/** * 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: Bearer token in Authorization header */ // === Storage Keys === const SAASTOKEN_KEY = 'zclaw-saas-token'; const SAASURL_KEY = 'zclaw-saas-url'; const SAASACCOUNT_KEY = 'zclaw-saas-account'; const SAASMODE_KEY = 'zclaw-connection-mode'; // === Types === /** Public account info returned by the SaaS backend */ export interface SaaSAccountInfo { id: string; username: string; email: string; display_name: string; role: string; status: string; totp_enabled: boolean; created_at: string; } /** A model available for relay through the SaaS backend */ export interface SaaSModelInfo { id: string; provider_id: string; alias: string; context_window: number; max_output_tokens: number; supports_streaming: boolean; supports_vision: boolean; } /** Config item from the SaaS backend */ export interface SaaSConfigItem { id: string; category: string; key_path: string; value_type: string; current_value: string | null; default_value: string | null; source: string; description: string | null; requires_restart: boolean; created_at: string; updated_at: string; } /** SaaS API error shape */ export interface SaaSErrorResponse { error: string; message: string; } /** Login response from POST /api/v1/auth/login */ export interface SaaSLoginResponse { token: string; account: SaaSAccountInfo; } /** Refresh response from POST /api/v1/auth/refresh */ interface SaaSRefreshResponse { token: string; } // === Error Class === export class SaaSApiError extends Error { constructor( public readonly status: number, public readonly code: string, message: string, ) { super(message); this.name = 'SaaSApiError'; } } // === Session Persistence === export interface SaaSSession { token: string; account: SaaSAccountInfo | null; saasUrl: string; } /** * Load a persisted SaaS session from localStorage. * Returns null if no valid session exists. */ export function loadSaaSSession(): SaaSSession | null { try { const token = localStorage.getItem(SAASTOKEN_KEY); const saasUrl = localStorage.getItem(SAASURL_KEY); const accountRaw = localStorage.getItem(SAASACCOUNT_KEY); if (!token || !saasUrl) { return null; } const account: SaaSAccountInfo | null = accountRaw ? (JSON.parse(accountRaw) as SaaSAccountInfo) : null; return { token, account, saasUrl }; } catch { // Corrupted data - clear all clearSaaSSession(); return null; } } /** * Persist a SaaS session to localStorage. */ export function saveSaaSSession(session: SaaSSession): void { localStorage.setItem(SAASTOKEN_KEY, session.token); localStorage.setItem(SAASURL_KEY, session.saasUrl); if (session.account) { localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account)); } } /** * Clear the persisted SaaS session from localStorage. */ export function clearSaaSSession(): void { localStorage.removeItem(SAASTOKEN_KEY); localStorage.removeItem(SAASURL_KEY); localStorage.removeItem(SAASACCOUNT_KEY); } /** * Persist the connection mode to localStorage. */ export function saveConnectionMode(mode: string): void { localStorage.setItem(SAASMODE_KEY, mode); } /** * Load the connection mode from localStorage. * Returns null if not set. */ export function loadConnectionMode(): string | null { return localStorage.getItem(SAASMODE_KEY); } // === Client Implementation === export class SaaSClient { private baseUrl: string; private token: string | null = null; 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 */ setToken(token: string | null): void { this.token = token; } /** Check if the client has an auth token */ isAuthenticated(): boolean { return !!this.token; } // --- Core HTTP --- /** * Make an authenticated request and parse the JSON response. * Throws SaaSApiError on non-ok responses. */ private async request( method: string, path: string, body?: unknown, timeoutMs = 15000, ): Promise { const headers: Record = { 'Content-Type': 'application/json', }; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } const response = await fetch(`${this.baseUrl}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), }); // Handle 401 specially - caller may want to trigger re-auth if (response.status === 401) { 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; } // --- Health --- /** * Quick connectivity check against the SaaS backend. */ async healthCheck(): Promise { try { await this.request('GET', '/api/health', undefined, 5000); return true; } catch { return false; } } // --- Auth Endpoints --- /** * Login with username and password. * Auto-sets the client token on success. */ async login(username: string, password: string): Promise { const data = await this.request( 'POST', '/api/v1/auth/login', { username, password }, ); this.token = data.token; return data; } /** * Register a new account. * Auto-sets the client token on success. */ async register(data: { username: string; email: string; password: string; display_name?: string; }): Promise { const result = await this.request( 'POST', '/api/v1/auth/register', data, ); this.token = result.token; return result; } /** * Get the current authenticated user's account info. */ async me(): Promise { return this.request('GET', '/api/v1/auth/me'); } /** * Refresh the current token. * Auto-updates the client token on success. */ async refreshToken(): Promise { const data = await this.request('POST', '/api/v1/auth/refresh'); this.token = data.token; return data.token; } // --- Model Endpoints --- /** * List available models for relay. * Only returns enabled models from enabled providers. */ async listModels(): Promise { return this.request('GET', '/api/v1/relay/models'); } // --- Chat Relay --- /** * Send a chat completion request via the SaaS relay. * Returns the raw Response object to support both streaming and non-streaming. * * The caller is responsible for: * - Reading the response body (JSON or SSE stream) * - Handling errors from the response */ async chatCompletion( body: unknown, signal?: AbortSignal, ): Promise { const headers: Record = { 'Content-Type': 'application/json', }; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } // Use caller's AbortSignal if provided, otherwise default 5min timeout const effectiveSignal = signal ?? AbortSignal.timeout(300_000); const response = await fetch( `${this.baseUrl}/api/v1/relay/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body), signal: effectiveSignal, }, ); return response; } // --- Config Endpoints --- /** * List config items, optionally filtered by category. */ async listConfig(category?: string): Promise { const qs = category ? `?category=${encodeURIComponent(category)}` : ''; return this.request('GET', `/api/v1/config/items${qs}`); } } // === 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');