chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -0,0 +1,516 @@
/**
* LoginPage - 全屏登录页面
*
* 强制 SaaS 登录门禁页面。用户必须登录后才能进入系统。
* 深色渐变背景 + 毛玻璃卡片设计。
* 复用 saasStore 的 login/register/loginWithTotp 逻辑。
*/
import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import {
LogIn,
UserPlus,
Eye,
EyeOff,
Loader2,
AlertCircle,
Mail,
Shield,
ShieldCheck,
ArrowLeft,
User,
} from 'lucide-react';
import { useSaaSStore } from '../store/saasStore';
import { cn } from '../lib/utils';
import { isTauriRuntime } from '../lib/tauri-gateway';
// === Constants ===
const PRODUCTION_SAAS_URL = 'https://saas.zclaw.com';
const DEV_SAAS_URL = 'http://127.0.0.1:8080';
// === Animation Variants ===
const cardVariants = {
hidden: { opacity: 0, y: 20, scale: 0.96 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
},
};
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: 0.15 + i * 0.05, duration: 0.35, ease: 'easeOut' as const },
}),
};
// === ZCLAW Logo SVG ===
function ZclawLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#logoGrad)" />
<text
x="32"
y="42"
textAnchor="middle"
fill="white"
fontSize="28"
fontWeight="bold"
fontFamily="system-ui, sans-serif"
>
Z
</text>
</svg>
);
}
// === Helpers ===
/** 根据运行环境自动选择 SaaS 服务器地址 */
function getSaasUrl(): string {
return isTauriRuntime() ? PRODUCTION_SAAS_URL : DEV_SAAS_URL;
}
// === Component ===
export function LoginPage() {
const {
login,
loginWithTotp,
register,
isLoading,
error: storeError,
totpRequired,
clearError,
} = useSaaSStore();
// Form state
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = 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 [totpCode, setTotpCode] = useState('');
const [showTotpStep, setShowTotpStep] = useState(false);
// Sync TOTP required from store & clear stale errors on mount
const mountedRef = useRef(false);
useEffect(() => {
clearError();
mountedRef.current = true;
}, [clearError]);
// Sync TOTP required from store
useEffect(() => {
if (totpRequired && !showTotpStep) {
setShowTotpStep(true);
}
}, [totpRequired, showTotpStep]);
// 屏蔽首次挂载时从 bootstrap/connectionStore 残留的 storeError
const displayError = mountedRef.current ? (storeError || localError) : localError;
const clearErrors = () => setLocalError(null);
// === Login Handler ===
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
if (!password) {
setLocalError('请输入密码');
return;
}
try {
await login(getSaasUrl(), username.trim(), password);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Register Handler ===
const handleRegisterSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
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;
}
try {
await register(
getSaasUrl(),
username.trim(),
email.trim(),
password,
displayName.trim() || undefined,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === TOTP Handler ===
const handleTotpSubmit = async () => {
if (totpCode.length !== 6) return;
clearErrors();
try {
await loginWithTotp(getSaasUrl(), username.trim(), password, totpCode);
setTotpCode('');
setShowTotpStep(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Tab Switch ===
const handleTabSwitch = (registerMode: boolean) => {
setIsRegister(registerMode);
clearErrors();
setConfirmPassword('');
setEmail('');
setDisplayName('');
};
const handleBackToLogin = () => {
setShowTotpStep(false);
setTotpCode('');
clearErrors();
};
// === Input class helper ===
const inputClass = cn(
'w-full px-4 py-3 rounded-lg text-sm text-white placeholder-white/40',
'bg-white/5 border border-white/10',
'focus:outline-none focus:ring-2 focus:ring-emerald-400/30 focus:border-emerald-400/50',
'transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
{/* Animated gradient background */}
<div
className="absolute inset-0 bg-gradient-to-br from-[#0c1445] via-[#1a1a5e] to-[#0d1b2a]"
style={{ backgroundSize: '200% 200%', animation: 'gradientShift 8s ease infinite' }}
/>
{/* Radial glow */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[600px] rounded-full bg-emerald-500/8 blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] rounded-full bg-blue-500/10 blur-[100px] pointer-events-none" />
{/* Card */}
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
className="relative z-10 w-full max-w-md mx-4"
>
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl p-8 md:p-10">
{/* TOTP Verification Step */}
{showTotpStep ? (
<div className="space-y-5">
<motion.div
custom={0}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center"
>
<div className="mx-auto w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-emerald-400" />
</div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="text-sm text-white/50 mt-1">
</p>
</motion.div>
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
autoComplete="one-time-code"
autoFocus
disabled={isLoading}
className={cn(inputClass, 'text-center font-mono text-2xl tracking-[0.5em] py-4')}
onKeyDown={(e) => {
if (e.key === 'Enter' && totpCode.length === 6) handleTotpSubmit();
}}
/>
</motion.div>
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible" className="flex gap-3">
<button
type="button"
onClick={handleBackToLogin}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm text-white/70 border border-white/15 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
<ArrowLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleTotpSubmit}
disabled={isLoading || totpCode.length !== 6}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</button>
</motion.div>
</div>
) : (
<>
{/* Logo & Brand */}
<motion.div custom={0} variants={fieldVariants} initial="hidden" animate="visible" className="text-center mb-8">
<ZclawLogo className="w-16 h-16 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-white tracking-wide">ZCLAW</h1>
<p className="text-sm text-white/50 mt-1">
{isRegister ? '创建账号,开始使用' : '登录以继续'}
</p>
</motion.div>
{/* Tab Switcher */}
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible" className="flex mb-6 bg-white/5 rounded-lg p-1">
<button
type="button"
onClick={() => handleTabSwitch(false)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
!isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<LogIn className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleTabSwitch(true)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<UserPlus className="w-3.5 h-3.5" />
</button>
</motion.div>
{/* Form */}
<form
onSubmit={isRegister ? handleRegisterSubmit : handleLoginSubmit}
className="space-y-4"
>
{/* Username */}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
autoComplete="username"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
{/* Email (Register only) */}
{isRegister && (
<motion.div custom={3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邮箱地址"
autoComplete="email"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
)}
{/* Display Name (Register only, optional) */}
{isRegister && (
<motion.div custom={4} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="显示名称(可选)"
autoComplete="name"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Password */}
<motion.div custom={isRegister ? 5 : 3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isRegister ? '密码(至少 6 位)' : '密码'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
disabled={isLoading}
className={cn(inputClass, 'pr-10')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 cursor-pointer"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</motion.div>
{/* Confirm Password (Register only) */}
{isRegister && (
<motion.div custom={6} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
autoComplete="new-password"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Error Display */}
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
{/* Submit Button */}
<motion.div custom={isRegister ? 7 : 4} variants={fieldVariants} initial="hidden" animate="visible">
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{isRegister ? '注册中...' : '登录中...'}
</>
) : (
<>
{isRegister ? (
<><UserPlus className="w-4 h-4" /></>
) : (
<><LogIn className="w-4 h-4" /></>
)}
</>
)}
</button>
</motion.div>
</form>
{/* Version footer */}
<motion.p
custom={isRegister ? 8 : 5}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center text-xs text-white/25 mt-6"
>
ZCLAW AI Agent Platform
</motion.p>
</>
)}
</div>
</motion.div>
</div>
);
}

View File

@@ -365,7 +365,7 @@ export function RightPanel() {
onClick={handleCancelEdit}
aria-label="Cancel edit"
>
Cancel
</Button>
<Button
variant="primary"
@@ -373,7 +373,7 @@ export function RightPanel() {
onClick={() => { handleSaveAgent().catch(silentErrorHandler('RightPanel')); }}
aria-label="Save edit"
>
Save
</Button>
</div>
) : (
@@ -383,7 +383,7 @@ export function RightPanel() {
onClick={handleStartEdit}
aria-label="Edit Agent"
>
Edit
</Button>
)
) : null}
@@ -395,20 +395,20 @@ export function RightPanel() {
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">About Me</div>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3"></div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="Name" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
<AgentInput label="Role" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
<AgentInput label="Nickname" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
<AgentInput label="Model" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
<AgentInput label="名称" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
<AgentInput label="角色" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
<AgentInput label="昵称" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
<AgentInput label="模型" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="Role" value={selectedClone?.role || '全能型 AI 助手'} />
<AgentRow label="Nickname" value={selectedClone?.nickname || '小龙'} />
<AgentRow label="Model" value={selectedClone?.model || currentModel} />
<AgentRow label="Emoji" value={selectedClone?.emoji || '🦞'} />
<AgentRow label="角色" value={selectedClone?.role || '全能型 AI 助手'} />
<AgentRow label="昵称" value={selectedClone?.nickname || '小龙'} />
<AgentRow label="模型" value={selectedClone?.model || currentModel} />
<AgentRow label="表情" value={selectedClone?.emoji || '🦞'} />
</div>
)}
</motion.div>
@@ -418,33 +418,33 @@ export function RightPanel() {
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">You in My Eyes</div>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3"></div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="Name" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
<AgentInput label="Role" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
<AgentInput label="Scenarios" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="coding, research" />
<AgentInput label="Workspace" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
<AgentToggle label="File Restriction" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
<AgentToggle label="Opt-in Program" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
<AgentInput label="你的名称" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
<AgentInput label="你的角色" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
<AgentInput label="场景" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="编程, 研究" />
<AgentInput label="工作区" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
<AgentToggle label="文件限制" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
<AgentToggle label="隐私计划" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="Name" value={userNameDisplay} />
<AgentRow label="Addressing" value={userAddressing} />
<AgentRow label="Timezone" value={localTimezone} />
<AgentRow label="你的名称" value={userNameDisplay} />
<AgentRow label="称呼方式" value={userAddressing} />
<AgentRow label="时区" value={localTimezone} />
<div className="flex gap-4">
<div className="w-16 text-gray-500 dark:text-gray-400">Focus</div>
<div className="w-16 text-gray-500 dark:text-gray-400"></div>
<div className="flex-1 flex flex-wrap gap-2">
{focusAreas.map((item) => (
<Badge key={item} variant="default">{item}</Badge>
))}
</div>
</div>
<AgentRow label="Workspace" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.zclaw/zclaw-workspace'} />
<AgentRow label="Resolved" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
<AgentRow label="File Restriction" value={selectedClone?.restrictFiles ? 'Enabled' : 'Disabled'} />
<AgentRow label="Opt-in" value={selectedClone?.privacyOptIn ? 'Joined' : 'Not joined'} />
<AgentRow label="工作区" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.zclaw/zclaw-workspace'} />
<AgentRow label="已解析" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
<AgentRow label="文件限制" value={selectedClone?.restrictFiles ? '已开启' : '已关闭'} />
<AgentRow label="隐私计划" value={selectedClone?.privacyOptIn ? '已加入' : '未加入'} />
</div>
)}
</motion.div>
@@ -455,9 +455,9 @@ export function RightPanel() {
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Bootstrap Files</div>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100"></div>
<Badge variant={selectedClone?.bootstrapReady ? 'success' : 'default'}>
{selectedClone?.bootstrapReady ? 'Generated' : 'Not generated'}
{selectedClone?.bootstrapReady ? '已生成' : '未生成'}
</Badge>
</div>
<div className="space-y-2 text-sm">
@@ -466,13 +466,13 @@ export function RightPanel() {
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-gray-800 dark:text-gray-200">{file.name}</span>
<Badge variant={file.exists ? 'success' : 'error'}>
{file.exists ? 'Exists' : 'Missing'}
{file.exists ? '已存在' : '缺失'}
</Badge>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 break-all">{file.path}</div>
</div>
)) : (
<p className="text-sm text-gray-500 dark:text-gray-400">No bootstrap files generated for this Agent.</p>
<p className="text-sm text-gray-500 dark:text-gray-400"> Agent </p>
)}
</div>
</motion.div>
@@ -497,7 +497,7 @@ export function RightPanel() {
<WifiOff className="w-4 h-4 text-gray-500 dark:text-gray-400" />
)}
<Badge variant={connected ? 'success' : 'default'}>
Gateway {connected ? 'Connected' : connectionState === 'connecting' ? 'Connecting...' : connectionState === 'reconnecting' ? 'Reconnecting...' : 'Disconnected'}
Gateway {connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</Badge>
</div>
{connected && (
@@ -506,8 +506,8 @@ export function RightPanel() {
size="sm"
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
className="p-1 text-gray-500 hover:text-orange-500"
title="Refresh data"
aria-label="Refresh data"
title="刷新数据"
aria-label="刷新数据"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -537,7 +537,7 @@ export function RightPanel() {
onClick={handleReconnect}
className="w-full"
>
Connect Gateway
Gateway
</Button>
</div>
)}

View File

@@ -136,6 +136,7 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
const merged = [...syncedModels, ...saasModels];
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
localStorage.setItem('zclaw-config-dirty.model.custom', '1');
} else if (direction === 'merge') {
// Merge: local wins for conflicts
const kept = localModels.filter((m) => !selectedKeys.has(m.id));
@@ -153,6 +154,7 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
const merged = [...kept, ...saasOnly];
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
localStorage.setItem('zclaw-config-dirty.model.custom', '1');
}
setSyncResult(conflicts.length > 0 && conflicts.length === selectedKeys.size ? 'partial' : 'success');

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
import { saasClient } from '../../lib/saas-client';
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useSaaSStore } from '../../store/saasStore';

View File

@@ -5,6 +5,7 @@ import { useConnectionStore } from '../../store/connectionStore';
import { useConfigStore } from '../../store/configStore';
import { useChatStore } from '../../store/chatStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { secureStorage } from '../../lib/secure-storage';
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X, Zap, Check } from 'lucide-react';
// 自定义模型数据结构
@@ -53,7 +54,9 @@ const AVAILABLE_PROVIDERS = [
];
const STORAGE_KEY = 'zclaw-custom-models';
const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:';
const EMBEDDING_STORAGE_KEY = 'zclaw-embedding-config';
const EMBEDDING_KEY_SECURE = 'zclaw-secure-embedding-apikey';
const DEFAULT_EMBEDDING_PROVIDERS: EmbeddingProvider[] = [
{ id: 'local', name: '本地 TF-IDF (无需 API)', defaultModel: 'tfidf', dimensions: 0 },
@@ -64,11 +67,16 @@ const DEFAULT_EMBEDDING_PROVIDERS: EmbeddingProvider[] = [
{ id: 'deepseek', name: 'DeepSeek', defaultModel: 'deepseek-embedding', dimensions: 1536 },
];
function loadEmbeddingConfig(): EmbeddingConfig {
/**
* Load embedding config from localStorage. apiKey will be empty here;
* call loadEmbeddingApiKey() to retrieve it from secure storage.
*/
function loadEmbeddingConfigBase(): Omit<EmbeddingConfig, 'apiKey'> & { apiKey: string } {
try {
const stored = localStorage.getItem(EMBEDDING_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
const parsed = JSON.parse(stored);
return { ...parsed, apiKey: '' };
}
} catch {
// ignore
@@ -82,7 +90,11 @@ function loadEmbeddingConfig(): EmbeddingConfig {
};
}
function saveEmbeddingConfig(config: EmbeddingConfig): void {
/**
* Save embedding config to localStorage. API key is NOT saved here;
* use saveEmbeddingApiKey() separately.
*/
function saveEmbeddingConfigBase(config: Omit<EmbeddingConfig, 'apiKey'>): void {
try {
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
} catch {
@@ -90,8 +102,26 @@ function saveEmbeddingConfig(config: EmbeddingConfig): void {
}
}
// 从 localStorage 加载自定义模型
function loadCustomModels(): CustomModel[] {
/**
* Save embedding API key to secure storage.
*/
async function saveEmbeddingApiKey(apiKey: string): Promise<void> {
if (!apiKey.trim()) {
await secureStorage.delete(EMBEDDING_KEY_SECURE);
return;
}
await secureStorage.set(EMBEDDING_KEY_SECURE, apiKey.trim());
}
/**
* Load embedding API key from secure storage.
*/
async function loadEmbeddingApiKey(): Promise<string | null> {
return secureStorage.get(EMBEDDING_KEY_SECURE);
}
// 从 localStorage 加载自定义模型 (apiKeys are stripped from localStorage)
function loadCustomModelsBase(): CustomModel[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
@@ -103,15 +133,33 @@ function loadCustomModels(): CustomModel[] {
return [];
}
// 保存自定义模型到 localStorage
function saveCustomModels(models: CustomModel[]): void {
// 保存自定义模型到 localStorage (apiKeys are stripped before saving)
function saveCustomModelsBase(models: CustomModel[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(models));
const sanitized = models.map(m => {
const { apiKey: _, ...rest } = m;
return rest;
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized));
} catch {
// ignore
}
}
/**
* Async load: fetches models from localStorage and merges apiKeys from secure storage.
*/
async function loadCustomModelsWithKeys(): Promise<CustomModel[]> {
const models = loadCustomModelsBase();
const modelsWithKeys = await Promise.all(
models.map(async (model) => {
const apiKey = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + model.id);
return { ...model, apiKey: apiKey || undefined };
})
);
return modelsWithKeys;
}
export function ModelsAPI() {
const connectionState = useConnectionStore((s) => s.connectionState);
const connect = useConnectionStore((s) => s.connect);
@@ -129,7 +177,7 @@ export function ModelsAPI() {
const [showApiKey, setShowApiKey] = useState(false);
// Embedding 配置状态
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig>(loadEmbeddingConfig);
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig>(loadEmbeddingConfigBase);
const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false);
const [testingEmbedding, setTestingEmbedding] = useState(false);
const [embeddingTestResult, setEmbeddingTestResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -147,9 +195,20 @@ export function ModelsAPI() {
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
// 加载自定义模型
// 加载自定义模型和 embedding API key (async for secure storage)
useEffect(() => {
setCustomModels(loadCustomModels());
const loadAll = async () => {
// Load custom models with their secure apiKeys
const modelsWithKeys = await loadCustomModelsWithKeys();
setCustomModels(modelsWithKeys);
// Load embedding apiKey from secure storage
const embApiKey = await loadEmbeddingApiKey();
if (embApiKey) {
setEmbeddingConfig(prev => ({ ...prev, apiKey: embApiKey }));
}
};
loadAll().catch(silentErrorHandler('ModelsAPI'));
}, []);
useEffect(() => {
@@ -194,14 +253,14 @@ export function ModelsAPI() {
};
// 保存模型
const handleSaveModel = () => {
const handleSaveModel = async () => {
if (!formData.modelId.trim()) return;
const newModel: CustomModel = {
id: formData.modelId.trim(),
name: formData.displayName.trim() || formData.modelId.trim(),
provider: formData.provider,
apiKey: formData.apiKey.trim(),
apiKey: formData.apiKey.trim() || undefined,
apiProtocol: formData.apiProtocol,
baseUrl: formData.baseUrl.trim() || AVAILABLE_PROVIDERS.find(p => p.id === formData.provider)?.baseUrl,
createdAt: editingModel?.createdAt || new Date().toISOString(),
@@ -216,8 +275,15 @@ export function ModelsAPI() {
updatedModels = [...customModels, newModel];
}
// Save apiKey to secure storage
if (newModel.apiKey) {
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + newModel.id, newModel.apiKey);
} else {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + newModel.id);
}
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
setShowAddModal(false);
setEditingModel(null);
@@ -226,10 +292,12 @@ export function ModelsAPI() {
};
// 删除模型
const handleDeleteModel = (modelId: string) => {
const handleDeleteModel = async (modelId: string) => {
const updatedModels = customModels.filter(m => m.id !== modelId);
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
// Also remove apiKey from secure storage
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
};
// 设为默认模型
@@ -241,7 +309,7 @@ export function ModelsAPI() {
isDefault: m.id === modelId,
}));
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
};
// Provider 变更时更新 baseUrl
@@ -272,7 +340,10 @@ export function ModelsAPI() {
enabled: embeddingConfig.provider !== 'local' && embeddingConfig.apiKey.trim() !== '',
};
setEmbeddingConfig(configToSave);
saveEmbeddingConfig(configToSave);
// Save apiKey to secure storage, rest to localStorage
await saveEmbeddingApiKey(configToSave.apiKey);
saveEmbeddingConfigBase(configToSave);
// Push config to Rust backend for semantic memory search
if (configToSave.enabled) {

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bot, Zap, Package,
Search, Sparkles, ChevronRight, X
Search, ChevronRight, X
} from 'lucide-react';
import { CloneManager } from './CloneManager';
import { useConfigStore } from '../store/configStore';
@@ -33,8 +33,7 @@ const NAV_ITEMS: {
export function Sidebar({
onOpenSettings,
onMainViewChange,
onNewChat
}: SidebarProps) {
}: Omit<SidebarProps, 'onNewChat'>) {
const [activeTab, setActiveTab] = useState<Tab>('clones');
const [searchQuery, setSearchQuery] = useState('');
const userName = useConfigStore((state) => state.quickConfig?.userName) || '用户7141';
@@ -72,20 +71,6 @@ export function Sidebar({
</div>
</div>
{/* 新对话按钮 */}
<div className="px-3 py-2">
<button
onClick={() => {
setActiveTab('clones');
onNewChat?.();
}}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-700 dark:text-gray-300 transition-colors group"
>
<Sparkles className="w-5 h-5 text-gray-500" />
<span className="font-medium"></span>
</button>
</div>
{/* 导航项 */}
<nav className="px-3 space-y-0.5">
{NAV_ITEMS.map(({ key, label, icon: Icon, mainView }) => (