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
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
518 lines
17 KiB
TypeScript
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');
|