feat(saas): Phase 3 桌面端 SaaS 集成 — 客户端、Store、UI、LLM 适配器
- saas-client.ts: SaaS HTTP 客户端 (登录/注册/Token/模型列表/Chat Relay/配置同步) - saasStore.ts: Zustand 状态管理 (登录态、连接模式、可用模型、localStorage 持久化) - connectionStore.ts: 集成 SaaS 模式分支 (connect() 优先检查 SaaS 连接模式) - llm-service.ts: SaasLLMAdapter 实现 (通过 SaaS Relay 代理 LLM 调用) - SaaSLogin.tsx: 登录/注册表单 (服务器地址、用户名、密码、邮箱) - SaaSStatus.tsx: 连接状态展示 (账号信息、健康检查、可用模型列表) - SaaSSettings.tsx: SaaS 设置页面入口 (登录态切换、功能列表) - SettingsLayout.tsx: 添加 SaaS 平台菜单项 - store/index.ts: 导出 useSaaSStore
This commit is contained in:
361
desktop/src/lib/saas-client.ts
Normal file
361
desktop/src/lib/saas-client.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
timeoutMs = 15000,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<T>;
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth Endpoints ---
|
||||
|
||||
/**
|
||||
* Login with username and password.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
async login(username: string, password: string): Promise<SaaSLoginResponse> {
|
||||
const data = await this.request<SaaSLoginResponse>(
|
||||
'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<SaaSLoginResponse> {
|
||||
const result = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/register', data,
|
||||
);
|
||||
this.token = result.token;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's account info.
|
||||
*/
|
||||
async me(): Promise<SaaSAccountInfo> {
|
||||
return this.request<SaaSAccountInfo>('GET', '/api/v1/auth/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the current token.
|
||||
* Auto-updates the client token on success.
|
||||
*/
|
||||
async refreshToken(): Promise<string> {
|
||||
const data = await this.request<SaaSRefreshResponse>('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<SaaSModelInfo[]> {
|
||||
return this.request<SaaSModelInfo[]>('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<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<SaaSConfigItem[]> {
|
||||
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request<SaaSConfigItem[]>('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');
|
||||
Reference in New Issue
Block a user