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
空catch块修复 (12处, 6文件): - ModelsAPI: 4处 localStorage 配置读写添加 console.warn - VikingPanel: 2处 viking 操作添加日志 - Workspace/MCPServices/SaaSStatus/TOTPSettings: 各1-3处 ErrorBoundary新增覆盖: - ChatArea: 两种UI模式均包裹(防白屏) - RightPanel: 两种UI模式均包裹 - AuditLogsPanel/HeartbeatConfig/VikingPanel: 设置页包裹
544 lines
20 KiB
TypeScript
544 lines
20 KiB
TypeScript
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 {
|
||
const { getGatewayClient } = await import('./lib/gateway-client');
|
||
const client = getGatewayClient();
|
||
if (client) {
|
||
// Create default agent with versatile assistant personality
|
||
const defaultAgent = await client.createClone({
|
||
name: '全能助手',
|
||
role: '全能型 AI 助手',
|
||
nickname: '小龙',
|
||
emoji: '🦞',
|
||
personality: 'friendly',
|
||
scenarios: ['coding', 'writing', 'research', 'product', 'data'],
|
||
userName: 'User',
|
||
userRole: 'user',
|
||
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
|
||
});
|
||
|
||
if (defaultAgent?.clone) {
|
||
setCurrentAgent({
|
||
id: defaultAgent.clone.id,
|
||
name: defaultAgent.clone.name,
|
||
icon: defaultAgent.clone.emoji || '🦞',
|
||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||
lastMessage: defaultAgent.clone.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;
|
||
|
||
|