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:
iven
2026-03-27 14:21:23 +08:00
parent a66b675675
commit 15450ca895
9 changed files with 1407 additions and 1 deletions

View File

@@ -18,7 +18,7 @@ import { DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL } from '../constants/models';
// === Types ===
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'mock';
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'saas' | 'mock';
export interface LLMConfig {
provider: LLMProvider;
@@ -77,6 +77,12 @@ const DEFAULT_CONFIGS: Record<LLMProvider, LLMConfig> = {
temperature: 0.7,
timeout: 60000,
},
saas: {
provider: 'saas',
maxTokens: 4096,
temperature: 0.7,
timeout: 300000, // 5 min for streaming
},
mock: {
provider: 'mock',
maxTokens: 100,
@@ -412,6 +418,85 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
}
}
// === SaaS Relay Adapter (via SaaS backend) ===
class SaasLLMAdapter implements LLMServiceAdapter {
private config: LLMConfig;
constructor(config: LLMConfig) {
this.config = { ...DEFAULT_CONFIGS.saas, ...config };
}
async complete(messages: LLMMessage[], options?: Partial<LLMConfig>): Promise<LLMResponse> {
const config = { ...this.config, ...options };
const startTime = Date.now();
// Dynamic import to avoid circular dependency
const { useSaaSStore } = await import('../store/saasStore');
const { saasUrl, authToken } = useSaaSStore.getState();
if (!saasUrl || !authToken) {
throw new Error('[SaaS] 未登录 SaaS 平台,请先在设置中登录');
}
// Dynamic import of SaaSClient singleton
const { saasClient } = await import('./saas-client');
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
const openaiBody = {
model: config.model || 'default',
messages,
max_tokens: config.maxTokens || 4096,
temperature: config.temperature ?? 0.7,
stream: false,
};
const response = await saasClient.chatCompletion(
openaiBody,
AbortSignal.timeout(config.timeout || 300000),
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'unknown',
message: `SaaS relay 请求失败 (${response.status})`,
}));
throw new Error(
`[SaaS] ${errorData.message || errorData.error || `请求失败: ${response.status}`}`,
);
}
const data = await response.json();
const latencyMs = Date.now() - startTime;
return {
content: data.choices?.[0]?.message?.content || '',
tokensUsed: {
input: data.usage?.prompt_tokens || 0,
output: data.usage?.completion_tokens || 0,
},
model: data.model,
latencyMs,
};
}
isAvailable(): boolean {
// Check synchronously via localStorage for availability check
// Dynamic import would be async, so we use a simpler check
try {
const token = localStorage.getItem('zclaw-saas-token');
return !!token;
} catch {
return false;
}
}
getProvider(): LLMProvider {
return 'saas';
}
}
// === Factory ===
let cachedAdapter: LLMServiceAdapter | null = null;
@@ -427,6 +512,8 @@ export function createLLMAdapter(config?: Partial<LLMConfig>): LLMServiceAdapter
return new VolcengineLLMAdapter(finalConfig);
case 'gateway':
return new GatewayLLMAdapter(finalConfig);
case 'saas':
return new SaasLLMAdapter(finalConfig);
case 'mock':
default:
return new MockLLMAdapter(finalConfig);

View 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');