diff --git a/desktop/src/components/SaaS/SaaSLogin.tsx b/desktop/src/components/SaaS/SaaSLogin.tsx new file mode 100644 index 0000000..e2d9758 --- /dev/null +++ b/desktop/src/components/SaaS/SaaSLogin.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail } from 'lucide-react'; + +interface SaaSLoginProps { + onLogin: (saasUrl: string, username: string, password: string) => Promise; + onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise; + initialUrl?: string; + isLoggingIn?: boolean; + error?: string | null; +} + +export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error }: SaaSLoginProps) { + const [serverUrl, setServerUrl] = useState(initialUrl || ''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isRegister, setIsRegister] = useState(false); + const [localError, setLocalError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLocalError(null); + + if (!serverUrl.trim()) { + setLocalError('请输入服务器地址'); + return; + } + if (!username.trim()) { + setLocalError('请输入用户名'); + return; + } + if (!password) { + setLocalError('请输入密码'); + return; + } + + if (isRegister) { + if (!email.trim()) { + setLocalError('请输入邮箱地址'); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + setLocalError('邮箱格式不正确'); + return; + } + if (password.length < 6) { + setLocalError('密码长度至少 6 个字符'); + return; + } + if (password !== confirmPassword) { + setLocalError('两次输入的密码不一致'); + return; + } + + if (onRegister) { + try { + await onRegister( + serverUrl.trim(), + username.trim(), + email.trim(), + password, + displayName.trim() || undefined, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setLocalError(message); + } + return; + } + } + + try { + await onLogin(serverUrl.trim(), username.trim(), password); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setLocalError(message); + } + }; + + const displayError = error || localError; + + const handleTabSwitch = (register: boolean) => { + setIsRegister(register); + setLocalError(null); + setConfirmPassword(''); + setEmail(''); + setDisplayName(''); + }; + + return ( +
+

+ {isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'} +

+

+ {isRegister + ? '创建账号以使用 ZCLAW 云端服务' + : '连接到 ZCLAW SaaS 平台,解锁云端能力'} +

+ + {/* Tab Switcher */} +
+ + {onRegister && ( + + )} +
+ + {/* Form */} +
+ {/* Server URL */} +
+ +
+ + setServerUrl(e.target.value)} + placeholder="https://saas.zclaw.com" + className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> +
+
+ + {/* Username */} +
+ + setUsername(e.target.value)} + placeholder="your-username" + autoComplete="username" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> +
+ + {/* Email (Register only) */} + {isRegister && ( +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> +
+
+ )} + + {/* Display Name (Register only, optional) */} + {isRegister && ( +
+ + setDisplayName(e.target.value)} + placeholder="ZCLAW User" + autoComplete="name" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> +
+ )} + + {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={isRegister ? '至少 6 个字符' : 'Enter password'} + autoComplete={isRegister ? 'new-password' : 'current-password'} + className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> + +
+
+ + {/* Confirm Password (Register only) */} + {isRegister && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + autoComplete="new-password" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" + disabled={isLoggingIn} + /> +
+ )} + + {/* Error Display */} + {displayError && ( +
+ + {displayError} +
+ )} + + {/* Submit Button */} + +
+
+ ); +} diff --git a/desktop/src/components/SaaS/SaaSSettings.tsx b/desktop/src/components/SaaS/SaaSSettings.tsx new file mode 100644 index 0000000..89890f6 --- /dev/null +++ b/desktop/src/components/SaaS/SaaSSettings.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { useSaaSStore } from '../../store/saasStore'; +import { SaaSLogin } from './SaaSLogin'; +import { SaaSStatus } from './SaaSStatus'; +import { Cloud, Info } from 'lucide-react'; + +export function SaaSSettings() { + const isLoggedIn = useSaaSStore((s) => s.isLoggedIn); + const account = useSaaSStore((s) => s.account); + const saasUrl = useSaaSStore((s) => s.saasUrl); + const connectionMode = useSaaSStore((s) => s.connectionMode); + const login = useSaaSStore((s) => s.login); + const register = useSaaSStore((s) => s.register); + const logout = useSaaSStore((s) => s.logout); + + const [showLogin, setShowLogin] = useState(!isLoggedIn); + const [loginError, setLoginError] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + const handleLogin = async (url: string, username: string, password: string) => { + setIsLoggingIn(true); + setLoginError(null); + try { + await login(url, username, password); + setShowLogin(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '登录失败'; + setLoginError(message); + } finally { + setIsLoggingIn(false); + } + }; + + const handleRegister = async ( + url: string, + username: string, + email: string, + password: string, + displayName?: string, + ) => { + setIsLoggingIn(true); + setLoginError(null); + try { + await register(url, username, email, password, displayName); + // register auto-logs in, no need for separate login call + setShowLogin(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '注册失败'; + setLoginError(message); + } finally { + setIsLoggingIn(false); + } + }; + + const handleLogout = () => { + logout(); + setShowLogin(true); + setLoginError(null); + }; + + return ( +
+
+
+ +
+
+

SaaS 账号

+

管理 ZCLAW 云端平台连接

+
+
+ + {/* Connection mode info */} +
+ + + 当前模式: {connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}。 + {connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'} + +
+ + {/* Login form or status display */} + {!showLogin ? ( + setShowLogin(true)} + /> + ) : ( + + )} + + {/* Features list when logged in */} + {isLoggedIn && !showLogin && ( +
+

+ 云端功能 +

+
+
+ + + +
+
+
+ )} +
+ ); +} + +function CloudFeatureRow({ + name, + description, + status, +}: { + name: string; + description: string; + status: 'active' | 'inactive'; +}) { + return ( +
+
+
{name}
+
{description}
+
+ + {status === 'active' ? '可用' : '需要订阅'} + +
+ ); +} diff --git a/desktop/src/components/SaaS/SaaSStatus.tsx b/desktop/src/components/SaaS/SaaSStatus.tsx new file mode 100644 index 0000000..bb92447 --- /dev/null +++ b/desktop/src/components/SaaS/SaaSStatus.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; +import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client'; +import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import { useSaaSStore } from '../../store/saasStore'; + +interface SaaSStatusProps { + isLoggedIn: boolean; + account: SaaSAccountInfo | null; + saasUrl: string; + onLogout: () => void; + onLogin: () => void; +} + +export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) { + const availableModels = useSaaSStore((s) => s.availableModels); + const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels); + + const [checkingHealth, setCheckingHealth] = useState(false); + const [healthOk, setHealthOk] = useState(null); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + if (isLoggedIn) { + fetchAvailableModels(); + } + }, [isLoggedIn, fetchAvailableModels]); + + async function checkHealth() { + setCheckingHealth(true); + setHealthOk(null); + try { + const response = await fetch(`${saasUrl}/api/health`, { + signal: AbortSignal.timeout(5000), + }); + setHealthOk(response.ok); + } catch { + setHealthOk(false); + } finally { + setCheckingHealth(false); + } + } + + if (isLoggedIn && account) { + const displayName = account.display_name || account.username; + const initial = displayName[0].toUpperCase(); + + return ( +
+ {/* Main status bar */} +
+
+
+ {initial} +
+
+
{displayName}
+
{saasUrl}
+ + {account.role} + +
+
+
+
+ + 已连接 +
+ + +
+
+ + {/* Expandable details */} + {showDetails && ( +
+ {/* Health Check */} +
+ 服务健康 +
+ {healthOk === null && !checkingHealth && ( + 未检测 + )} + {checkingHealth && } + {healthOk === true && ( +
+ 正常 +
+ )} + {healthOk === false && ( +
+ 不可达 +
+ )} + +
+
+ + {/* Available Models */} +
+
+ + + 可用模型 ({availableModels.length}) + +
+ {availableModels.length === 0 ? ( +

+ 暂无可用模型,请确认管理员已在后台配置 Provider 和 Model +

+ ) : ( +
+ {availableModels.map((model) => ( + + ))} +
+ )} +
+
+ )} +
+ ); + } + + return ( +
+
+ +
+
SaaS 平台
+
未连接
+
+
+ +
+ ); +} + +function ModelRow({ model }: { model: SaaSModelInfo }) { + return ( +
+ {model.alias || model.id} +
+ {model.supports_streaming && 流式} + {model.supports_vision && 视觉} + {(model.context_window / 1000).toFixed(0)}k +
+
+ ); +} diff --git a/desktop/src/components/Settings/SettingsLayout.tsx b/desktop/src/components/Settings/SettingsLayout.tsx index 655fe7d..588a7e7 100644 --- a/desktop/src/components/Settings/SettingsLayout.tsx +++ b/desktop/src/components/Settings/SettingsLayout.tsx @@ -18,6 +18,7 @@ import { Heart, Key, Database, + Cloud, } from 'lucide-react'; import { silentErrorHandler } from '../../lib/error-utils'; import { General } from './General'; @@ -37,6 +38,7 @@ import { TaskList } from '../TaskList'; import { HeartbeatConfig } from '../HeartbeatConfig'; import { SecureStorage } from './SecureStorage'; import { VikingPanel } from '../VikingPanel'; +import { SaaSSettings } from '../SaaS/SaaSSettings'; interface SettingsLayoutProps { onBack: () => void; @@ -54,6 +56,7 @@ type SettingsPage = | 'privacy' | 'security' | 'storage' + | 'saas' | 'viking' | 'audit' | 'tasks' @@ -72,6 +75,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] = { id: 'workspace', label: '工作区', icon: }, { id: 'privacy', label: '数据与隐私', icon: }, { id: 'storage', label: '安全存储', icon: }, + { id: 'saas', label: 'SaaS 平台', icon: }, { id: 'viking', label: '语义记忆', icon: }, { id: 'security', label: '安全状态', icon: }, { id: 'audit', label: '审计日志', icon: }, @@ -97,6 +101,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) { case 'workspace': return ; case 'privacy': return ; case 'storage': return ; + case 'saas': return ; case 'security': return (
diff --git a/desktop/src/lib/llm-service.ts b/desktop/src/lib/llm-service.ts index e1d033c..7b8ea30 100644 --- a/desktop/src/lib/llm-service.ts +++ b/desktop/src/lib/llm-service.ts @@ -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 = { 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): Promise { + 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): LLMServiceAdapter return new VolcengineLLMAdapter(finalConfig); case 'gateway': return new GatewayLLMAdapter(finalConfig); + case 'saas': + return new SaasLLMAdapter(finalConfig); case 'mock': default: return new MockLLMAdapter(finalConfig); diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts new file mode 100644 index 0000000..d8e2f48 --- /dev/null +++ b/desktop/src/lib/saas-client.ts @@ -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( + 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'); diff --git a/desktop/src/store/connectionStore.ts b/desktop/src/store/connectionStore.ts index d39656c..272b60f 100644 --- a/desktop/src/store/connectionStore.ts +++ b/desktop/src/store/connectionStore.ts @@ -213,6 +213,37 @@ export const useConnectionStore = create((set, get) => { try { set({ error: null }); + // === SaaS Relay Mode === + // Check connection mode from localStorage (set by saasStore). + // This takes priority over Tauri/Gateway when the user has selected SaaS mode. + const savedMode = localStorage.getItem('zclaw-connection-mode'); + if (savedMode === 'saas') { + const { loadSaaSSession, saasClient } = await import('../lib/saas-client'); + const session = loadSaaSSession(); + + if (!session || !session.token || !session.saasUrl) { + throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台'); + } + + log.debug('Using SaaS relay mode:', session.saasUrl); + + // Configure the singleton client + saasClient.setBaseUrl(session.saasUrl); + saasClient.setToken(session.token); + + // Health check via GET /api/v1/relay/models + try { + await saasClient.listModels(); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + throw new Error(`SaaS 平台连接失败: ${errMsg}`); + } + + set({ connectionState: 'connected', gatewayVersion: 'saas-relay' }); + log.debug('Connected to SaaS relay'); + return; + } + // === Internal Kernel Mode (Tauri) === // Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available const useInternalKernel = isTauriRuntime(); diff --git a/desktop/src/store/index.ts b/desktop/src/store/index.ts index 26da3bb..fe7e875 100644 --- a/desktop/src/store/index.ts +++ b/desktop/src/store/index.ts @@ -35,6 +35,10 @@ export type { SessionStore, SessionStateSlice, SessionActionsSlice, Session, Ses export { useMemoryGraphStore } from './memoryGraphStore'; export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore'; +// === SaaS Store === +export { useSaaSStore } from './saasStore'; +export type { SaaSStore, SaaSStateSlice, SaaSActionsSlice, ConnectionMode } from './saasStore'; + // === Browser Hand Store === export { useBrowserHandStore } from './browserHandStore'; diff --git a/desktop/src/store/saasStore.ts b/desktop/src/store/saasStore.ts new file mode 100644 index 0000000..164314a --- /dev/null +++ b/desktop/src/store/saasStore.ts @@ -0,0 +1,293 @@ +/** + * SaaS Store - SaaS Platform Connection State Management + * + * Manages SaaS login state, account info, connection mode, + * and available models. Persists auth state to localStorage + * via saas-client helpers. + * + * Connection modes: + * - 'tauri': Local Kernel via Tauri (default) + * - 'gateway': External Gateway via WebSocket + * - 'saas': SaaS backend relay + */ + +import { create } from 'zustand'; +import { + saasClient, + SaaSApiError, + loadSaaSSession, + saveSaaSSession, + clearSaaSSession, + saveConnectionMode, + loadConnectionMode, + type SaaSAccountInfo, + type SaaSModelInfo, + type SaaSLoginResponse, +} from '../lib/saas-client'; +import { createLogger } from '../lib/logger'; + +const log = createLogger('SaaSStore'); + +// === Types === + +export type ConnectionMode = 'tauri' | 'gateway' | 'saas'; + +export interface SaaSStateSlice { + isLoggedIn: boolean; + account: SaaSAccountInfo | null; + saasUrl: string; + authToken: string | null; + connectionMode: ConnectionMode; + availableModels: SaaSModelInfo[]; + isLoading: boolean; + error: string | null; +} + +export interface SaaSActionsSlice { + login: (saasUrl: string, username: string, password: string) => Promise; + register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise; + logout: () => void; + setConnectionMode: (mode: ConnectionMode) => void; + fetchAvailableModels: () => Promise; + clearError: () => void; + restoreSession: () => void; +} + +export type SaaSStore = SaaSStateSlice & SaaSActionsSlice; + +// === Constants === + +const DEFAULT_SAAS_URL = 'https://saas.zclaw.com'; + +// === Helpers === + +/** Determine the initial connection mode from persisted state */ +function resolveInitialMode(session: ReturnType): ConnectionMode { + const persistedMode = loadConnectionMode(); + if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') { + return persistedMode; + } + return session ? 'saas' : 'tauri'; +} + +// === Store Implementation === + +export const useSaaSStore = create((set, get) => { + // Restore session from localStorage on init + const session = loadSaaSSession(); + const initialMode = resolveInitialMode(session); + + // If session exists, configure the singleton client + if (session) { + saasClient.setBaseUrl(session.saasUrl); + saasClient.setToken(session.token); + } + + return { + // === Initial State === + isLoggedIn: session !== null, + account: session?.account ?? null, + saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL, + authToken: session?.token ?? null, + connectionMode: initialMode, + availableModels: [], + isLoading: false, + error: null, + + // === Actions === + + login: async (saasUrl: string, username: string, password: string) => { + set({ isLoading: true, error: null }); + + try { + const trimmedUrl = saasUrl.trim(); + const trimmedUsername = username.trim(); + + if (!trimmedUrl) { + throw new Error('请输入服务器地址'); + } + if (!trimmedUsername) { + throw new Error('请输入用户名'); + } + if (!password) { + throw new Error('请输入密码'); + } + + const normalizedUrl = trimmedUrl.replace(/\/+$/, ''); + + // Configure singleton client and attempt login + saasClient.setBaseUrl(normalizedUrl); + const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password); + + // Persist session + const sessionData = { + token: loginData.token, + account: loginData.account, + saasUrl: normalizedUrl, + }; + saveSaaSSession(sessionData); + saveConnectionMode('saas'); + + set({ + isLoggedIn: true, + account: loginData.account, + saasUrl: normalizedUrl, + authToken: loginData.token, + connectionMode: 'saas', + isLoading: false, + error: null, + }); + + // Fetch available models in background (non-blocking) + get().fetchAvailableModels().catch((err: unknown) => { + log.warn('Failed to fetch models after login:', err); + }); + } catch (err: unknown) { + const message = err instanceof SaaSApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + + const isNetworkError = message.includes('Failed to fetch') + || message.includes('NetworkError') + || message.includes('ECONNREFUSED') + || message.includes('timeout'); + + const userMessage = isNetworkError + ? `无法连接到 SaaS 服务器: ${get().saasUrl}` + : message; + + set({ isLoading: false, error: userMessage }); + throw new Error(userMessage); + } + }, + + register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => { + set({ isLoading: true, error: null }); + + try { + const trimmedUrl = saasUrl.trim(); + if (!trimmedUrl) { + throw new Error('请输入服务器地址'); + } + if (!username.trim()) { + throw new Error('请输入用户名'); + } + if (!email.trim()) { + throw new Error('请输入邮箱'); + } + if (!password) { + throw new Error('请输入密码'); + } + + const normalizedUrl = trimmedUrl.replace(/\/+$/, ''); + + saasClient.setBaseUrl(normalizedUrl); + const registerData: SaaSLoginResponse = await saasClient.register({ + username: username.trim(), + email: email.trim(), + password, + display_name: displayName, + }); + + const sessionData = { + token: registerData.token, + account: registerData.account, + saasUrl: normalizedUrl, + }; + saveSaaSSession(sessionData); + saveConnectionMode('saas'); + + set({ + isLoggedIn: true, + account: registerData.account, + saasUrl: normalizedUrl, + authToken: registerData.token, + connectionMode: 'saas', + isLoading: false, + error: null, + }); + + get().fetchAvailableModels().catch((err: unknown) => { + log.warn('Failed to fetch models after register:', err); + }); + } catch (err: unknown) { + const message = err instanceof SaaSApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + + set({ isLoading: false, error: message }); + throw new Error(message); + } + }, + + logout: () => { + saasClient.setToken(null); + clearSaaSSession(); + saveConnectionMode('tauri'); + + set({ + isLoggedIn: false, + account: null, + authToken: null, + connectionMode: 'tauri', + availableModels: [], + error: null, + }); + }, + + setConnectionMode: (mode: ConnectionMode) => { + const { isLoggedIn } = get(); + + // Cannot switch to SaaS mode if not logged in + if (mode === 'saas' && !isLoggedIn) { + return; + } + + saveConnectionMode(mode); + set({ connectionMode: mode }); + }, + + fetchAvailableModels: async () => { + const { isLoggedIn, authToken, saasUrl } = get(); + + if (!isLoggedIn || !authToken) { + set({ availableModels: [] }); + return; + } + + try { + saasClient.setBaseUrl(saasUrl); + saasClient.setToken(authToken); + const models = await saasClient.listModels(); + set({ availableModels: models }); + } catch (err: unknown) { + log.warn('Failed to fetch available models:', err); + // Do not set error state - model fetch failure is non-critical + set({ availableModels: [] }); + } + }, + + clearError: () => { + set({ error: null }); + }, + + restoreSession: () => { + const restored = loadSaaSSession(); + if (restored) { + saasClient.setBaseUrl(restored.saasUrl); + saasClient.setToken(restored.token); + set({ + isLoggedIn: true, + account: restored.account, + saasUrl: restored.saasUrl, + authToken: restored.token, + connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri', + }); + } + }, + }; +});