Files
zclaw_openfang/desktop/src/App.tsx
iven d871685e25
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(auth): 5 BUG 修复 — refresh token 持久化 + 密码验证 + 浏览器兼容
BUG-1 (P1): LoginPage 注册密码验证从 6 位改为 8 位,与后端一致
BUG-2 (P0): refresh token 持久化到 OS keyring + restoreSession 三级恢复
  (access token → refresh token → cookie auth) + saveSaaSSession 改为 await
BUG-3 (P0): Tauri 聊天路由降级问题,根因同 BUG-2(会话恢复失败)
BUG-4 (P1): App.tsx 跳过 Onboarding 改用 agentStore(兼容所有 client),
  Workspace.tsx Tauri invoke 改为动态 import 避免浏览器崩溃
BUG-5: tauri.conf.json createUpdaterArtifacts 改为 boolean true
2026-04-11 09:43:17 +08:00

545 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react';
import './index.css';
import { Sidebar } from './components/Sidebar';
import { ChatArea } from './components/ChatArea';
import { RightPanel } from './components/RightPanel';
import { ErrorBoundary } from './components/ui/ErrorBoundary';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
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';
import { getStoredGatewayToken } from './lib/gateway-client';
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, loadEmbeddingApiKey } from './lib/embedding-client';
import { invoke } from '@tauri-apps/api/core';
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
import { useToast } from './components/ui/Toast';
import type { Clone } from './store/agentStore';
import { createLogger } from './lib/logger';
import { startOfflineMonitor } from './store/offlineStore';
import { useUIModeStore } from './store/uiModeStore';
import { SimpleSidebar } from './components/SimpleSidebar';
const log = createLogger('App');
type View = 'main' | 'settings';
// Bootstrap component that ensures ZCLAW is running before rendering main UI
function BootstrapScreen({ status }: { status: string }) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<p className="text-gray-600 text-sm">{status}</p>
</div>
</div>
);
}
function App() {
const [view, setView] = useState<View>('main');
const [bootstrapping, setBootstrapping] = useState(true);
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
const [showOnboarding, setShowOnboarding] = useState(false);
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hand Approval state
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
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 } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();
const uiMode = useUIModeStore((s) => s.mode);
// Proposal notifications
const { toast } = useToast();
useProposalNotifications(); // Sets up polling for pending proposals
// Show toast when new proposals are available
useEffect(() => {
const handleProposalAvailable = (event: Event) => {
const customEvent = event as CustomEvent<{ count: number }>;
const { count } = customEvent.detail;
toast(`${count} 个新的人格变更提案待审批`, 'info');
};
window.addEventListener('zclaw:proposal-available', handleProposalAvailable);
return () => {
window.removeEventListener('zclaw:proposal-available', handleProposalAvailable);
};
}, [toast]);
useEffect(() => {
document.title = 'ZCLAW';
const stopOfflineMonitor = startOfflineMonitor();
return () => { stopOfflineMonitor(); };
}, []);
// Restore SaaS session from OS keyring on startup (before auth gate)
const [isRestoring, setIsRestoring] = useState(true);
useEffect(() => {
const restore = async () => {
try {
await useSaaSStore.getState().restoreSession();
} catch {
// Session restore failed — user will see login page
}
setIsRestoring(false);
};
restore();
}, []);
// Watch for Hands that need approval
useEffect(() => {
const handsNeedingApproval = hands.filter(h => h.status === 'needs_approval');
if (handsNeedingApproval.length > 0 && !showApprovalModal) {
// Find the first hand with needs_approval and create a pending run
const hand = handsNeedingApproval[0];
if (hand.currentRunId) {
setPendingApprovalRun({
runId: hand.currentRunId,
status: 'needs_approval',
startedAt: new Date().toISOString(),
});
setShowApprovalModal(true);
}
}
}, [hands, showApprovalModal]);
// Handle approval/rejection of Hand runs
const handleApproveHand = useCallback(async (runId: string) => {
// Find the hand that owns this run
const hand = hands.find(h => h.currentRunId === runId);
if (!hand) return;
await approveHand(hand.id, runId, true);
await loadHands();
setShowApprovalModal(false);
setPendingApprovalRun(null);
}, [hands, approveHand, loadHands]);
const handleRejectHand = useCallback(async (runId: string, reason: string) => {
// Find the hand that owns this run
const hand = hands.find(h => h.currentRunId === runId);
if (!hand) return;
await approveHand(hand.id, runId, false, reason);
await loadHands();
setShowApprovalModal(false);
setPendingApprovalRun(null);
}, [hands, approveHand, loadHands]);
const handleCloseApprovalModal = useCallback(() => {
setShowApprovalModal(false);
// Don't clear pendingApprovalRun - keep it for reference
}, []);
// Bootstrap: Start ZCLAW Gateway before rendering main UI
useEffect(() => {
let mounted = true;
const bootstrap = async () => {
// 未登录时不启动 bootstrap直接结束 loading
if (!useSaaSStore.getState().isLoggedIn) {
setBootstrapping(false);
return;
}
try {
// Step 1: Check and start local gateway in Tauri environment
if (isTauriRuntime()) {
setBootstrapStatus('Checking gateway status...');
try {
const status = await getLocalGatewayStatus();
const isRunning = status.portStatus === 'busy' || status.listenerPids.length > 0;
if (!isRunning && status.cliAvailable) {
setBootstrapStatus('Starting ZCLAW Gateway...');
log.debug('Local gateway not running, auto-starting...');
await startLocalGateway();
// Wait for gateway to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
log.debug('Local gateway started');
} else if (isRunning) {
log.debug('Local gateway already running');
}
} catch (err) {
log.warn('Failed to check/start local gateway:', err);
}
}
if (!mounted) return;
// Step 2: Connect to gateway
setBootstrapStatus('Connecting to gateway...');
const gatewayToken = getStoredGatewayToken();
await connect(undefined, gatewayToken);
if (!mounted) return;
// Step 3: Check if onboarding is needed
if (onboardingNeeded && !onboardingLoading) {
setShowOnboarding(true);
}
// Step 4: Initialize stores with gateway client
initializeStores();
// Step 4.5: Auto-start heartbeat engine for self-evolution
try {
const defaultAgentId = 'zclaw-main';
await intelligenceClient.heartbeat.init(defaultAgentId, {
enabled: true,
interval_minutes: 30,
quiet_hours_start: '22:00',
quiet_hours_end: '08:00',
notify_channel: 'ui',
proactivity_level: 'standard',
max_alerts_per_tick: 5,
});
// Sync memory stats to heartbeat engine
try {
const stats = await intelligenceClient.memory.stats();
const taskCount = stats.byType?.['task'] || 0;
await intelligenceClient.heartbeat.updateMemoryStats(
defaultAgentId,
taskCount,
stats.totalEntries,
stats.storageSizeBytes
);
log.debug('Memory stats synced to heartbeat engine');
} catch (statsErr) {
log.warn('Failed to sync memory stats:', statsErr);
}
await intelligenceClient.heartbeat.start(defaultAgentId);
log.debug('Heartbeat engine started for self-evolution');
// Set up periodic memory stats sync (every 5 minutes)
const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000;
const statsSyncInterval = setInterval(async () => {
try {
const stats = await intelligenceClient.memory.stats();
const taskCount = stats.byType?.['task'] || 0;
await intelligenceClient.heartbeat.updateMemoryStats(
defaultAgentId,
taskCount,
stats.totalEntries,
stats.storageSizeBytes
);
log.debug('Memory stats synced (periodic)');
} catch (err) {
log.warn('Periodic memory stats sync failed:', err);
}
}, MEMORY_STATS_SYNC_INTERVAL);
// Store interval for cleanup via ref
statsSyncRef.current = statsSyncInterval;
} catch (err) {
log.warn('Failed to start heartbeat engine:', err);
// Non-critical, continue without heartbeat
}
// Step 5: Restore embedding config to Rust backend (Tauri-only)
if (isTauriRuntime()) {
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();
const embApiKey = await loadEmbeddingApiKey();
if (embConfig.enabled && embConfig.provider !== 'local' && embApiKey) {
setBootstrapStatus('Restoring embedding configuration...');
await invoke('viking_configure_embedding', {
provider: embConfig.provider,
apiKey: embApiKey,
model: embConfig.model || undefined,
endpoint: embConfig.endpoint || undefined,
});
log.debug('Embedding configuration restored to backend');
}
} catch (embErr) {
log.warn('Failed to restore embedding config:', embErr);
// Non-critical, semantic search will fall back to TF-IDF
}
// Step 5b: Configure summary driver using active LLM (for L0/L1 generation)
try {
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', {
endpoint: modelConfig.baseUrl,
apiKey: modelConfig.apiKey,
model: modelConfig.model || undefined,
});
log.debug('Summary driver configured with active LLM');
}
} catch (sumErr) {
log.warn('Failed to configure summary driver:', sumErr);
// Non-critical, summaries won't be auto-generated
}
}
// Step 6: Bootstrap complete
setBootstrapping(false);
} catch (err) {
log.error('Bootstrap failed:', err);
// Still allow app to load, connection status will show error
setBootstrapping(false);
}
};
bootstrap();
return () => {
mounted = false;
// Clean up periodic stats sync interval
if (statsSyncRef.current) {
clearInterval(statsSyncRef.current);
}
};
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);
// Handle onboarding completion
const handleOnboardingSuccess = (clone: Clone) => {
markCompleted({
userName: clone.userName || 'User',
userRole: clone.userRole,
});
setCurrentAgent({
id: clone.id,
name: clone.name,
icon: clone.emoji || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: clone.role || 'New Agent',
time: '',
});
setShowOnboarding(false);
// Auto-trigger first Hand based on industry template
const templateId = clone.source_template_id || '';
const industryQueries: Record<string, { hand: string; action: string; query: string }> = {
'edu-teacher-001': { hand: 'researcher', action: 'report', query: '帮我研究2026年教育数字化转型趋势包括AI教学工具的最新进展' },
'healthcare-admin-001': { hand: 'collector', action: 'collect', query: '帮我采集最新的医疗政策文件摘要,重点关注基层医疗改革方向' },
'design-shantou-001': { hand: 'researcher', action: 'report', query: '帮我研究2026年服装设计流行趋势包括色彩、面料和款式创新' },
};
const task = industryQueries[templateId];
if (task) {
// Delay slightly to let UI settle
setTimeout(() => {
useHandStore.getState().triggerHand(task.hand, {
action: task.action,
query: { query: task.query },
}).catch(() => {
// Non-critical — user can trigger manually
});
}, 2000);
}
};
// 登录门禁 — 必须登录才能使用
if (isRestoring) {
return <BootstrapScreen status="Restoring session..." />;
}
if (!isLoggedIn) {
return <LoginPage />;
}
if (view === 'settings') {
return <SettingsLayout onBack={() => setView('main')} />;
}
// Show bootstrap screen while starting gateway
if (bootstrapping) {
return <BootstrapScreen status={bootstrapStatus} />;
}
// Show onboarding wizard for first-time users
if (showOnboarding) {
return (
<AgentOnboardingWizard
isOpen={true}
onClose={async () => {
// Skip onboarding but still create a default agent with default personality
try {
// Use agentStore (which uses the correct client from connectionStore)
// instead of directly importing getGatewayClient
const { useAgentStore } = await import('./store/agentStore');
const agentStore = useAgentStore.getState();
// Create default agent with versatile assistant personality
const result = await agentStore.createClone({
name: '全能助手',
role: '全能型 AI 助手',
nickname: '小龙',
emoji: '🦞',
personality: 'friendly',
scenarios: ['coding', 'writing', 'research', 'product', 'data'],
userName: 'User',
userRole: 'user',
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
});
if (result) {
setCurrentAgent({
id: result.id,
name: result.name,
icon: result.emoji || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: result.role || '全能型 AI 助手',
time: '',
});
}
} catch (err) {
log.warn('Failed to create default agent on skip:', err);
}
// Mark onboarding as completed
markCompleted({
userName: 'User',
userRole: 'user',
});
setShowOnboarding(false);
}}
onSuccess={handleOnboardingSuccess}
/>
);
}
// Simple mode: sidebar + chat + detail drawer (Trae Solo style)
if (uiMode === 'simple') {
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
{/* 简洁侧边栏: 对话 + 行业资讯 */}
<SimpleSidebar
onOpenSettings={() => setView('settings')}
onToggleMode={() => useUIModeStore.getState().setMode('professional')}
/>
{/* 主聊天区域 */}
<div className="flex-1 flex flex-col overflow-hidden">
<ErrorBoundary fallback={<div className="flex-1 flex items-center justify-center text-gray-500"></div>}>
<ChatArea compact onOpenDetail={() => setShowDetailDrawer(true)} />
</ErrorBoundary>
</div>
{/* 详情抽屉 - 简洁模式仅 状态/Agent/管家 */}
<DetailDrawer
open={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title="详情"
>
<ErrorBoundary fallback={<div className="p-6 text-center text-gray-500"></div>}>
<RightPanel simpleMode />
</ErrorBoundary>
</DetailDrawer>
{/* Hand Approval Modal (global) */}
<HandApprovalModal
handRun={pendingApprovalRun}
isOpen={showApprovalModal}
onApprove={handleApproveHand}
onReject={handleRejectHand}
onClose={handleCloseApprovalModal}
/>
{/* Proposal Notifications Handler */}
<ProposalNotificationHandler />
</div>
);
}
// Professional mode: three-column layout (default)
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
{/* 左侧边栏 */}
<Sidebar
onOpenSettings={() => setView('settings')}
/>
{/* 主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部工具栏 */}
<TopBar
title="ZCLAW"
onOpenDetail={() => setShowDetailDrawer(true)}
/>
{/* 聊天区域 */}
<ErrorBoundary fallback={<div className="flex-1 flex items-center justify-center text-gray-500"></div>}>
<ChatArea />
</ErrorBoundary>
</div>
{/* 详情抽屉 - 按需显示 */}
<DetailDrawer
open={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title="详情"
>
<ErrorBoundary fallback={<div className="p-6 text-center text-gray-500"></div>}>
<RightPanel />
</ErrorBoundary>
</DetailDrawer>
{/* Hand Approval Modal (global) */}
<HandApprovalModal
handRun={pendingApprovalRun}
isOpen={showApprovalModal}
onApprove={handleApproveHand}
onReject={handleRejectHand}
onClose={handleCloseApprovalModal}
/>
{/* Proposal Notifications Handler */}
<ProposalNotificationHandler />
</div>
);
}
export default App;