import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { MemoryPanel } from './components/Panel/MemoryPanel' import { SkillList } from './components/Panel/SkillList' import { TodoPanel } from './components/Panel/TodoPanel' import { getGatewaySettings, buildWsUrl, type GatewaySettings } from './components/Settings/SettingsModal' import { ConfigPage } from './components/Settings/ConfigPage' import { ConnectionStatus } from './components/ConnectionStatus' import { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' import { useWebSocket } from './hooks/useWebSocket' import { useChat } from './hooks/useChat' import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol' function getInitialSettings(): GatewaySettings { return getGatewaySettings() } function App() { const [gatewaySettings, setGatewaySettings] = useState(getInitialSettings) const wsUrl = buildWsUrl(gatewaySettings) const lastAutoSwitchedTopicRef = useRef(null) const { // 连接状态 isConnected, // Session 状态 session, sessionId, chatId, // Topic 状态 topics, selectedTopic, // 消息状态 messages, isLoading, isReadOnly, // 子智能体视图 subAgentView, // 记忆 memories, requestMemoryList, createMemory, updateMemory, deleteMemory, // 技能 skills, requestSkillList, todos, setTodos, requestTodoList, requestSubAgentTodoList, // 定时任务 schedulerJobs, sidebarTab, setSidebarTab, requestSchedulerJobList, schedulerView, enterSchedulerJobView, exitSchedulerJobView, // 通道 channels, selectedChannel, selectChannel, requestChannelList, // Session sessions, selectedSessionId, selectSession, // 方法 handleMessage, handleCommand, clearMessages, handleServerMessage, setSendMessage, selectTopic, createTopic, switchTopic, deleteTopic, requestSessionList, requestTopicList, topicRefreshTrigger, enterSubAgentView, exitSubAgentView, handleStop, } = useChat() const { status, sendMessage } = useWebSocket({ url: wsUrl, onMessage: handleServerMessage, }) // 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令 useEffect(() => { setSendMessage(sendMessage) }, [setSendMessage, sendMessage]) // ---- 主题状态 ---- const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => { try { return localStorage.getItem('picobot-memory-panel-open') !== 'false' } catch { return false } }) const toggleMemoryPanel = useCallback((open: boolean) => { setMemoryPanelOpen(open) localStorage.setItem('picobot-memory-panel-open', String(open)) }, []) const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory') const [theme, setTheme] = useState<'dark' | 'light'>(() => { const saved = localStorage.getItem('picobot-theme') return saved === 'light' ? 'light' : 'dark' }) useEffect(() => { const root = document.documentElement if (theme === 'light') { root.classList.add('light') } else { root.classList.remove('light') } localStorage.setItem('picobot-theme', theme) // 切换时启用平滑过渡 root.classList.add('theme-transitioning') const timer = setTimeout(() => { root.classList.remove('theme-transitioning') }, 350) return () => clearTimeout(timer) }, [theme]) const [showThinking, setShowThinking] = useState(() => { return localStorage.getItem('picobot-show-thinking') !== 'false' }) const [configPageOpen, setConfigPageOpen] = useState(false) const handleSaveConnection = useCallback((host: string, port: number) => { setGatewaySettings({ host, port }) }, []) useEffect(() => { localStorage.setItem('picobot-show-thinking', String(showThinking)) }, [showThinking]) // ---- WebSocket 初始化 ---- // Step 1: 连接建立后先请求通道列表 useEffect(() => { if (isConnected && status === 'connected') { const cmd = requestChannelList() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } }, [isConnected, status, handleCommand, sendMessage, requestChannelList]) // Step 2: 通道列表加载后,请求选中通道的 Session 列表 useEffect(() => { if (channels.length > 0 && status === 'connected') { const sessionCmd = requestSessionList() handleCommand(sessionCmd) sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) }) } }, [channels.length, status, handleCommand, sendMessage, requestSessionList]) // Session 加载后自动加载 Topics useEffect(() => { if (sessionId && status === 'connected') { const topicCmd = requestTopicList() if (topicCmd) { handleCommand(topicCmd) sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }) } } }, [sessionId, status, handleCommand, sendMessage, requestTopicList]) // 话题描述异步生成后自动刷新话题列表 useEffect(() => { if (topicRefreshTrigger === 0) return if (status !== 'connected') return const topicCmd = requestTopicList() if (!topicCmd) return const timer = setTimeout(() => { handleCommand(topicCmd) sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }) }, 500) return () => clearTimeout(timer) }, [topicRefreshTrigger]) // Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时) useEffect(() => { if (topics.length === 0 || status !== 'connected') { return } // 用户已经选中了某个 topic → 不要抢走 if (selectedTopic) { return } const firstTopic = topics[0] if (lastAutoSwitchedTopicRef.current === firstTopic.id) { return } lastAutoSwitchedTopicRef.current = firstTopic.id selectTopic(firstTopic.id) const cmd = switchTopic(firstTopic.id) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [topics, status, selectedTopic, selectTopic, switchTopic, handleCommand, sendMessage]); const handleSendMessage = useCallback( (content: string, attachments: Attachment[] = []) => { if (isReadOnly || !sessionId) { return } if (content.startsWith('/')) { const parts = content.slice(1).split(' ') const command = parts[0] const args = parts.slice(1) let cmd: Command switch (command) { case 'new': cmd = createTopic(args.join(' ') || undefined) break case 'list': cmd = { type: 'list_sessions', include_archived: args[0] === 'all' } break case 'use': if (args[0]) { cmd = { type: 'switch_topic', topic_id: args[0] } } else { alert('Usage: /use ') return } break case 'save': cmd = { type: 'save_topic', filepath: args[0] || undefined, include_subagents: false } break case 'stop': cmd = { type: 'stop_execution' } break default: alert(`Unknown command: /${command}`) return } handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } else { handleMessage(content, attachments) sendMessage({ type: 'message', content, attachments, chat_id: chatId, }) } }, [sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly] ) const handleStopExecution = useCallback(() => { const cmd = handleStop() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [sendMessage, handleCommand, handleStop]) const handleCreateTopic = useCallback(() => { if (isReadOnly || !sessionId) { return } const cmd = createTopic() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) const handleRefreshTopics = useCallback(() => { if (!sessionId) return const cmd = requestTopicList() if (cmd) { handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } }, [sessionId, requestTopicList, handleCommand, sendMessage]) const handleSwitchTopic = useCallback( (topicId: string) => { selectTopic(topicId) const cmd = switchTopic(topicId) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [sendMessage, handleCommand, switchTopic, selectTopic] ) const handleDeleteTopic = useCallback( (topicId: string) => { const cmd = deleteTopic(topicId) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) // 如果删除的是当前选中话题,清空选中状态和消息 if (topicId === selectedTopic) { selectTopic('') clearMessages() } }, [sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages] ) const handleNavigateToSubAgent = useCallback( (taskId: string, description: string) => { const cmd = enterSubAgentView(taskId, description) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [enterSubAgentView, handleCommand, sendMessage] ) const handleExitSubAgentView = useCallback(() => { exitSubAgentView() }, [exitSubAgentView]) // 切换到定时任务 tab 时自动获取列表 useEffect(() => { if (sidebarTab === 'scheduler' && status === 'connected') { const cmd = requestSchedulerJobList() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } }, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList]) // 连接就绪时自动拉取记忆、技能和待办列表 useEffect(() => { if (status === 'connected') { const memCmd = requestMemoryList() handleCommand(memCmd) sendMessage({ type: 'command', payload: JSON.stringify(memCmd) }) const skillCmd = requestSkillList() handleCommand(skillCmd) sendMessage({ type: 'command', payload: JSON.stringify(skillCmd) }) } }, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList]) // 连接就绪、切换 topic、或进出子代理视图时刷新 todo 列表 const prevTodoTriggerRef = useRef('') useEffect(() => { if (status !== 'connected') return const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}` if (key === prevTodoTriggerRef.current) return prevTodoTriggerRef.current = key setTodos([]) // 先清空,防止切换时短暂显示旧 scope 的 todos const todoCmd = subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList() handleCommand(todoCmd) sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) }) }, [status, selectedTopic, subAgentView, handleCommand, sendMessage, requestTodoList, requestSubAgentTodoList, setTodos]) const handleRefreshMemories = useCallback(() => { const cmd = requestMemoryList() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [handleCommand, sendMessage, requestMemoryList]) const handleRefreshSkills = useCallback(() => { const cmd = requestSkillList() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [handleCommand, sendMessage, requestSkillList]) const sendMemoryCommand = useCallback((cmd: Command) => { handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [handleCommand, sendMessage]) // 根据当前视图(主会话/子代理)返回正确的 todo 请求命令 const refreshTodoList = useCallback((): Command => { return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList() }, [subAgentView, requestTodoList, requestSubAgentTodoList]) const handleRefreshSchedulerJobs = useCallback(() => { const cmd = requestSchedulerJobList() handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [handleCommand, sendMessage, requestSchedulerJobList]) const handleViewSchedulerJob = useCallback( (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => { const cmd = enterSchedulerJobView(lookup, jobId, description) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [enterSchedulerJobView, handleCommand, sendMessage] ) const handleExitSchedulerJobView = useCallback(() => { exitSchedulerJobView() }, [exitSchedulerJobView]) const handleSwitchChannel = useCallback( (channelId: string) => { if (channelId === selectedChannel) return lastAutoSwitchedTopicRef.current = null selectChannel(channelId) }, [selectedChannel, selectChannel] ) const handleSelectSession = useCallback( (sessionId: string) => { if (sessionId === selectedSessionId) return lastAutoSwitchedTopicRef.current = null selectSession(sessionId) }, [selectedSessionId, selectSession] ) const chatMessages = useMemo(() => { const result: ChatMessage[] = [] const toolCallIndex = new Map() for (const msg of messages) { if (msg.type === 'tool_call') { toolCallIndex.set(msg.toolCallId || msg.id, result.length) result.push({ ...msg, type: 'merged_tool', status: 'calling', callContent: msg.content, resultContent: '', }) } else if (msg.type === 'tool_result') { const idx = toolCallIndex.get(msg.toolCallId || msg.id) if (idx !== undefined) { result[idx] = { ...result[idx], status: 'result', resultContent: msg.content, durationMs: msg.durationMs, } } } else if (msg.type === 'tool_pending') { const idx = toolCallIndex.get(msg.toolCallId || msg.id) if (idx !== undefined) { result[idx] = { ...result[idx], status: 'pending', resultContent: msg.content, } } } else { result.push(msg) } } return result }, [messages]) return (
{/* Header */}

Pico Bot

{/* 主题切换按钮 */}
{/* Main Content */}
{/* Left Sidebar */}
{/* Tab 栏 */}
{sidebarTab === 'topics' ? ( ) : ( )}
{/* Center - Chat */}
{/* Scheduler job view back bar */} {schedulerView && (
定时任务: {schedulerView.description}
通道: {schedulerView.channel}
)} {/* Sub-agent back bar */} {subAgentView && (
子智能体: {subAgentView.description}
类型: {subAgentView.subagentType || '...'}
状态: {subAgentView.status === 'completed' ? '已完成' : subAgentView.status === 'failed' ? '失败' : subAgentView.status === 'timeout' ? '超时' : subAgentView.status === 'running' ? '执行中' : subAgentView.status === 'loading' ? '加载中...' : subAgentView.status}
)}
c.id === selectedChannel)?.name ?? 'PicoBot') } onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage} onNavigateToSubAgent={handleNavigateToSubAgent} onStop={handleStopExecution} showThinking={showThinking} todoPanel={ } />
{/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */}
{/* Tab 栏 */}
{/* Panel content */}
{rightPanelTab === 'memory' ? ( ) : ( )}
{/* Reopen button — visible when panel is collapsed */} {!memoryPanelOpen && (
)}
{/* 系统配置页面 */} {configPageOpen && ( setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} /> )}
) } export default App