chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
516
desktop/src/components/LoginPage.tsx
Normal file
516
desktop/src/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
Reference in New Issue
Block a user