Files
zclaw_openfang/desktop/src/lib/saas-client.ts
iven ea00c32c08
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat(saas): industry agent template assignment system
Phase 1-8 of industry-agent-delivery plan:

- DB migration: accounts.assigned_template_id (ON DELETE SET NULL)
- SaaS API: 4 new endpoints (assign/get/unassign/create-agent)
- Service layer: assign_template_to_account, get_assigned_template, unassign_template, create_agent_from_template)
- Types: AssignTemplateRequest, AgentConfigFromTemplate (capabilities merged into tools)
- Frontend SaaS Client: assignTemplate, getAssignedTemplate, unassignTemplate, createAgentFromTemplate
- saasStore: assignedTemplate state + login auto-fetch + actions
- saas-relay-client: fix unused import and saasUrl reference error
- connectionStore: fix relayModel undefined error
- capabilities default to glm-4-flash

- Route registration: new template assignment routes

Cospec and handlers consolidated

Build: cargo check --workspace PASS, tsc --noEmit Pass
2026-04-03 13:31:58 +08:00

518 lines
17 KiB
TypeScript

/**
* ZCLAW SaaS Client
*
* Typed HTTP client for the ZCLAW SaaS backend API (v1).
* Handles authentication, model listing, chat relay, and config management.
*
* API base path: /api/v1/...
* Auth: HttpOnly cookie (primary) + Bearer token fallback
*
* Security: Tokens are NO LONGER persisted to localStorage.
* The backend sets HttpOnly cookies on login/register/refresh.
* On page reload, cookie-based auth is verified via GET /api/v1/auth/me.
*
* Architecture: This file is the main entry point and thin shell.
* Types, errors, session helpers, and API methods are in sub-modules:
* - saas-types.ts — all type/interface definitions
* - saas-errors.ts — SaaSApiError class
* - saas-session.ts — session persistence (load/save/clear)
* - saas-auth.ts — login/register/TOTP methods (mixin)
* - saas-relay.ts — relay tasks, chat completion, usage (mixin)
* - saas-prompt.ts — prompt OTA methods (mixin)
* - saas-telemetry.ts — telemetry reporting methods (mixin)
*/
// === Re-export everything from sub-modules ===
export type {
SaaSAccountInfo,
SaaSModelInfo,
SaaSConfigItem,
SaaSErrorResponse,
SaaSLoginResponse,
SaaSRefreshResponse,
TotpSetupResponse,
TotpResultResponse,
DeviceInfo,
RelayTaskInfo,
SyncConfigRequest,
ConfigDiffItem,
ConfigDiffResponse,
ConfigSyncResult,
PaginatedResponse,
PromptTemplateInfo,
PromptVersionInfo,
PromptVariable,
PromptCheckResult,
PromptUpdatePayload,
ProviderInfo,
CreateProviderRequest,
UpdateProviderRequest,
ModelInfo,
CreateModelRequest,
UpdateModelRequest,
AccountApiKeyInfo,
CreateApiKeyRequest,
UsageStats,
AccountPublic,
UpdateAccountRequest,
TokenInfo,
CreateTokenRequest,
OperationLogInfo,
DashboardStats,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
PermissionTemplate,
CreateTemplateRequest,
AgentTemplateAvailable,
AgentTemplateFull,
AgentConfigFromTemplate,
} from './saas-types';
export { SaaSApiError } from './saas-errors';
export type { SaaSSession } from './saas-session';
export {
loadSaaSSession,
loadSaaSSessionSync,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
loadConnectionMode,
} from './saas-session';
// === Imports for the class implementation ===
import type {
SaaSAccountInfo,
SaaSLoginResponse,
TotpSetupResponse,
TotpResultResponse,
SaaSModelInfo,
SaaSConfigItem,
DeviceInfo,
SyncConfigRequest,
ConfigDiffResponse,
ConfigSyncResult,
SaaSErrorResponse,
RelayTaskInfo,
UsageStats,
PromptCheckResult,
PromptTemplateInfo,
PromptVersionInfo,
PaginatedResponse,
AgentTemplateAvailable,
AgentTemplateFull,
AgentConfigFromTemplate,
} from './saas-types';
import { SaaSApiError } from './saas-errors';
import { clearSaaSSession } from './saas-session';
import { createLogger } from './logger';
const saasLog = createLogger('saas-client');
import { installAuthMethods } from './saas-auth';
import { installRelayMethods } from './saas-relay';
import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry';
import { installBillingMethods } from './saas-billing';
// === Client Implementation ===
export class SaaSClient {
private baseUrl: string;
private token: string | null = null;
/**
* Refresh mutex: shared Promise to prevent concurrent token refresh.
* When multiple requests hit 401 simultaneously, they all await the same
* refresh Promise instead of triggering N parallel refresh calls.
*/
private _refreshPromise: Promise<string> | null = null;
/**
* Thread-safe token refresh — coalesces concurrent refresh attempts into one.
* First caller triggers the actual refresh; subsequent callers await the same Promise.
*/
async refreshMutex(): Promise<string> {
if (this._refreshPromise) return this._refreshPromise;
this._refreshPromise = this.refreshToken().finally(() => {
this._refreshPromise = null;
});
return this._refreshPromise;
}
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
/** Update the base URL (e.g. when user changes server address) */
setBaseUrl(url: string): void {
this.baseUrl = url.replace(/\/+$/, '');
}
/** Get the current base URL */
getBaseUrl(): string {
return this.baseUrl;
}
/** Set or clear the auth token (in-memory only, never persisted) */
setToken(token: string | null): void {
this.token = token;
}
/** Check if the client is authenticated (token in memory or cookie-based) */
isAuthenticated(): boolean {
return !!this.token || this._cookieAuth;
}
/** Track cookie-based auth state (page reload) */
private _cookieAuth: boolean = false;
/**
* Attempt to restore auth state from HttpOnly cookie.
* Called on page reload when no token is in memory.
* Returns account info if cookie is valid, null otherwise.
*/
async restoreFromCookie(): Promise<SaaSAccountInfo | null> {
try {
const account = await this.me();
this._cookieAuth = true;
return account;
} catch (e) {
saasLog.debug('Cookie auth restore failed', { error: e });
this._cookieAuth = false;
return null;
}
}
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
private _isAuthEndpoint(path: string): boolean {
return path.includes('/auth/login') || path.includes('/auth/register') || path.includes('/auth/refresh');
}
// --- Core HTTP ---
/** Track whether the server appears reachable */
private _serverReachable: boolean = true;
/** Check if the SaaS server was last known to be reachable */
isServerReachable(): boolean {
return this._serverReachable;
}
/**
* Make an authenticated request with automatic retry on transient failures.
* Retries up to 2 times with exponential backoff (1s, 2s).
* Throws SaaSApiError on non-ok responses.
*/
public async request<T>(
method: string,
path: string,
body?: unknown,
timeoutMs = 15000,
_isRefreshRetry = false,
): Promise<T> {
const maxRetries = 2;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Bearer token as fallback — primary auth is HttpOnly cookie
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
credentials: 'include', // Send HttpOnly cookies
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeoutMs),
});
this._serverReachable = true;
// 401: 尝试刷新 Token 后重试 (防止递归)
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
try {
const newToken = await this.refreshMutex();
if (newToken) {
return this.request<T>(method, path, body, timeoutMs, true);
}
} catch (refreshErr) {
// Token refresh failed — clear session and trigger logout
clearSaaSSession().catch(e => saasLog.debug('Failed to clear SaaS session on refresh failure', { error: e })); // async cleanup, fire-and-forget
localStorage.removeItem('zclaw-connection-mode');
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
}
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
}
if (!response.ok) {
const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null;
throw new SaaSApiError(
response.status,
errorBody?.error || 'UNKNOWN',
errorBody?.message || `请求失败 (${response.status})`,
);
}
// 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
} catch (err: unknown) {
const isNetworkError = err instanceof TypeError
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
if (isNetworkError && attempt < maxRetries) {
this._serverReachable = false;
const delay = baseDelay * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, delay));
continue;
}
this._serverReachable = false;
if (err instanceof SaaSApiError) throw err;
throw new SaaSApiError(0, 'NETWORK_ERROR', `网络错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Unreachable, but TypeScript needs it
throw new SaaSApiError(0, 'UNKNOWN', '请求失败');
}
// --- Health ---
/**
* Quick connectivity check against the SaaS backend.
*/
async healthCheck(): Promise<boolean> {
try {
await this.request<unknown>('GET', '/api/health', undefined, 5000);
return true;
} catch (e) {
saasLog.debug('Health check failed', { error: e });
return false;
}
}
// --- Device Endpoints ---
/**
* Register or update this device with the SaaS backend.
* Uses UPSERT semantics — same (account, device_id) updates last_seen_at.
*/
async registerDevice(params: {
device_id: string;
device_name?: string;
platform?: string;
app_version?: string;
}): Promise<void> {
await this.request<unknown>('POST', '/api/v1/devices/register', params);
}
/**
* Send a heartbeat to indicate the device is still active.
* Also sends platform and app_version so the backend can detect client upgrades.
*/
async deviceHeartbeat(deviceId: string): Promise<void> {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
device_id: deviceId,
platform: typeof navigator !== 'undefined' ? navigator.platform : undefined,
app_version: appVersion,
});
}
/**
* List devices registered for the current account.
*/
async listDevices(): Promise<DeviceInfo[]> {
const res = await this.request<{ items: DeviceInfo[] }>('GET', '/api/v1/devices');
return res.items;
}
// --- Model Endpoints ---
/**
* List available models for relay.
* Only returns enabled models from enabled providers.
*/
async listModels(): Promise<SaaSModelInfo[]> {
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
}
// --- Config Endpoints ---
/**
* List config items, optionally filtered by category.
*/
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
const res = await this.request<{ items: SaaSConfigItem[] }>('GET', `/api/v1/config/items${qs}`);
return res.items;
}
/** Compute config diff between client and SaaS (read-only) */
async computeConfigDiff(request: SyncConfigRequest): Promise<ConfigDiffResponse> {
return this.request<ConfigDiffResponse>('POST', '/api/v1/config/diff', request);
}
/** Sync config from client to SaaS (push) or merge */
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
}
/**
* Pull all config items from SaaS (for startup auto-sync).
* Returns configs updated since the given timestamp, or all if since is omitted.
*/
async pullConfig(since?: string): Promise<{
configs: Array<{
key: string
category: string
value: string | null
value_type: string
default: string | null
updated_at: string
}>
pulled_at: string
}> {
const qs = since ? `?since=${encodeURIComponent(since)}` : '';
return this.request('GET', '/api/v1/config/pull' + qs);
}
// --- Agent Template Endpoints ---
/**
* List available agent templates (lightweight).
* Used for template selection during onboarding.
*/
async fetchAvailableTemplates(): Promise<AgentTemplateAvailable[]> {
return this.request<AgentTemplateAvailable[]>('GET', '/api/v1/agent-templates/available');
}
/**
* Get full template details by ID.
* Returns all fields needed to create an agent from template.
*/
async fetchTemplateFull(id: string): Promise<AgentTemplateFull> {
return this.request<AgentTemplateFull>('GET', `/api/v1/agent-templates/${id}/full`);
}
// --- Template Assignment ---
/**
* Assign a template to the current account.
* Records the user's industry choice for onboarding flow control.
*/
async assignTemplate(templateId: string): Promise<AgentTemplateFull> {
return this.request<AgentTemplateFull>('POST', '/api/v1/accounts/me/assign-template', {
template_id: templateId,
});
}
/**
* Get the template currently assigned to the account.
* Returns null if no template is assigned.
*/
async getAssignedTemplate(): Promise<AgentTemplateFull | null> {
return this.request<AgentTemplateFull | null>('GET', '/api/v1/accounts/me/assigned-template');
}
/**
* Unassign the current template from the account.
*/
async unassignTemplate(): Promise<void> {
await this.request<unknown>('DELETE', '/api/v1/accounts/me/assigned-template');
}
/**
* Create an agent configuration from a template.
* Merges capabilities into tools, applies default model fallback.
*/
async createAgentFromTemplate(templateId: string): Promise<AgentConfigFromTemplate> {
return this.request<AgentConfigFromTemplate>('POST', `/api/v1/agent-templates/${templateId}/create-agent`);
}
}
// === Install mixin methods ===
installAuthMethods(SaaSClient);
installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
installBillingMethods(SaaSClient);
// === API Method Type Declarations ===
// These methods are installed at runtime by installXxxMethods() in saas-*.ts.
// We declare them here via interface merging so TypeScript knows they exist on SaaSClient.
export interface SaaSClient {
// --- Auth (saas-auth.ts) ---
login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse>;
register(data: { username: string; email: string; password: string; display_name?: string }): Promise<SaaSLoginResponse>;
me(): Promise<SaaSAccountInfo>;
refreshToken(): Promise<string>;
changePassword(oldPassword: string, newPassword: string): Promise<void>;
setupTotp(): Promise<TotpSetupResponse>;
verifyTotp(code: string): Promise<TotpResultResponse>;
disableTotp(password: string): Promise<TotpResultResponse>;
// --- Relay (saas-relay.ts) ---
listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]>;
getRelayTask(taskId: string): Promise<RelayTaskInfo>;
retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }>;
chatCompletion(body: unknown, signal?: AbortSignal): Promise<Response>;
getUsage(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats>;
// --- Prompt OTA (saas-prompt.ts) ---
checkPromptUpdates(deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult>;
listPrompts(params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise<PaginatedResponse<PromptTemplateInfo>>;
getPrompt(name: string): Promise<PromptTemplateInfo>;
listPromptVersions(name: string): Promise<PromptVersionInfo[]>;
getPromptVersion(name: string, version: number): Promise<PromptVersionInfo>;
// --- Telemetry (saas-telemetry.ts) ---
reportTelemetry(data: {
device_id: string;
app_version: string;
entries: Array<{
model_id: string;
input_tokens: number;
output_tokens: number;
latency_ms?: number;
success: boolean;
error_type?: string;
timestamp: string;
connection_mode: string;
}>;
}): Promise<{ accepted: number; rejected: number }>;
reportAuditSummary(data: {
device_id: string;
entries: Array<{
action: string;
target: string;
result: string;
timestamp: string;
}>;
}): Promise<{ accepted: number; total: number }>;
// --- Billing (saas-billing.ts) ---
incrementUsageDimension(dimension: string, count?: number): Promise<import('./saas-billing').UsageIncrementResult>;
reportUsageFireAndForget(dimension: string, count?: number): void;
}
// === Singleton ===
/**
* Global SaaS client singleton.
* Initialized with a default URL; the URL and token are updated on login.
*/
export const saasClient = new SaaSClient('https://saas.zclaw.com');