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

@@ -12,6 +12,7 @@ import { HandApprovalModal } from './components/HandApprovalModal';
import { TopBar } from './components/TopBar';
import { DetailDrawer } from './components/DetailDrawer';
import { useConnectionStore } from './store/connectionStore';
import { useSaaSStore } from './store/saasStore';
import { useHandStore, type HandRun } from './store/handStore';
import { useChatStore } from './store/chatStore';
import { initializeStores } from './store';
@@ -19,9 +20,10 @@ import { getStoredGatewayToken } from './lib/gateway-client';
import { pageVariants, defaultTransition, fadeInVariants } from './lib/animations';
import { Loader2 } from 'lucide-react';
import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/tauri-gateway';
import { LoginPage } from './components/LoginPage';
import { useOnboarding } from './lib/use-onboarding';
import { intelligenceClient } from './lib/intelligence-client';
import { loadEmbeddingConfig } from './lib/embedding-client';
import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client';
import { invoke } from '@tauri-apps/api/core';
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
import { useToast } from './components/ui/Toast';
@@ -54,10 +56,11 @@ function App() {
const [showApprovalModal, setShowApprovalModal] = useState(false);
const connect = useConnectionStore((s) => s.connect);
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
const hands = useHandStore((s) => s.hands);
const approveHand = useHandStore((s) => s.approveHand);
const loadHands = useHandStore((s) => s.loadHands);
const { setCurrentAgent, newConversation } = useChatStore();
const { setCurrentAgent } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();
// Proposal notifications
@@ -132,6 +135,9 @@ function App() {
let mounted = true;
const bootstrap = async () => {
// 未登录时不启动 bootstrap
if (!useSaaSStore.getState().isLoggedIn) return;
try {
// Step 1: Check and start local gateway in Tauri environment
if (isTauriRuntime()) {
@@ -234,12 +240,33 @@ function App() {
// Step 5: Restore embedding config to Rust backend
try {
// Migrate plaintext embedding apiKey to secure storage if present
try {
const embStored = localStorage.getItem('zclaw-embedding-config');
if (embStored) {
const embParsed = JSON.parse(embStored);
if (embParsed.apiKey && embParsed.apiKey.trim()) {
const { saveEmbeddingApiKey } = await import('./lib/embedding-client');
const { secureStorage } = await import('./lib/secure-storage');
// Only migrate if not already in secure storage
const existing = await secureStorage.get('zclaw-secure-embedding-apikey');
if (!existing) {
await saveEmbeddingApiKey(embParsed.apiKey);
}
// Strip apiKey from localStorage
const { apiKey: _, ...stripped } = embParsed;
localStorage.setItem('zclaw-embedding-config', JSON.stringify(stripped));
}
}
} catch { /* migration failure is non-critical */ }
const embConfig = loadEmbeddingConfig();
if (embConfig.enabled && embConfig.provider !== 'local' && embConfig.apiKey) {
const embApiKey = await loadEmbeddingApiKey();
if (embConfig.enabled && embConfig.provider !== 'local' && embApiKey) {
setBootstrapStatus('Restoring embedding configuration...');
await invoke('viking_configure_embedding', {
provider: embConfig.provider,
apiKey: embConfig.apiKey,
apiKey: embApiKey,
model: embConfig.model || undefined,
endpoint: embConfig.endpoint || undefined,
});
@@ -252,8 +279,10 @@ function App() {
// Step 5b: Configure summary driver using active LLM (for L0/L1 generation)
try {
const { getDefaultModelConfig } = await import('./store/connectionStore');
const modelConfig = getDefaultModelConfig();
const { getDefaultModelConfigAsync, migrateModelApiKeysToSecureStorage } = await import('./store/connectionStore');
// Migrate any plaintext API keys to secure storage (idempotent)
await migrateModelApiKeysToSecureStorage();
const modelConfig = await getDefaultModelConfigAsync();
if (modelConfig && modelConfig.apiKey && modelConfig.baseUrl) {
setBootstrapStatus('Configuring summary driver...');
await invoke('viking_configure_summary_driver', {
@@ -288,7 +317,7 @@ function App() {
clearInterval(window.__ZCLAW_STATS_SYNC_INTERVAL__);
}
};
}, [connect, onboardingNeeded, onboardingLoading]);
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);
// Handle onboarding completion
const handleOnboardingSuccess = (clone: Clone) => {
@@ -312,11 +341,10 @@ function App() {
setMainContentView(view);
};
// 处理新对话
const handleNewChat = () => {
newConversation();
setMainContentView('chat');
};
// 登录门禁 — 必须登录才能使用
if (!isLoggedIn) {
return <LoginPage />;
}
if (view === 'settings') {
return <SettingsLayout onBack={() => setView('main')} />;
@@ -384,7 +412,6 @@ function App() {
<Sidebar
onOpenSettings={() => setView('settings')}
onMainViewChange={handleMainViewChange}
onNewChat={handleNewChat}
/>
{/* 主内容区 */}

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 }) => (

View File

@@ -106,3 +106,8 @@ body {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}

View File

@@ -13,6 +13,7 @@ import {
type LearningEventType,
type FeedbackSentiment,
} from '../types/active-learning';
import { generateRandomString } from './crypto-utils';
// === 常量 ===
@@ -23,7 +24,7 @@ const SUGGESTION_COOLDOWN_HOURS = 2;
// === 生成 ID ===
function generateEventId(): string {
return `le-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return `le-${Date.now()}-${generateRandomString(8)}`;
}
// === 分析反馈情感 ===
@@ -172,7 +173,7 @@ export class ActiveLearningEngine {
// 1. 正面反馈 -> 偏好正面回复
if (event.observation.includes('谢谢') || event.observation.includes('好的')) {
this.addPattern({
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `pat-${Date.now()}-${generateRandomString(8)}`,
type: 'preference',
pattern: 'positive_response_preference',
description: '用户偏好正面回复风格',
@@ -185,7 +186,7 @@ export class ActiveLearningEngine {
// 2. 纠正 -> 需要更精确
if (event.type === 'correction') {
this.addPattern({
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `pat-${Date.now()}-${generateRandomString(8)}`,
type: 'preference',
pattern: 'precision_preference',
description: '用户对精确性有更高要求',
@@ -198,7 +199,7 @@ export class ActiveLearningEngine {
// 3. 上下文相关 -> 场景偏好
if (event.context) {
this.addPattern({
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `pat-${Date.now()}-${generateRandomString(8)}`,
type: 'context',
pattern: 'context_aware',
description: 'Agent 需要关注上下文',
@@ -252,7 +253,7 @@ export class ActiveLearningEngine {
// 生成建议
suggestions.push({
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `sug-${Date.now()}-${generateRandomString(8)}`,
agentId,
type: pattern.type,
pattern: pattern.pattern,

View File

@@ -13,7 +13,7 @@
*/
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
import { hashSha256 } from './crypto-utils';
import { hashSha256, generateRandomString } from './crypto-utils';
// Storage key prefixes
const API_KEY_PREFIX = 'zclaw_api_key_';
@@ -456,21 +456,19 @@ export function clearSecurityLog(): void {
/**
* Generate a random API key for testing
* WARNING: Only use for testing purposes
* @internal Only use for testing purposes
*/
export function generateTestApiKey(type: ApiKeyType): string {
const rules = KEY_VALIDATION_RULES[type];
const length = rules.minLength + 10;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let key = '';
if (rules.prefix && rules.prefix.length > 0) {
key = rules.prefix[0];
}
for (let i = key.length; i < length; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
const remainingLength = length - key.length;
key += generateRandomString(remainingLength);
return key;
}

View File

@@ -42,7 +42,9 @@ function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `audit_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const bytes = crypto.getRandomValues(new Uint8Array(6));
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
return `audit_${Date.now()}_${suffix}`;
}
function getTimestamp(): string {

View File

@@ -14,6 +14,8 @@
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.3
*/
import { generateRandomString } from './crypto-utils';
// === Types ===
export type AutonomyLevel = 'supervised' | 'assisted' | 'autonomous';
@@ -278,7 +280,7 @@ export class AutonomyManager {
* Returns approval ID that can be used to approve/reject.
*/
requestApproval(decision: AutonomyDecision): string {
const approvalId = `approval_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const approvalId = `approval_${Date.now()}_${generateRandomString(6)}`;
this.pendingApprovals.set(approvalId, decision);
// Store in localStorage for persistence
@@ -349,7 +351,7 @@ export class AutonomyManager {
private logDecision(decision: AutonomyDecision, context: Record<string, unknown>): void {
const entry: AuditLogEntry = {
id: `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
id: `audit_${Date.now()}_${generateRandomString(6)}`,
action: decision.action,
decision,
context,

View File

@@ -10,7 +10,8 @@
* - Secure key caching with automatic expiration
*/
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
// Legacy static salt — only used for backward compatibility with existing encrypted data
const LEGACY_SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
const ITERATIONS = 100000;
const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
@@ -87,13 +88,15 @@ function getCacheKey(masterKey: string, salt: Uint8Array): string {
*/
export async function deriveKey(
masterKey: string,
salt: Uint8Array = SALT
salt?: Uint8Array
): Promise<CryptoKey> {
// Use legacy salt for backward compatibility if no salt provided
const effectiveSalt = salt ?? LEGACY_SALT;
// Clean up expired keys periodically
cleanupExpiredKeys();
// Check cache first
const cacheKey = getCacheKey(masterKey, salt);
const cacheKey = getCacheKey(masterKey, effectiveSalt);
const cached = keyCache.get(cacheKey);
if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) {
return cached.key;
@@ -111,7 +114,7 @@ export async function deriveKey(
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
salt: effectiveSalt,
iterations: ITERATIONS,
hash: 'SHA-256',
},

View File

@@ -6,6 +6,7 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { secureStorage } from './secure-storage';
export interface EmbeddingConfig {
provider: string;
@@ -32,12 +33,18 @@ export interface EmbeddingProvider {
}
const EMBEDDING_STORAGE_KEY = 'zclaw-embedding-config';
const EMBEDDING_KEY_SECURE = 'zclaw-secure-embedding-apikey';
/**
* Load embedding config from localStorage. apiKey is NOT included;
* use loadEmbeddingApiKey() to retrieve it from secure storage.
*/
export function loadEmbeddingConfig(): EmbeddingConfig {
try {
const stored = localStorage.getItem(EMBEDDING_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
const parsed = JSON.parse(stored);
return { ...parsed, apiKey: '' };
}
} catch {
// ignore
@@ -51,14 +58,37 @@ export function loadEmbeddingConfig(): EmbeddingConfig {
};
}
/**
* Save embedding config to localStorage. API key is NOT saved here;
* use saveEmbeddingApiKey() separately.
*/
export function saveEmbeddingConfig(config: EmbeddingConfig): void {
try {
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
const { apiKey: _, ...rest } = config;
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(rest));
} catch {
// ignore
}
}
/**
* Load embedding API key from secure storage.
*/
export async function loadEmbeddingApiKey(): Promise<string | null> {
return secureStorage.get(EMBEDDING_KEY_SECURE);
}
/**
* Save embedding API key to secure storage.
*/
export 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());
}
export async function getEmbeddingProviders(): Promise<EmbeddingProvider[]> {
const result = await invoke<[string, string, string, number][]>('embedding_providers');
return result.map(([id, name, defaultModel, dimensions]) => ({
@@ -75,7 +105,9 @@ export async function createEmbedding(
): Promise<EmbeddingResponse> {
const savedConfig = loadEmbeddingConfig();
const provider = config?.provider ?? savedConfig.provider;
const apiKey = config?.apiKey ?? savedConfig.apiKey;
// Resolve apiKey: use explicit config value, then secure storage, then empty
const explicitKey = config?.apiKey?.trim();
const apiKey = explicitKey || await loadEmbeddingApiKey() || '';
const model = config?.model ?? savedConfig.model;
const endpoint = config?.endpoint ?? savedConfig.endpoint;
@@ -136,6 +168,8 @@ export class EmbeddingClient {
constructor(config?: EmbeddingConfig) {
this.config = config ?? loadEmbeddingConfig();
// If no explicit apiKey was provided and config was loaded from localStorage,
// the apiKey will be empty. It will be resolved from secure storage lazily.
}
get isApiMode(): boolean {
@@ -143,7 +177,11 @@ export class EmbeddingClient {
}
async embed(text: string): Promise<number[]> {
const response = await createEmbedding(text, this.config);
// Resolve apiKey from secure storage if not in config
const effectiveConfig = this.config.apiKey
? this.config
: { ...this.config, apiKey: await loadEmbeddingApiKey() ?? '' };
const response = await createEmbedding(text, effectiveConfig);
return response.embedding;
}
@@ -161,7 +199,12 @@ export class EmbeddingClient {
if (config.provider !== undefined || config.apiKey !== undefined) {
this.config.enabled = this.config.provider !== 'local' && !!this.config.apiKey;
}
// Save non-key fields to localStorage
saveEmbeddingConfig(this.config);
// Save apiKey to secure storage (fire-and-forget)
if (config.apiKey !== undefined) {
saveEmbeddingApiKey(config.apiKey).catch(() => {});
}
}
getConfig(): EmbeddingConfig {

View File

@@ -5,6 +5,8 @@
* for user-friendly error handling.
*/
import { generateRandomString } from './crypto-utils';
// === Error Categories ===
export type ErrorCategory =
@@ -348,7 +350,7 @@ export function classifyError(error: unknown): AppError {
if (matched) {
const { pattern, match } = matched;
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
id: `err_${Date.now()}_${generateRandomString(6)}`,
category: pattern.category,
severity: pattern.severity,
title: pattern.title,
@@ -367,7 +369,7 @@ export function classifyError(error: unknown): AppError {
// Unknown error - return generic error
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
id: `err_${Date.now()}_${generateRandomString(6)}`,
category: 'system',
severity: 'medium',
title: 'An Error Occurred',

View File

@@ -151,7 +151,9 @@ function createIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const bytes = crypto.getRandomValues(new Uint8Array(6));
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
return `idem_${Date.now()}_${suffix}`;
}
// === Client ===
@@ -474,7 +476,7 @@ export class GatewayClient {
): Promise<{ runId: string }> {
const agentId = opts?.agentId || this.defaultAgentId;
const runId = createIdempotencyKey();
const sessionId = opts?.sessionKey || `session_${Date.now()}`;
const sessionId = opts?.sessionKey || crypto.randomUUID();
// If no agent ID, try to fetch from ZCLAW status (async, but we'll handle it in connectZclawStream)
if (!agentId) {
@@ -732,7 +734,7 @@ export class GatewayClient {
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
log.error(`POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
const error = new Error(`REST API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
const error = new Error(`REST API error: ${response.status} ${response.statusText}`);
(error as any).status = response.status;
(error as any).body = errorBody;
throw error;

View File

@@ -48,6 +48,7 @@
import { invoke } from '@tauri-apps/api/core';
import { isTauriRuntime } from './tauri-gateway';
import { generateRandomString } from './crypto-utils';
import {
intelligence,
@@ -379,7 +380,7 @@ const fallbackMemory = {
async store(entry: MemoryEntryInput): Promise<string> {
const store = getFallbackStore();
const id = `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const id = `mem_${Date.now()}_${generateRandomString(6)}`;
const now = new Date().toISOString();
const memory: MemoryEntry = {

View File

@@ -464,7 +464,7 @@ export class KernelClient {
agentId?: string;
}
): Promise<{ runId: string }> {
const runId = `run_${Date.now()}`;
const runId = crypto.randomUUID();
const sessionId = opts?.sessionKey || runId;
const agentId = opts?.agentId || this.defaultAgentId;
@@ -1100,12 +1100,54 @@ export class KernelClient {
return () => {};
}
// === A2A (Agent-to-Agent) API ===
/**
* Verify audit log chain (GatewayClient compatibility)
* Note: Not implemented for internal kernel
* Send a direct A2A message from one agent to another
*/
async verifyAuditLogChain(): Promise<{ valid: boolean; chain_depth?: number; root_hash?: string; broken_at_index?: number }> {
return { valid: false, chain_depth: 0, root_hash: '' };
/**
* Send a direct A2A message from one agent to another
*/
async a2aSend(from: string, to: string, payload: unknown, messageType?: string): Promise<void> {
await invoke('agent_a2a_send', {
from,
to,
payload,
messageType: messageType || 'notification',
});
}
/**
* Broadcast a message from an agent to all other agents
*/
async a2aBroadcast(from: string, payload: unknown): Promise<void> {
await invoke('agent_a2a_broadcast', { from, payload });
}
/**
* Discover agents that have a specific capability
*/
async a2aDiscover(capability: string): Promise<Array<{
id: string;
name: string;
description: string;
capabilities: Array<{ name: string; description: string }>;
role: string;
priority: number;
}>> {
return await invoke('agent_a2a_discover', { capability });
}
/**
* Delegate a task to another agent and wait for response
*/
async a2aDelegateTask(from: string, to: string, task: string, timeoutMs?: number): Promise<unknown> {
return await invoke('agent_a2a_delegate_task', {
from,
to,
task,
timeoutMs: timeoutMs || 30000,
});
}
// === Internal ===

View File

@@ -342,13 +342,9 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
latencyMs,
};
} catch (err) {
console.warn('[LLMService] Kernel chat failed, falling back to mock:', err);
// Return empty response instead of throwing
return {
content: '',
tokensUsed: { input: 0, output: 0 },
latencyMs: Date.now() - startTime,
};
console.error('[LLMService] Kernel chat failed:', err);
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Gateway] Kernel chat failed: ${message}`);
}
}
@@ -470,7 +466,7 @@ class SaasLLMAdapter implements LLMServiceAdapter {
const data = await response.json();
const latencyMs = Date.now() - startTime;
return {
const result = {
content: data.choices?.[0]?.message?.content || '',
tokensUsed: {
input: data.usage?.prompt_tokens || 0,
@@ -479,6 +475,19 @@ class SaasLLMAdapter implements LLMServiceAdapter {
model: data.model,
latencyMs,
};
// Record telemetry for SaaS relay usage
try {
const { recordLLMUsage } = await import('./telemetry-collector');
recordLLMUsage(
result.model || 'saas-relay',
result.tokensUsed.input,
result.tokensUsed.output,
{ latencyMs, success: true, connectionMode: 'saas' },
);
} catch { /* non-blocking */ }
return result;
}
isAvailable(): boolean {
@@ -559,12 +568,17 @@ export function saveConfig(config: LLMConfig): void {
delete safeConfig.apiKey;
localStorage.setItem(LLM_CONFIG_KEY, JSON.stringify(safeConfig));
// Mark config as dirty for SaaS push sync
localStorage.setItem('zclaw-config-dirty.llm.default', '1');
resetLLMAdapter();
}
// === Prompt Templates ===
export const LLM_PROMPTS = {
// 硬编码默认值 — 当 SaaS 不可用且本地无缓存时的终极兜底
const HARDCODED_PROMPTS: Record<string, { system: string; user: (arg: string) => string }> = {
reflection: {
system: `你是一个 AI Agent 的自我反思引擎。分析最近的对话历史,识别行为模式,并生成改进建议。
@@ -587,11 +601,7 @@ export const LLM_PROMPTS = {
],
"identityProposals": []
}`,
user: (context: string) => `分析以下对话历史,进行自我反思:
${context}
请识别行为模式(积极和消极),并提供具体的改进建议。`,
user: (context: string) => `分析以下对话历史,进行自我反思:\n\n${context}\n\n请识别行为模式积极和消极并提供具体的改进建议。`,
},
compaction: {
@@ -603,9 +613,7 @@ ${context}
3. 保留未完成的任务
4. 保持时间顺序
5. 摘要应能在后续对话中替代原始内容`,
user: (messages: string) => `请将以下对话压缩为简洁摘要,保留关键信息:
${messages}`,
user: (messages: string) => `请将以下对话压缩为简洁摘要,保留关键信息:\n\n${messages}`,
},
extraction: {
@@ -626,14 +634,200 @@ ${messages}`,
"tags": ["标签1", "标签2"]
}
]`,
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:
${conversation}
如果没有值得记忆的内容,返回空数组 []。`,
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容返回空数组 []。`,
},
};
// === Prompt Cache (SaaS OTA) ===
const PROMPT_CACHE_KEY = 'zclaw-prompt-cache';
interface CachedPrompt {
name: string;
version: number;
source: string;
system: string;
userTemplate: string | null;
syncedAt: string;
}
/** 获取本地缓存的提示词版本号映射 */
function loadPromptCache(): Record<string, CachedPrompt> {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(PROMPT_CACHE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
/** 保存提示词缓存到 localStorage */
function savePromptCache(cache: Record<string, CachedPrompt>): void {
if (typeof window === 'undefined') return;
localStorage.setItem(PROMPT_CACHE_KEY, JSON.stringify(cache));
}
/**
* 获取指定提示词的系统提示词
* 优先级:本地缓存 → 硬编码默认值
*/
export function getSystemPrompt(name: string): string {
const cache = loadPromptCache();
if (cache[name]?.system) {
return cache[name].system;
}
return HARDCODED_PROMPTS[name]?.system ?? '';
}
/**
* 获取指定提示词的用户提示词模板
* 优先级:本地缓存 → 硬编码默认值
*/
export function getUserPromptTemplate(name: string): string | ((arg: string) => string) | null {
const cache = loadPromptCache();
if (cache[name]) {
const tmpl = cache[name].userTemplate;
if (tmpl) return tmpl;
}
return HARDCODED_PROMPTS[name]?.user ?? null;
}
/** 获取提示词当前版本号(本地缓存) */
export function getPromptVersion(name: string): number {
const cache = loadPromptCache();
return cache[name]?.version ?? 0;
}
/** 获取所有本地缓存的提示词版本(用于 OTA 检查) */
export function getAllPromptVersions(): Record<string, number> {
const cache = loadPromptCache();
const versions: Record<string, number> = {};
for (const [name, entry] of Object.entries(cache)) {
versions[name] = entry.version;
}
return versions;
}
/**
* 应用 SaaS OTA 更新到本地缓存
* @param updates 从 SaaS 拉取的更新列表
*/
export function applyPromptUpdates(updates: Array<{
name: string;
version: number;
system_prompt: string;
user_prompt_template: string | null;
source: string;
changelog?: string | null;
}>): number {
const cache = loadPromptCache();
let applied = 0;
for (const update of updates) {
cache[update.name] = {
name: update.name,
version: update.version,
source: update.source,
system: update.system_prompt,
userTemplate: update.user_prompt_template,
syncedAt: new Date().toISOString(),
};
applied++;
}
if (applied > 0) {
savePromptCache(cache);
}
return applied;
}
/**
* 后台异步检查 SaaS 提示词更新
* 启动时和每 30 分钟调用一次
*/
let promptSyncTimer: ReturnType<typeof setInterval> | null = null;
export function startPromptOTASync(deviceId: string): void {
if (promptSyncTimer) return; // 已启动
if (typeof window === 'undefined') return;
const doSync = async () => {
try {
const { saasClient } = await import('./saas-client');
const { useSaaSStore } = await import('../store/saasStore');
const { saasUrl, authToken } = useSaaSStore.getState();
if (!saasUrl || !authToken) return;
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
const versions = getAllPromptVersions();
const result = await saasClient.checkPromptUpdates(deviceId, versions);
if (result.updates.length > 0) {
const applied = applyPromptUpdates(result.updates);
if (applied > 0) {
console.log(`[Prompt OTA] 已更新 ${applied} 个提示词模板`);
}
}
} catch (err) {
// 静默失败,不影响正常使用
console.debug('[Prompt OTA] 检查更新失败:', err);
}
};
// 立即执行一次
doSync();
// 每 30 分钟检查一次
promptSyncTimer = setInterval(doSync, 30 * 60 * 1000);
}
export function stopPromptOTASync(): void {
if (promptSyncTimer) {
clearInterval(promptSyncTimer);
promptSyncTimer = null;
}
}
// 保留向后兼容的 LLM_PROMPTS 导出(读取走 PromptCache
export const LLM_PROMPTS = {
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
};
// === Telemetry Integration ===
/**
* 记录一次 LLM 调用结果到遥测收集器。
* 所有 adapter 的 complete() 返回后应调用此函数。
*/
function trackLLMCall(
adapter: LLMServiceAdapter,
response: LLMResponse,
error?: unknown,
): void {
try {
const { recordLLMUsage } = require('./telemetry-collector');
recordLLMUsage(
response.model || adapter.getProvider(),
response.tokensUsed?.input ?? 0,
response.tokensUsed?.output ?? 0,
{
latencyMs: response.latencyMs,
success: !error,
errorType: error instanceof Error ? error.message.slice(0, 80) : undefined,
connectionMode: adapter.getProvider() === 'saas' ? 'saas' : 'tauri',
},
);
} catch {
// telemetry-collector may not be available (e.g., SSR)
}
}
// === Helper Functions ===
export async function llmReflect(context: string, adapter?: LLMServiceAdapter): Promise<string> {
@@ -641,9 +835,10 @@ export async function llmReflect(context: string, adapter?: LLMServiceAdapter):
const response = await llm.complete([
{ role: 'system', content: LLM_PROMPTS.reflection.system },
{ role: 'user', content: LLM_PROMPTS.reflection.user(context) },
{ role: 'user', content: typeof LLM_PROMPTS.reflection.user === 'function' ? LLM_PROMPTS.reflection.user(context) : LLM_PROMPTS.reflection.user },
]);
trackLLMCall(llm, response);
return response.content;
}
@@ -652,9 +847,10 @@ export async function llmCompact(messages: string, adapter?: LLMServiceAdapter):
const response = await llm.complete([
{ role: 'system', content: LLM_PROMPTS.compaction.system },
{ role: 'user', content: LLM_PROMPTS.compaction.user(messages) },
{ role: 'user', content: typeof LLM_PROMPTS.compaction.user === 'function' ? LLM_PROMPTS.compaction.user(messages) : LLM_PROMPTS.compaction.user },
]);
trackLLMCall(llm, response);
return response.content;
}
@@ -666,8 +862,9 @@ export async function llmExtract(
const response = await llm.complete([
{ role: 'system', content: LLM_PROMPTS.extraction.system },
{ role: 'user', content: LLM_PROMPTS.extraction.user(conversation) },
{ role: 'user', content: typeof LLM_PROMPTS.extraction.user === 'function' ? LLM_PROMPTS.extraction.user(conversation) : LLM_PROMPTS.extraction.user },
]);
trackLLMCall(llm, response);
return response.content;
}

View File

@@ -146,6 +146,282 @@ export interface ConfigSyncResult {
skipped: number;
}
/** Paginated response wrapper */
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
// === Prompt OTA Types ===
/** Prompt template info */
export interface PromptTemplateInfo {
id: string;
name: string;
category: string;
description: string | null;
source: string;
current_version: number;
status: string;
created_at: string;
updated_at: string;
}
/** Prompt version info */
export interface PromptVersionInfo {
id: string;
template_id: string;
version: number;
system_prompt: string;
user_prompt_template: string | null;
variables: PromptVariable[];
changelog: string | null;
min_app_version: string | null;
created_at: string;
}
/** Prompt variable definition */
export interface PromptVariable {
name: string;
type: string;
default_value?: string;
description?: string;
required?: boolean;
}
/** OTA update check result */
export interface PromptCheckResult {
updates: PromptUpdatePayload[];
server_time: string;
}
/** Single OTA update payload */
export interface PromptUpdatePayload {
name: string;
version: number;
system_prompt: string;
user_prompt_template: string | null;
variables: PromptVariable[];
source: string;
min_app_version: string | null;
changelog: string | null;
}
/** Provider info from GET /api/v1/providers */
export interface ProviderInfo {
id: string;
name: string;
display_name: string;
base_url: string;
api_protocol: string;
enabled: boolean;
rate_limit_rpm: number | null;
rate_limit_tpm: number | null;
created_at: string;
updated_at: string;
}
/** Create provider request */
export interface CreateProviderRequest {
name: string;
display_name: string;
base_url: string;
api_protocol?: string;
api_key?: string;
rate_limit_rpm?: number;
rate_limit_tpm?: number;
}
/** Update provider request */
export interface UpdateProviderRequest {
display_name?: string;
base_url?: string;
api_key?: string;
rate_limit_rpm?: number;
rate_limit_tpm?: number;
enabled?: boolean;
}
/** Model info from GET /api/v1/models */
export interface ModelInfo {
id: string;
provider_id: string;
model_id: string;
alias: string;
context_window: number;
max_output_tokens: number;
supports_streaming: boolean;
supports_vision: boolean;
enabled: boolean;
pricing_input: number;
pricing_output: number;
created_at: string;
updated_at: string;
}
/** Create model request */
export interface CreateModelRequest {
provider_id: string;
model_id: string;
alias: string;
context_window?: number;
max_output_tokens?: number;
supports_streaming?: boolean;
supports_vision?: boolean;
pricing_input?: number;
pricing_output?: number;
}
/** Update model request */
export interface UpdateModelRequest {
alias?: string;
context_window?: number;
max_output_tokens?: number;
supports_streaming?: boolean;
supports_vision?: boolean;
enabled?: boolean;
pricing_input?: number;
pricing_output?: number;
}
/** Account API key info */
export interface AccountApiKeyInfo {
id: string;
provider_id: string;
key_label: string | null;
permissions: string[];
enabled: boolean;
last_used_at: string | null;
created_at: string;
updated_at: string;
}
/** Create API key request */
export interface CreateApiKeyRequest {
provider_id: string;
key_value: string;
key_label?: string;
permissions?: string[];
}
/** Usage statistics */
export interface UsageStats {
total_input_tokens: number;
total_output_tokens: number;
total_requests: number;
by_provider: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
by_model: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
daily: Array<{ date: string; input_tokens: number; output_tokens: number; requests: number }>;
}
/** Account public info (extended) */
export interface AccountPublic {
id: string;
username: string;
email: string;
display_name: string;
role: string;
status: string;
totp_enabled: boolean;
last_login_at: string | null;
created_at: string;
}
/** Update account request */
export interface UpdateAccountRequest {
display_name?: string;
email?: string;
role?: string;
avatar_url?: string;
}
/** Token info */
export interface TokenInfo {
id: string;
name: string;
token_prefix: string;
permissions: string[];
last_used_at: string | null;
expires_at: string | null;
created_at: string;
token?: string;
}
/** Create token request */
export interface CreateTokenRequest {
name: string;
permissions: string[];
expires_days?: number;
}
/** Operation log info */
export interface OperationLogInfo {
id: number;
account_id: string | null;
action: string;
target_type: string | null;
target_id: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
created_at: string;
}
/** Dashboard statistics */
export interface DashboardStats {
total_accounts: number;
active_accounts: number;
tasks_today: number;
active_providers: number;
active_models: number;
tokens_today_input: number;
tokens_today_output: number;
}
/** Role info */
export interface RoleInfo {
id: string;
name: string;
description: string | null;
permissions: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
/** Create role request */
export interface CreateRoleRequest {
id: string;
name: string;
description?: string;
permissions: string[];
}
/** Update role request */
export interface UpdateRoleRequest {
name?: string;
description?: string;
permissions?: string[];
}
/** Permission template */
export interface PermissionTemplate {
id: string;
name: string;
description: string | null;
permissions: string[];
created_at: string;
updated_at: string;
}
/** Create template request */
export interface CreateTemplateRequest {
name: string;
description?: string;
permissions: string[];
}
// === Error Class ===
export class SaaSApiError extends Error {
@@ -258,6 +534,11 @@ export class SaaSClient {
return !!this.token;
}
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
private _isAuthEndpoint(path: string): boolean {
return path.includes('/auth/login') || path.includes('/auth/register') || path.includes('/auth/refresh');
}
// --- Core HTTP ---
/** Track whether the server appears reachable */
@@ -278,6 +559,7 @@ export class SaaSClient {
path: string,
body?: unknown,
timeoutMs = 15000,
_isRefreshRetry = false,
): Promise<T> {
const maxRetries = 2;
const baseDelay = 1000;
@@ -300,8 +582,31 @@ export class SaaSClient {
this._serverReachable = true;
// Handle 401 specially - caller may want to trigger re-auth
if (response.status === 401) {
// 401: 尝试刷新 Token 后重试 (防止递归)
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
try {
const newToken = await this.refreshToken();
if (newToken) {
// Persist refreshed token to localStorage
try {
const raw = localStorage.getItem('zclaw-saas-session');
if (raw) {
const session = JSON.parse(raw);
session.token = newToken;
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
}
} catch { /* non-blocking */ }
return this.request<T>(method, path, body, timeoutMs, true);
}
} catch (refreshErr) {
// Token refresh failed — clear session and trigger logout
try {
const { clearSaaSSession } = require('./saas-client');
clearSaaSSession();
localStorage.removeItem('zclaw-connection-mode');
} catch { /* non-blocking */ }
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
}
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
}
@@ -364,6 +669,8 @@ export class SaaSClient {
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
const body: Record<string, string> = { username, password };
if (totpCode) body.totp_code = totpCode;
// Clear stale token before login — avoid sending expired token on auth endpoint
this.token = null;
const data = await this.request<SaaSLoginResponse>(
'POST', '/api/v1/auth/login', body,
);
@@ -381,6 +688,8 @@ export class SaaSClient {
password: string;
display_name?: string;
}): Promise<SaaSLoginResponse> {
// Clear stale token before register
this.token = null;
const result = await this.request<SaaSLoginResponse>(
'POST', '/api/v1/auth/register', data,
);
@@ -449,10 +758,14 @@ export class SaaSClient {
/**
* Send a heartbeat to indicate the device is still active.
* Also sends platform and app_version so the backend can detect client upgrades.
*/
async deviceHeartbeat(deviceId: string): Promise<void> {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
device_id: deviceId,
platform: typeof navigator !== 'undefined' ? navigator.platform : undefined,
app_version: appVersion,
});
}
@@ -460,7 +773,8 @@ export class SaaSClient {
* List devices registered for the current account.
*/
async listDevices(): Promise<DeviceInfo[]> {
return this.request<DeviceInfo[]>('GET', '/api/v1/devices');
const res = await this.request<{ items: DeviceInfo[] }>('GET', '/api/v1/devices');
return res.items;
}
// --- Model Endpoints ---
@@ -501,6 +815,7 @@ export class SaaSClient {
* Send a chat completion request via the SaaS relay.
* Returns the raw Response object to support both streaming and non-streaming.
*
* Includes one retry on 401 (auto token refresh) and on network errors.
* The caller is responsible for:
* - Reading the response body (JSON or SSE stream)
* - Handling errors from the response
@@ -509,27 +824,59 @@ export class SaaSClient {
body: unknown,
signal?: AbortSignal,
): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
const maxAttempts = 2; // 1 initial + 1 retry
for (let attempt = 0; attempt < maxAttempts; attempt++) {
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);
try {
const response = await fetch(
`${this.baseUrl}/api/v1/relay/chat/completions`,
{
method: 'POST',
headers,
body: JSON.stringify(body),
signal: effectiveSignal,
},
);
// On 401, attempt token refresh once
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
try {
const newToken = await this.refreshToken();
if (newToken) continue; // Retry with refreshed token
} catch {
// Refresh failed, return the 401 response
}
}
this._serverReachable = true;
return response;
} catch (err: unknown) {
this._serverReachable = false;
const isNetworkError = err instanceof TypeError
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
if (isNetworkError && attempt < maxAttempts - 1) {
// Brief backoff before retry
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
throw err;
}
}
// 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;
// Unreachable but TypeScript needs it
throw new Error('chatCompletion: all attempts exhausted');
}
// --- Config Endpoints ---
@@ -539,7 +886,8 @@ export class SaaSClient {
*/
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
const res = await this.request<{ items: SaaSConfigItem[] }>('GET', `/api/v1/config/items${qs}`);
return res.items;
}
/** Compute config diff between client and SaaS (read-only) */
@@ -551,6 +899,302 @@ export class SaaSClient {
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
}
/**
* Pull all config items from SaaS (for startup auto-sync).
* Returns configs updated since the given timestamp, or all if since is omitted.
*/
async pullConfig(since?: string): Promise<{
configs: Array<{
key: string
category: string
value: string | null
value_type: string
default: string | null
updated_at: string
}>
pulled_at: string
}> {
const qs = since ? `?since=${encodeURIComponent(since)}` : '';
return this.request('GET', '/api/v1/config/pull' + qs);
}
// --- Provider Management (Admin) ---
/** List all providers */
async listProviders(): Promise<ProviderInfo[]> {
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
}
/** Get provider by ID */
async getProvider(id: string): Promise<ProviderInfo> {
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
}
/** Create a new provider (admin only) */
async createProvider(data: CreateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
}
/** Update a provider (admin only) */
async updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
}
/** Delete a provider (admin only) */
async deleteProvider(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
}
// --- Model Management (Admin) ---
/** List models, optionally filtered by provider */
async listModelsAdmin(providerId?: string): Promise<ModelInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
}
/** Get model by ID */
async getModel(id: string): Promise<ModelInfo> {
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
}
/** Create a new model (admin only) */
async createModel(data: CreateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('POST', '/api/v1/models', data);
}
/** Update a model (admin only) */
async updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
}
/** Delete a model (admin only) */
async deleteModel(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/models/${id}`);
}
// --- Account API Keys ---
/** List account's API keys */
async listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
}
/** Create a new API key */
async createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
}
/** Rotate an API key */
async rotateApiKey(id: string, newKeyValue: string): Promise<void> {
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
}
/** Revoke an API key */
async revokeApiKey(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
}
// --- Usage Statistics ---
/** Get usage statistics for current account */
async getUsage(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
const qs = new URLSearchParams();
if (params?.from) qs.set('from', params.from);
if (params?.to) qs.set('to', params.to);
if (params?.provider_id) qs.set('provider_id', params.provider_id);
if (params?.model_id) qs.set('model_id', params.model_id);
const query = qs.toString();
return this.request<UsageStats>('GET', `/api/v1/usage${query ? '?' + query : ''}`);
}
// --- Account Management (Admin) ---
/** List all accounts (admin only) */
async listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
if (params?.role) qs.set('role', params.role);
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
const query = qs.toString();
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
}
/** Get account by ID (admin or self) */
async getAccount(id: string): Promise<AccountPublic> {
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
}
/** Update account (admin or self) */
async updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
}
/** Update account status (admin only) */
async updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
}
// --- API Token Management ---
/** List API tokens for current account */
async listTokens(): Promise<TokenInfo[]> {
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
}
/** Create a new API token */
async createToken(data: CreateTokenRequest): Promise<TokenInfo> {
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
}
/** Revoke an API token */
async revokeToken(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
}
// --- Operation Logs (Admin) ---
/** List operation logs (admin only) */
async listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
const query = qs.toString();
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
}
// --- Dashboard Statistics (Admin) ---
/** Get dashboard statistics (admin only) */
async getDashboardStats(): Promise<DashboardStats> {
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
}
// --- Role Management (Admin) ---
/** List all roles */
async listRoles(): Promise<RoleInfo[]> {
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
}
/** Get role by ID */
async getRole(id: string): Promise<RoleInfo> {
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
}
/** Create a new role (admin only) */
async createRole(data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
}
/** Update a role (admin only) */
async updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
}
/** Delete a role (admin only) */
async deleteRole(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
}
// --- Permission Templates ---
/** List permission templates */
async listPermissionTemplates(): Promise<PermissionTemplate[]> {
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
}
/** Get permission template by ID */
async getPermissionTemplate(id: string): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
}
/** Create a permission template (admin only) */
async createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
}
/** Delete a permission template (admin only) */
async deletePermissionTemplate(id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
}
/** Apply permission template to accounts (admin only) */
async applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
}
// === Prompt OTA ===
/** Check for prompt updates (OTA) */
async checkPromptUpdates(deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult> {
return this.request<PromptCheckResult>('POST', '/api/v1/prompts/check', {
device_id: deviceId,
versions: currentVersions,
});
}
/** List all prompt templates */
async listPrompts(params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise<PaginatedResponse<PromptTemplateInfo>> {
const qs = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
return this.request<PaginatedResponse<PromptTemplateInfo>>('GET', `/api/v1/prompts${qs}`);
}
/** Get prompt template by name */
async getPrompt(name: string): Promise<PromptTemplateInfo> {
return this.request<PromptTemplateInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}`);
}
/** List prompt versions */
async listPromptVersions(name: string): Promise<PromptVersionInfo[]> {
return this.request<PromptVersionInfo[]>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions`);
}
/** Get specific prompt version */
async getPromptVersion(name: string, version: number): Promise<PromptVersionInfo> {
return this.request<PromptVersionInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions/${version}`);
}
// === Telemetry ===
/** Report anonymous usage telemetry (token counts only, no content) */
async reportTelemetry(data: {
device_id: string;
app_version: string;
entries: Array<{
model_id: string;
input_tokens: number;
output_tokens: number;
latency_ms?: number;
success: boolean;
error_type?: string;
timestamp: string;
connection_mode: string;
}>;
}): Promise<{ accepted: number; rejected: number }> {
return this.request<{ accepted: number; rejected: number }>(
'POST', '/api/v1/telemetry/report', data,
);
}
/** Report audit log summary (action types and counts only, no content) */
async reportAuditSummary(data: {
device_id: string;
entries: Array<{
action: string;
target: string;
result: string;
timestamp: string;
}>;
}): Promise<{ accepted: number; total: number }> {
return this.request<{ accepted: number; total: number }>(
'POST', '/api/v1/telemetry/audit', data,
);
}
}
// === Singleton ===

View File

@@ -18,6 +18,9 @@ import {
encrypt,
decrypt,
generateMasterKey,
generateSalt,
arrayToBase64,
base64ToArray,
} from './crypto-utils';
// Cache for keyring availability check
@@ -27,9 +30,6 @@ let keyringAvailable: boolean | null = null;
const ENCRYPTED_PREFIX = 'enc_';
const MASTER_KEY_NAME = 'zclaw-master-key';
// Cache for the derived crypto key
let cachedCryptoKey: CryptoKey | null = null;
/**
* Check if secure storage (keyring) is available
*/
@@ -138,25 +138,6 @@ export const secureStorage = {
* Now with AES-GCM encryption for non-Tauri environments
*/
/**
* Get or create the master encryption key for localStorage fallback
*/
async function getOrCreateMasterKey(): Promise<CryptoKey> {
if (cachedCryptoKey) {
return cachedCryptoKey;
}
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
if (!masterKeyRaw) {
masterKeyRaw = generateMasterKey();
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
}
cachedCryptoKey = await deriveKey(masterKeyRaw);
return cachedCryptoKey;
}
/**
* Check if a stored value is encrypted (has iv and data fields)
*/
@@ -169,8 +150,21 @@ function isEncrypted(value: string): boolean {
}
}
/**
* Check if a stored value uses the v2 format (with random salt)
*/
function isV2Encrypted(value: string): boolean {
try {
const parsed = JSON.parse(value);
return parsed && parsed.version === 2 && typeof parsed.salt === 'string' && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
} catch {
return false;
}
}
/**
* Write encrypted data to localStorage
* Uses random salt per encryption (v2 format) for forward secrecy
*/
async function writeEncryptedLocalStorage(key: string, value: string): Promise<void> {
try {
@@ -178,45 +172,78 @@ async function writeEncryptedLocalStorage(key: string, value: string): Promise<v
if (!value) {
localStorage.removeItem(encryptedKey);
// Also remove legacy unencrypted key
localStorage.removeItem(key);
return;
}
try {
const cryptoKey = await getOrCreateMasterKey();
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
if (!masterKeyRaw) {
masterKeyRaw = generateMasterKey();
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
}
// Generate a random salt for each encryption (v2 format)
const salt = generateSalt(16);
const cryptoKey = await deriveKey(masterKeyRaw, salt);
const encrypted = await encrypt(value, cryptoKey);
localStorage.setItem(encryptedKey, JSON.stringify(encrypted));
// Remove legacy unencrypted key if it exists
const encryptedPayload = {
version: 2,
salt: arrayToBase64(salt),
iv: encrypted.iv,
data: encrypted.data,
};
localStorage.setItem(encryptedKey, JSON.stringify(encryptedPayload));
localStorage.removeItem(key);
} catch (error) {
console.error('[SecureStorage] Encryption failed:', error);
// Fallback to plaintext if encryption fails (should not happen)
localStorage.setItem(key, value);
// Do NOT fall back to plaintext — throw to signal the error
throw error;
}
} catch {
// Ignore localStorage failures
} catch (error) {
console.error('[SecureStorage] Failed to write encrypted localStorage:', error);
throw error;
}
}
/**
* Read and decrypt data from localStorage
* Supports both encrypted and legacy unencrypted formats
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
*/
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
try {
// Try encrypted key first
const encryptedKey = ENCRYPTED_PREFIX + key;
const encryptedRaw = localStorage.getItem(encryptedKey);
if (encryptedRaw && isEncrypted(encryptedRaw)) {
try {
const cryptoKey = await getOrCreateMasterKey();
const encrypted = JSON.parse(encryptedRaw);
return await decrypt(encrypted, cryptoKey);
} catch (error) {
console.error('[SecureStorage] Decryption failed:', error);
// Fall through to try legacy key
if (encryptedRaw) {
const masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
// Try v2 format (random salt)
if (masterKeyRaw && isV2Encrypted(encryptedRaw)) {
try {
const parsed = JSON.parse(encryptedRaw);
const salt = base64ToArray(parsed.salt);
const cryptoKey = await deriveKey(masterKeyRaw, salt);
return await decrypt(
{ iv: parsed.iv, data: parsed.data },
cryptoKey,
);
} catch (error) {
console.error('[SecureStorage] v2 decryption failed:', error);
// Fall through to try v1
}
}
// Try v1 format (static salt, backward compat)
if (masterKeyRaw && isEncrypted(encryptedRaw)) {
try {
const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt
const encrypted = JSON.parse(encryptedRaw);
return await decrypt(encrypted, cryptoKey);
} catch (error) {
console.error('[SecureStorage] v1 decryption failed:', error);
}
}
}

View File

@@ -12,7 +12,7 @@
* - Configuration changes
*/
import { hashSha256 } from './crypto-utils';
import { hashSha256, generateRandomString } from './crypto-utils';
// ============================================================================
// Types
@@ -90,7 +90,7 @@ let currentSessionId: string | null = null;
* Generate a unique event ID
*/
function generateEventId(): string {
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
return `evt_${Date.now()}_${generateRandomString(8)}`;
}
/**

View File

@@ -0,0 +1,209 @@
/**
* Telemetry Collector — 桌面端遥测收集器
*
* 收集本地 LLM 调用的 Token 用量统计和审计日志摘要(均无内容),
* 定期批量上报到 SaaS。
*
* 用量缓冲区上限 100 条,审计缓冲区上限 200 条,超限自动 flush。
* 定时 flush 每 5 分钟。仅在 SaaS 已登录时上报。
*/
import { saasClient } from './saas-client';
import { createLogger } from './logger';
const log = createLogger('TelemetryCollector');
// === Types ===
export interface TelemetryEntry {
model_id: string;
input_tokens: number;
output_tokens: number;
latency_ms?: number;
success: boolean;
error_type?: string;
timestamp: string;
connection_mode: string;
}
interface AuditEntry {
action: string;
target: string;
result: string;
timestamp: string;
}
// === State ===
const USAGE_BUFFER_LIMIT = 100;
const AUDIT_BUFFER_LIMIT = 200;
const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
let usageBuffer: TelemetryEntry[] = [];
let auditBuffer: AuditEntry[] = [];
let flushTimer: ReturnType<typeof setInterval> | null = null;
let deviceId: string | null = null;
// === Public API ===
/**
* 初始化遥测收集器(在 SaaS 登录后调用)。
* @param devId 设备 ID与 saasStore 使用的相同)
*/
export function initTelemetryCollector(devId: string): void {
deviceId = devId;
if (flushTimer) {
clearInterval(flushTimer);
}
flushTimer = setInterval(() => {
flushAll().catch((err: unknown) => {
log.warn('Scheduled telemetry flush failed:', err);
});
}, FLUSH_INTERVAL_MS);
log.info('Telemetry collector initialized');
}
/**
* 停止遥测收集器(在 SaaS 登出时调用)。
* 会尝试 flush 剩余条目。
*/
export function stopTelemetryCollector(): void {
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
// 尝试最后一次 flush
flushAll().catch(() => {
// 登出时不阻塞
});
usageBuffer = [];
auditBuffer = [];
deviceId = null;
log.info('Telemetry collector stopped');
}
/**
* 记录一次 LLM 调用的用量。
*
* @param modelId 模型标识
* @param inputTokens 输入 Token 数
* @param outputTokens 输出 Token 数
* @param options 可选参数
*/
export function recordLLMUsage(
modelId: string,
inputTokens: number,
outputTokens: number,
options?: {
latencyMs?: number;
success?: boolean;
errorType?: string;
connectionMode?: string;
},
): void {
if (!deviceId) return;
usageBuffer.push({
model_id: modelId,
input_tokens: inputTokens,
output_tokens: outputTokens,
latency_ms: options?.latencyMs,
success: options?.success ?? true,
error_type: options?.errorType,
timestamp: new Date().toISOString(),
connection_mode: options?.connectionMode || 'tauri',
});
if (usageBuffer.length >= USAGE_BUFFER_LIMIT) {
flushUsage().catch((err: unknown) => {
log.warn('Auto-flush usage triggered but failed:', err);
});
}
}
/**
* 记录一条审计日志摘要(仅操作类型,无内容)。
*
* @param action 操作类型(如 "hand.trigger", "agent.create"
* @param target 操作目标(如 Agent/Hand 名称)
* @param result 操作结果
*/
export function recordAuditEvent(
action: string,
target: string,
result: 'success' | 'failure' | 'pending',
): void {
if (!deviceId) return;
auditBuffer.push({
action,
target,
result,
timestamp: new Date().toISOString(),
});
if (auditBuffer.length >= AUDIT_BUFFER_LIMIT) {
flushAudit().catch((err: unknown) => {
log.warn('Auto-flush audit triggered but failed:', err);
});
}
}
// === Internal ===
async function flushAll(): Promise<void> {
await Promise.allSettled([
flushUsage(),
flushAudit(),
]);
}
async function flushUsage(): Promise<void> {
if (usageBuffer.length === 0 || !deviceId || !saasClient.isAuthenticated()) {
return;
}
const entries = usageBuffer;
usageBuffer = [];
try {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
const result = await saasClient.reportTelemetry({
device_id: deviceId,
app_version: appVersion,
entries,
});
log.info(`Usage telemetry flushed: ${result.accepted} accepted, ${result.rejected} rejected`);
} catch (err: unknown) {
usageBuffer = [...entries, ...usageBuffer].slice(0, USAGE_BUFFER_LIMIT * 2);
log.warn('Usage telemetry flush failed, entries re-buffered:', err);
}
}
async function flushAudit(): Promise<void> {
if (auditBuffer.length === 0 || !deviceId || !saasClient.isAuthenticated()) {
return;
}
const entries = auditBuffer;
auditBuffer = [];
try {
const result = await saasClient.reportAuditSummary({
device_id: deviceId,
entries,
});
log.info(`Audit summary flushed: ${result.accepted} accepted`);
} catch (err: unknown) {
auditBuffer = [...entries, ...auditBuffer].slice(0, AUDIT_BUFFER_LIMIT * 2);
log.warn('Audit summary flush failed, entries re-buffered:', err);
}
}

View File

@@ -8,6 +8,7 @@ import { getSkillDiscovery } from '../lib/skill-discovery';
import { useOfflineStore, isOffline } from './offlineStore';
import { useConnectionStore } from './connectionStore';
import { createLogger } from '../lib/logger';
import { generateRandomString } from '../lib/crypto-utils';
const log = createLogger('ChatStore');
@@ -106,7 +107,7 @@ interface ChatState {
}
function generateConvId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
return `conv_${Date.now()}_${generateRandomString(4)}`;
}
function deriveTitle(messages: Message[]): string {
@@ -224,7 +225,12 @@ export const useChatStore = create<ChatState>()(
const conversations = upsertActiveConversation([...state.conversations], state);
// Try to find existing conversation for this agent
const agentConversation = conversations.find(c => c.agentId === agent.id);
// DEFAULT_AGENT conversations are stored with agentId: null (via resolveConversationAgentId),
// so we need to match both the agent's ID and null for default agent lookups.
const agentConversation = conversations.find(c =>
c.agentId === agent.id ||
(agent.id === DEFAULT_AGENT.id && c.agentId === null)
);
if (agentConversation) {
// Restore the agent's previous conversation
@@ -251,7 +257,11 @@ export const useChatStore = create<ChatState>()(
syncAgents: (profiles) =>
set((state) => {
const agents = profiles.length > 0 ? profiles.map(toChatAgent) : [DEFAULT_AGENT];
const cloneAgents = profiles.length > 0 ? profiles.map(toChatAgent) : [];
// Always include DEFAULT_AGENT so users can switch back to default conversations
const agents = cloneAgents.length > 0
? [DEFAULT_AGENT, ...cloneAgents]
: [DEFAULT_AGENT];
const currentAgent = state.currentConversationId
? resolveAgentForConversation(
state.conversations.find((conversation) => conversation.id === state.currentConversationId)?.agentId || null,
@@ -260,7 +270,20 @@ export const useChatStore = create<ChatState>()(
: state.currentAgent
? agents.find((agent) => agent.id === state.currentAgent?.id) || agents[0]
: agents[0];
return { agents, currentAgent };
// Safety net: if rehydration failed to restore messages (onRehydrateStorage
// direct mutation doesn't trigger re-renders), restore them here via set().
let messages = state.messages;
let sessionKey = state.sessionKey;
if (messages.length === 0 && state.currentConversationId && state.conversations.length > 0) {
const conv = state.conversations.find(c => c.id === state.currentConversationId);
if (conv && conv.messages.length > 0) {
messages = conv.messages.map(m => ({ ...m }));
sessionKey = conv.sessionKey;
}
}
return { agents, currentAgent, messages, sessionKey };
}),
setCurrentModel: (model) => set({ currentModel: model }),
@@ -307,7 +330,7 @@ export const useChatStore = create<ChatState>()(
sendMessage: async (content: string) => {
const { addMessage, currentAgent, sessionKey } = get();
const effectiveSessionKey = sessionKey || `session_${Date.now()}`;
const effectiveSessionKey = sessionKey || crypto.randomUUID();
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
const agentId = currentAgent?.id || 'zclaw-main';
@@ -413,7 +436,7 @@ export const useChatStore = create<ChatState>()(
},
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `tool_${Date.now()}_${generateRandomString(4)}`,
role: 'tool',
content: output || input,
timestamp: new Date(),
@@ -426,7 +449,7 @@ export const useChatStore = create<ChatState>()(
},
onHand: (name: string, status: string, result?: unknown) => {
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `hand_${Date.now()}_${generateRandomString(4)}`,
role: 'hand',
content: result
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
@@ -588,7 +611,7 @@ export const useChatStore = create<ChatState>()(
}));
} else if (delta.stream === 'tool') {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `tool_${Date.now()}_${generateRandomString(4)}`,
role: 'tool',
content: delta.toolOutput || '',
timestamp: new Date(),
@@ -616,7 +639,7 @@ export const useChatStore = create<ChatState>()(
} else if (delta.stream === 'hand') {
// Handle Hand trigger events from ZCLAW
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `hand_${Date.now()}_${generateRandomString(4)}`,
role: 'hand',
content: delta.handResult
? (typeof delta.handResult === 'string' ? delta.handResult : JSON.stringify(delta.handResult, null, 2))
@@ -631,7 +654,7 @@ export const useChatStore = create<ChatState>()(
} else if (delta.stream === 'workflow') {
// Handle Workflow execution events from ZCLAW
const workflowMsg: Message = {
id: `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `workflow_${Date.now()}_${generateRandomString(4)}`,
role: 'workflow',
content: delta.workflowResult
? (typeof delta.workflowResult === 'string' ? delta.workflowResult : JSON.stringify(delta.workflowResult, null, 2))
@@ -671,12 +694,18 @@ export const useChatStore = create<ChatState>()(
}
}
// Restore messages from current conversation if exists
// Restore messages from current conversation via setState() to properly
// trigger subscriber re-renders. Direct mutation (state.messages = ...)
// does NOT notify zustand subscribers, leaving the UI stuck on [].
// Safe to reference useChatStore here because zustand persist runs
// hydration via setTimeout(1ms), so the store is fully created by
// the time this callback executes.
if (state?.currentConversationId && state.conversations) {
const currentConv = state.conversations.find(c => c.id === state.currentConversationId);
if (currentConv) {
state.messages = [...currentConv.messages];
state.sessionKey = currentConv.sessionKey;
if (currentConv && currentConv.messages.length > 0) {
const messages = currentConv.messages.map(m => ({ ...m }));
const sessionKey = currentConv.sessionKey;
useChatStore.setState({ messages, sessionKey });
}
}
},

View File

@@ -28,6 +28,7 @@ import {
} from '../lib/health-check';
import { useConfigStore } from './configStore';
import { createLogger } from '../lib/logger';
import { secureStorage } from '../lib/secure-storage';
const log = createLogger('ConnectionStore');
@@ -38,6 +39,7 @@ const log = createLogger('ConnectionStore');
// === Custom Models Helpers ===
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:';
interface CustomModel {
id: string;
@@ -51,7 +53,8 @@ interface CustomModel {
}
/**
* Get custom models from localStorage
* Get custom models from localStorage.
* NOTE: apiKeys are stripped from localStorage. Use getCustomModelApiKey() to retrieve them.
*/
function loadCustomModels(): CustomModel[] {
try {
@@ -66,13 +69,147 @@ function loadCustomModels(): CustomModel[] {
}
/**
* Get the default model configuration
* Save custom models to localStorage. API keys are stripped before saving.
* Use saveCustomModelApiKey() separately to persist the key securely.
*/
function saveCustomModels(models: CustomModel[]): void {
try {
// Strip apiKeys before persisting to localStorage
const sanitized = models.map(m => {
const { apiKey: _, ...rest } = m;
return rest;
});
localStorage.setItem(CUSTOM_MODELS_STORAGE_KEY, JSON.stringify(sanitized));
} catch (err) {
log.error('Failed to save models:', err);
}
}
/**
* Save an API key for a custom model to secure storage.
*/
export async function saveCustomModelApiKey(modelId: string, apiKey: string): Promise<void> {
if (!apiKey.trim()) {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
return;
}
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + modelId, apiKey.trim());
}
/**
* Retrieve an API key for a custom model from secure storage.
* Falls back to localStorage if secure storage is empty (migration path).
*/
export async function getCustomModelApiKey(modelId: string): Promise<string | null> {
const secureKey = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + modelId);
if (secureKey) {
return secureKey;
}
return null;
}
/**
* Delete an API key for a custom model from secure storage.
*/
export async function deleteCustomModelApiKey(modelId: string): Promise<void> {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
}
/**
* Migrate all plaintext API keys from localStorage custom models to secure storage.
* This is idempotent -- running it multiple times is safe.
* After migration, apiKeys are stripped from localStorage.
*/
export async function migrateModelApiKeysToSecureStorage(): Promise<void> {
try {
const stored = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY);
if (!stored) return;
const models: CustomModel[] = JSON.parse(stored);
let hasPlaintextKeys = false;
for (const model of models) {
if (model.apiKey && model.apiKey.trim()) {
hasPlaintextKeys = true;
// Check if secure storage already has this key (skip if migrated)
const existing = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + model.id);
if (!existing) {
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + model.id, model.apiKey.trim());
log.debug('Migrated API key for model:', model.id);
}
}
}
if (hasPlaintextKeys) {
// Re-save without apiKeys to clear them from localStorage
saveCustomModels(models);
log.info('Migrated', models.length, 'model API keys to secure storage');
}
} catch (err) {
log.warn('Failed to migrate model API keys:', err);
}
}
/**
* Get the default model configuration (async version).
* Retrieves apiKey from secure storage.
*
* Priority:
* 1. Model with isDefault: true
* 2. Model matching chatStore's currentModel
* 3. First model in the list
*/
export async function getDefaultModelConfigAsync(): Promise<{ provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null> {
const models = loadCustomModels();
// Priority 1: Find model with isDefault: true
let defaultModel = models.find(m => m.isDefault === true);
// Priority 2: Find model matching chatStore's currentModel
if (!defaultModel) {
try {
const chatStoreData = localStorage.getItem('zclaw-chat-storage');
if (chatStoreData) {
const parsed = JSON.parse(chatStoreData);
const currentModelId = parsed?.state?.currentModel;
if (currentModelId) {
defaultModel = models.find(m => m.id === currentModelId);
}
}
} catch (err) {
log.warn('Failed to read chatStore:', err);
}
}
// Priority 3: First model
if (!defaultModel) {
defaultModel = models[0];
}
if (defaultModel) {
// Retrieve apiKey from secure storage
const apiKey = await getCustomModelApiKey(defaultModel.id);
return {
provider: defaultModel.provider,
model: defaultModel.id,
apiKey: apiKey || '',
baseUrl: defaultModel.baseUrl || '',
apiProtocol: defaultModel.apiProtocol || 'openai',
};
}
return null;
}
/**
* Get the default model configuration (sync fallback).
* NOTE: This version cannot retrieve apiKeys from secure storage.
* Use getDefaultModelConfigAsync() when possible.
*
* @deprecated Use getDefaultModelConfigAsync() instead. This sync version
* is kept only for backward compatibility and will NOT return apiKeys that
* were stored via secure storage.
*/
export function getDefaultModelConfig(): { provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null {
const models = loadCustomModels();
@@ -234,7 +371,14 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// Health check via GET /api/v1/relay/models
try {
await saasClient.listModels();
} catch (err) {
} catch (err: unknown) {
// Handle expired session — clear auth and trigger re-login
const status = (err as { status?: number })?.status;
if (status === 401) {
const { useSaaSStore } = await import('./saasStore');
useSaaSStore.getState().logout();
throw new Error('SaaS 会话已过期,请重新登录');
}
const errMsg = err instanceof Error ? err.message : String(err);
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
}
@@ -253,8 +397,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
log.debug('Using internal ZCLAW Kernel (no external process needed)');
const kernelClient = getKernelClient();
// Get model config from custom models settings
const modelConfig = getDefaultModelConfig();
// Get model config from custom models settings (async for secure key retrieval)
const modelConfig = await getDefaultModelConfigAsync();
if (!modelConfig) {
throw new Error('请先在"模型与 API"设置页面添加自定义模型配置');

View File

@@ -8,6 +8,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useConnectionStore, getConnectionState } from './connectionStore';
import { generateRandomString } from '../lib/crypto-utils';
// === Types ===
@@ -75,7 +76,7 @@ function calculateNextDelay(currentDelay: number): number {
}
function generateMessageId(): string {
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return `queued_${Date.now()}_${generateRandomString(6)}`;
}
// === Store Implementation ===

View File

@@ -24,8 +24,17 @@ import {
type SaaSModelInfo,
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
initTelemetryCollector,
stopTelemetryCollector,
} from '../lib/telemetry-collector';
import {
startPromptOTASync,
stopPromptOTASync,
} from '../lib/llm-service';
const log = createLogger('SaaSStore');
@@ -58,6 +67,12 @@ export interface SaaSStateSlice {
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
}
export interface SaaSActionsSlice {
@@ -67,6 +82,8 @@ export interface SaaSActionsSlice {
logout: () => void;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
@@ -118,19 +135,21 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
_consecutiveFailures: 0,
// === Actions ===
login: async (saasUrl: string, username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
const requestUrl = normalizedUrl || window.location.origin;
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
try {
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!trimmedUsername) {
throw new Error('请输入用户名');
}
@@ -138,8 +157,6 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
throw new Error('请输入密码');
}
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
// Configure singleton client and attempt login
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
@@ -172,6 +189,22 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
});
// Auto-pull SaaS config in background (non-blocking)
get().syncConfigFromSaaS().then(() => {
// After pull, push any locally modified configs back to SaaS
get().pushConfigToSaaS().catch((err: unknown) => {
log.warn('Failed to push config to SaaS:', err);
});
}).catch((err: unknown) => {
log.warn('Failed to sync config after login:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
// Check for TOTP required signal
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
@@ -191,7 +224,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|| message.includes('timeout');
const userMessage = isNetworkError
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
? `无法连接到 SaaS 服务器: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
@@ -232,6 +265,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
@@ -245,9 +284,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
const trimmedUrl = saasUrl.trim();
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!username.trim()) {
throw new Error('请输入用户名');
}
@@ -293,6 +330,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after register:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError
? err.message
@@ -309,6 +352,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setToken(null);
clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
set({
isLoggedIn: false,
@@ -354,6 +399,131 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
/**
* Push locally modified configs to SaaS (push direction of bidirectional sync).
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Collect all dirty config keys
const dirtyKeys: string[] = [];
const dirtyValues: Record<string, unknown> = {};
let i = 0;
while (true) {
const key = localStorage.key(i);
if (!key) break;
i++;
if (key.startsWith('zclaw-config-dirty.') && localStorage.getItem(key) === '1') {
const configKey = key.replace('zclaw-config-dirty.', '');
const storageKey = `zclaw-config.${configKey}`;
const value = localStorage.getItem(storageKey);
if (value !== null) {
dirtyKeys.push(configKey);
dirtyValues[configKey] = value;
}
}
}
if (dirtyKeys.length === 0) return;
// Generate a client fingerprint
const fingerprint = DEVICE_ID;
const syncRequest = {
client_fingerprint: fingerprint,
action: 'merge' as const,
config_keys: dirtyKeys,
client_values: dirtyValues,
};
// Compute diff first (dry run)
const diff = await saasClient.computeConfigDiff(syncRequest as SyncConfigRequest);
if (diff.conflicts > 0) {
log.warn(`Config sync has ${diff.conflicts} conflicts, using merge strategy`);
}
// Perform actual sync
const result = await saasClient.syncConfig(syncRequest);
log.info(`Config push result: ${result.updated} updated, ${result.created} created, ${result.skipped} skipped`);
// Clear dirty flags for successfully synced keys
for (const key of dirtyKeys) {
localStorage.removeItem(`zclaw-config-dirty.${key}`);
}
} catch (err: unknown) {
log.warn('Failed to push config to SaaS:', err);
}
},
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Read last sync timestamp from localStorage
const lastSyncKey = 'zclaw-config-last-sync';
const lastSync = localStorage.getItem(lastSyncKey) || undefined;
const result = await saasClient.pullConfig(lastSync);
if (result.configs.length === 0) {
log.info('No config updates from SaaS');
return;
}
// Apply SaaS config values to localStorage
// Each config is stored as zclaw-config.{category}.{key}
for (const config of result.configs) {
if (config.value === null) continue;
const storageKey = `zclaw-config.${config.category}.${config.key}`;
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
const lastPullKey = `zclaw-config-pull-ts.${config.category}.${config.key}`;
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);
if (dirtyKey && localStorage.getItem(dirtyKey) === '1') {
// Local was modified since last pull → keep local, skip overwrite
log.warn(`Config conflict, keeping local: ${config.key}`);
continue;
}
// If existing value differs from what we last pulled AND differs from SaaS, local was modified
if (existing !== null && lastPulledValue !== null && existing !== lastPulledValue && existing !== config.value) {
log.warn(`Config conflict (local modified), keeping local: ${config.key}`);
continue;
}
// Only update if the value has actually changed
if (existing !== config.value) {
localStorage.setItem(storageKey, config.value);
// Record the pulled value for future diff checks
localStorage.setItem(`zclaw-config-pulled.${config.category}.${config.key}`, config.value);
log.info(`Config synced: ${config.key}`);
}
}
// Update last sync timestamp
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
@@ -368,21 +538,43 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
platform: navigator.platform,
app_version: __APP_VERSION__ || 'unknown',
app_version: (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'),
});
log.info('Device registered successfully');
// Start periodic heartbeat (every 5 minutes)
// Start periodic heartbeat (every 5 minutes) with failure tracking
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
const timer = window.setInterval(() => {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (state.isLoggedIn && state.authToken) {
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
} else {
if (!state.isLoggedIn || !state.authToken) {
window.clearInterval(timer);
return;
}
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// Reset failure count on success
if (state._consecutiveFailures > 0) {
log.info(`Heartbeat recovered after ${state._consecutiveFailures} failures`);
}
set({ _consecutiveFailures: 0, saasReachable: true } as unknown as Partial<SaaSStore>);
} catch (err) {
const failures = state._consecutiveFailures + 1;
log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`);
set({ _consecutiveFailures: failures } as unknown as Partial<SaaSStore>);
// Auto-degrade to local mode after threshold
if (failures >= DEGRADE_AFTER_FAILURES && state.connectionMode === 'saas') {
log.warn(`SaaS unreachable after ${failures} attempts — degrading to local mode`);
set({
saasReachable: false,
connectionMode: 'tauri',
} as unknown as Partial<SaaSStore>);
saveConnectionMode('tauri');
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial<SaaSStore>);
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
@@ -406,6 +598,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
}
},

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;