import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import './index.css';
import { Sidebar, MainViewType } from './components/Sidebar';
import { ChatArea } from './components/ChatArea';
import { RightPanel } from './components/RightPanel';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AutomationPanel } from './components/Automation';
import { SkillMarket } from './components/SkillMarket';
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 { 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, 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';
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 (
);
}
function App() {
const [view, setView] = useState('main');
const [mainContentView, setMainContentView] = useState('chat');
const [bootstrapping, setBootstrapping] = useState(true);
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
const [showOnboarding, setShowOnboarding] = useState(false);
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
const statsSyncRef = useRef | null>(null);
// Hand Approval state
const [pendingApprovalRun, setPendingApprovalRun] = useState(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();
// 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);
};
// 处理主视图切换
const handleMainViewChange = (view: MainViewType) => {
setMainContentView(view);
};
// 登录门禁 — 必须登录才能使用
if (isRestoring) {
return ;
}
if (!isLoggedIn) {
return ;
}
if (view === 'settings') {
return setView('main')} />;
}
// Show bootstrap screen while starting gateway
if (bootstrapping) {
return ;
}
// Show onboarding wizard for first-time users
if (showOnboarding) {
return (
{
// 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}
/>
);
}
return (
{/* 左侧边栏 */}
setView('settings')}
onMainViewChange={handleMainViewChange}
/>
{/* 主内容区 */}
{/* 顶部工具栏 */}
setShowDetailDrawer(true)}
/>
{/* 内容区域 */}
{mainContentView === 'automation' ? (
) : mainContentView === 'skills' ? (
) : (
)}
{/* 详情抽屉 - 按需显示 */}
setShowDetailDrawer(false)}
title="详情"
>
{/* Hand Approval Modal (global) */}
{/* Proposal Notifications Handler */}
);
}
export default App;