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:
295
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
295
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
@@ -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<void>;
|
||||
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
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<string | null>(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 (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
{isRegister
|
||||
? '创建账号以使用 ZCLAW 云端服务'
|
||||
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
|
||||
</p>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-5 border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(false)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
!isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录
|
||||
</span>
|
||||
</button>
|
||||
{onRegister && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(true)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
注册
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
服务器地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-url"
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="saas-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
邮箱
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Name (Register only, optional) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
显示名称 <span className="text-gray-400 font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
id="saas-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="saas-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
id="saas-confirm-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{isRegister ? '注册中...' : '登录中...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isRegister ? (
|
||||
<><UserPlus className="w-4 h-4" />注册</>
|
||||
) : (
|
||||
<><LogIn className="w-4 h-4" />登录</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
158
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-9 h-9 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||
<Cloud className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">SaaS 账号</h1>
|
||||
<p className="text-sm text-gray-500">管理 ZCLAW 云端平台连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection mode info */}
|
||||
<div className="flex items-start gap-2 text-sm text-gray-500 bg-blue-50 rounded-lg border border-blue-100 p-3 mb-5">
|
||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
|
||||
<span>
|
||||
当前模式: <strong className="text-gray-700">{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}</strong>。
|
||||
{connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Login form or status display */}
|
||||
{!showLogin ? (
|
||||
<SaaSStatus
|
||||
isLoggedIn={isLoggedIn}
|
||||
account={account}
|
||||
saasUrl={saasUrl}
|
||||
onLogout={handleLogout}
|
||||
onLogin={() => setShowLogin(true)}
|
||||
/>
|
||||
) : (
|
||||
<SaaSLogin
|
||||
onLogin={handleLogin}
|
||||
onRegister={handleRegister}
|
||||
initialUrl={saasUrl}
|
||||
isLoggingIn={isLoggingIn}
|
||||
error={loginError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Features list when logged in */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
云端功能
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<CloudFeatureRow
|
||||
name="云端同步"
|
||||
description="对话记录和配置自动同步到云端"
|
||||
status="active"
|
||||
/>
|
||||
<CloudFeatureRow
|
||||
name="团队协作"
|
||||
description="与团队成员共享 Agent 和技能"
|
||||
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||
/>
|
||||
<CloudFeatureRow
|
||||
name="高级分析"
|
||||
description="使用统计和用量分析仪表板"
|
||||
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudFeatureRow({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'active' | 'inactive';
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-500">{description}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
status === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{status === 'active' ? '可用' : '需要订阅'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
172
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
@@ -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<boolean | null>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Main status bar */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-emerald-500 flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 text-sm">{displayName}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{saasUrl}</div>
|
||||
<span className="inline-block mt-0.5 text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||
{account.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
<span>已连接</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{showDetails && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
{/* Health Check */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">服务健康</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{healthOk === null && !checkingHealth && (
|
||||
<span className="text-xs text-gray-400">未检测</span>
|
||||
)}
|
||||
{checkingHealth && <Loader2 className="w-4 h-4 animate-spin text-gray-400" />}
|
||||
{healthOk === true && (
|
||||
<div className="flex items-center gap-1 text-green-600 text-sm">
|
||||
<CheckCircle className="w-4 h-4" />正常
|
||||
</div>
|
||||
)}
|
||||
{healthOk === false && (
|
||||
<div className="flex items-center gap-1 text-red-500 text-sm">
|
||||
<XCircle className="w-4 h-4" />不可达
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={checkHealth}
|
||||
disabled={checkingHealth}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checkingHealth ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Models */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
可用模型 ({availableModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{availableModels.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 pl-6">
|
||||
暂无可用模型,请确认管理员已在后台配置 Provider 和 Model
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 pl-6">
|
||||
{availableModels.map((model) => (
|
||||
<ModelRow key={model.id} model={model} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloudOff className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">SaaS 平台</div>
|
||||
<div className="text-xs text-gray-500">未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
连接
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({ model }: { model: SaaSModelInfo }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-800">{model.alias || model.id}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{model.supports_streaming && <span>流式</span>}
|
||||
{model.supports_vision && <span>视觉</span>}
|
||||
<span className="font-mono">{(model.context_window / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <FolderOpen className="w-4 h-4" /> },
|
||||
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
|
||||
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
|
||||
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
|
||||
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
@@ -97,6 +101,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
case 'workspace': return <Workspace />;
|
||||
case 'privacy': return <Privacy />;
|
||||
case 'storage': return <SecureStorage />;
|
||||
case 'saas': return <SaaSSettings />;
|
||||
case 'security': return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
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');
|
||||
@@ -213,6 +213,37 @@ export const useConnectionStore = create<ConnectionStore>((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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
293
desktop/src/store/saasStore.ts
Normal file
293
desktop/src/store/saasStore.ts
Normal file
@@ -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<void>;
|
||||
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
fetchAvailableModels: () => Promise<void>;
|
||||
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<typeof loadSaaSSession>): ConnectionMode {
|
||||
const persistedMode = loadConnectionMode();
|
||||
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||
return persistedMode;
|
||||
}
|
||||
return session ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user