From c724bbf8648e287308adbb387fc4b16e197c3b03 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 3 Aug 2026 23:25:22 +0800 Subject: [PATCH] =?UTF-8?q?chore(web):=20prettier=20=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=B9=B6=E5=9C=A8=20CI=20?= =?UTF-8?q?=E5=90=AF=E7=94=A8=20format:check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 47 个前端文件统一执行 npm run format,消除存量格式差异。 随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。 --- .github/workflows/ci.yml | 4 + Makefile | 3 +- web/src/App.tsx | 681 ++--- web/src/api/client.ts | 25 +- web/src/api/config.ts | 31 +- web/src/api/experts.ts | 100 +- web/src/api/mcp.ts | 6 +- web/src/api/skills.ts | 14 +- web/src/api/subagents.ts | 28 +- web/src/api/tools.ts | 6 +- web/src/components/Chat/ChatContainer.tsx | 58 +- web/src/components/Chat/ExpertSelector.tsx | 187 +- web/src/components/Chat/MessageBubble.tsx | 734 +++-- web/src/components/Chat/MessageInput.tsx | 251 +- web/src/components/Chat/MessageList.tsx | 169 +- web/src/components/Chat/ModelSelector.tsx | 222 +- web/src/components/Chat/ToolDetailModal.tsx | 73 +- web/src/components/ConnectionStatus.tsx | 26 +- web/src/components/Header/ChannelSelector.tsx | 262 +- web/src/components/Header/SessionSelector.tsx | 198 +- web/src/components/Panel/MemoryPanel.tsx | 449 +++- web/src/components/Panel/SkillList.tsx | 180 +- web/src/components/Panel/TodoPanel.tsx | 130 +- web/src/components/Panel/ToolPanel.tsx | 335 +-- web/src/components/Settings/ConfigPage.tsx | 2369 ++++++++++++----- web/src/components/Settings/SettingsModal.tsx | 94 +- web/src/components/Settings/constants.ts | 29 +- web/src/components/Settings/types.ts | 349 ++- web/src/components/Settings/ui.tsx | 298 ++- .../components/Sidebar/SchedulerJobList.tsx | 124 +- web/src/components/Sidebar/SessionInfo.tsx | 14 +- web/src/components/Sidebar/TopicList.tsx | 181 +- web/src/hooks/chat/messageMappers.ts | 52 +- web/src/hooks/chat/types.ts | 26 +- web/src/hooks/chat/useConnection.ts | 30 +- web/src/hooks/chat/useMessages.ts | 462 ++-- web/src/hooks/chat/useSchedulerView.ts | 96 +- web/src/hooks/chat/useSessions.ts | 55 +- web/src/hooks/chat/useSideData.ts | 98 +- web/src/hooks/chat/useSubAgentView.ts | 566 ++-- web/src/hooks/chat/useTopics.ts | 120 +- web/src/hooks/useChat.test.ts | 341 +-- web/src/hooks/useChat.ts | 335 +-- web/src/hooks/useWebSocket.ts | 130 +- web/src/index.css | 205 +- web/src/main.tsx | 10 +- web/src/test/setup.ts | 2 +- web/src/types/protocol.ts | 522 ++-- web/src/vite-env.d.ts | 2 +- 49 files changed, 6404 insertions(+), 4278 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15fe9f0..9aea30c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,10 @@ jobs: working-directory: web run: npm run lint + - name: Format check (prettier) + working-directory: web + run: npm run format:check + - name: Type check working-directory: web run: npx tsc --noEmit diff --git a/Makefile b/Makefile index 16aa7d2..5101569 100644 --- a/Makefile +++ b/Makefile @@ -49,8 +49,9 @@ clean: check: @echo "Checking formatting..." cargo fmt --all -- --check - @echo "Checking frontend (lint + build)..." + @echo "Checking frontend (lint + format + build)..." cd web && npm run lint + cd web && npm run format:check cd web && npm run build @echo "Checking Rust code..." cargo check diff --git a/web/src/App.tsx b/web/src/App.tsx index b916032..86955f4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,28 +1,50 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelLeftClose, PanelLeftOpen, Brain, Settings as SettingsIcon, ChevronRight } 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, TodoItemSummary } from './types/protocol' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Zap, + ArrowLeft, + Bot, + Clock, + Sun, + Moon, + PanelLeftClose, + PanelLeftOpen, + Brain, + Settings as SettingsIcon, + ChevronRight, +} 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, + TodoItemSummary, +} from './types/protocol'; function getInitialSettings(): GatewaySettings { - return getGatewaySettings() + return getGatewaySettings(); } function App() { - const [gatewaySettings, setGatewaySettings] = useState(getInitialSettings) - const wsUrl = buildWsUrl(gatewaySettings) - const lastAutoSwitchedTopicRef = useRef(null) + const [gatewaySettings, setGatewaySettings] = useState(getInitialSettings); + const wsUrl = buildWsUrl(gatewaySettings); + const lastAutoSwitchedTopicRef = useRef(null); const { // 连接状态 @@ -92,463 +114,479 @@ function App() { exitSubAgentView, navigateToSubAgentLevel, handleStop, - } = useChat() + } = useChat(); const { status, sendMessage } = useWebSocket({ url: wsUrl, onMessage: handleServerMessage, - }) + }); // 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令 useEffect(() => { - setSendMessage(sendMessage) - }, [setSendMessage, sendMessage]) + setSendMessage(sendMessage); + }, [setSendMessage, sendMessage]); // ---- 右边栏状态(与左边栏对称的折叠/展开逻辑) ---- const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => { try { - return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true' + return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'; } catch { - return false + return false; } - }) + }); const toggleRightSidebar = useCallback(() => { - setRightSidebarCollapsed(prev => { - const next = !prev - localStorage.setItem('picobot-right-sidebar-collapsed', String(next)) - return next - }) - }, []) + setRightSidebarCollapsed((prev) => { + const next = !prev; + localStorage.setItem('picobot-right-sidebar-collapsed', String(next)); + return next; + }); + }, []); - const [rightPanelTab, setRightPanelTab] = useState<'todo' | 'memory' | 'skill'>('todo') + const [rightPanelTab, setRightPanelTab] = useState<'todo' | 'memory' | 'skill'>('todo'); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { try { - return localStorage.getItem('picobot-sidebar-collapsed') === 'true' + return localStorage.getItem('picobot-sidebar-collapsed') === 'true'; } catch { - return false + return false; } - }) + }); const toggleSidebar = useCallback(() => { - setSidebarCollapsed(prev => { - const next = !prev - localStorage.setItem('picobot-sidebar-collapsed', String(next)) - return next - }) - }, []) + setSidebarCollapsed((prev) => { + const next = !prev; + localStorage.setItem('picobot-sidebar-collapsed', String(next)); + return next; + }); + }, []); const [theme, setTheme] = useState<'dark' | 'light'>(() => { - const saved = localStorage.getItem('picobot-theme') - return saved === 'light' ? 'light' : 'dark' - }) + const saved = localStorage.getItem('picobot-theme'); + return saved === 'light' ? 'light' : 'dark'; + }); useEffect(() => { - const root = document.documentElement + const root = document.documentElement; if (theme === 'light') { - root.classList.add('light') + root.classList.add('light'); } else { - root.classList.remove('light') + root.classList.remove('light'); } - localStorage.setItem('picobot-theme', theme) + localStorage.setItem('picobot-theme', theme); // 切换时启用平滑过渡 - root.classList.add('theme-transitioning') + root.classList.add('theme-transitioning'); const timer = setTimeout(() => { - root.classList.remove('theme-transitioning') - }, 350) - return () => clearTimeout(timer) - }, [theme]) + root.classList.remove('theme-transitioning'); + }, 350); + return () => clearTimeout(timer); + }, [theme]); const [showThinking, setShowThinking] = useState(() => { - return localStorage.getItem('picobot-show-thinking') !== 'false' - }) + return localStorage.getItem('picobot-show-thinking') !== 'false'; + }); - const [configPageOpen, setConfigPageOpen] = useState(false) - const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers') + const [configPageOpen, setConfigPageOpen] = useState(false); + const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers'); // 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态 - const [settingsClosedTick, setSettingsClosedTick] = useState(0) + const [settingsClosedTick, setSettingsClosedTick] = useState(0); const handleSaveConnection = useCallback((host: string, port: number) => { - setGatewaySettings({ host, port }) - }, []) + setGatewaySettings({ host, port }); + }, []); useEffect(() => { - localStorage.setItem('picobot-show-thinking', String(showThinking)) - }, [showThinking]) + 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) }) + const cmd = requestChannelList(); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); } - }, [isConnected, status, handleCommand, sendMessage, requestChannelList]) + }, [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) }) + const sessionCmd = requestSessionList(); + handleCommand(sessionCmd); + sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) }); } - }, [channels.length, status, handleCommand, sendMessage, requestSessionList]) + }, [channels.length, status, handleCommand, sendMessage, requestSessionList]); // Session 加载后自动加载 Topics useEffect(() => { if (sessionId && status === 'connected') { - const topicCmd = requestTopicList() + const topicCmd = requestTopicList(); if (topicCmd) { - handleCommand(topicCmd) - sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }) + handleCommand(topicCmd); + sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }); } } - }, [sessionId, status, handleCommand, sendMessage, requestTopicList]) + }, [sessionId, status, handleCommand, sendMessage, requestTopicList]); // 话题描述异步生成后自动刷新话题列表 useEffect(() => { - if (topicRefreshTrigger === 0) return - if (status !== 'connected') return - const topicCmd = requestTopicList() - if (!topicCmd) return + 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) + handleCommand(topicCmd); + sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }); + }, 500); - return () => clearTimeout(timer) - }, [topicRefreshTrigger, status, handleCommand, sendMessage, requestTopicList]) + return () => clearTimeout(timer); + }, [topicRefreshTrigger, status, handleCommand, sendMessage, requestTopicList]); // Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时) useEffect(() => { if (topics.length === 0 || status !== 'connected') { - return + return; } // 用户已经选中了某个 topic → 不要抢走 if (selectedTopic) { - return + return; } - const firstTopic = topics[0] + const firstTopic = topics[0]; if (lastAutoSwitchedTopicRef.current === firstTopic.id) { - return + return; } - lastAutoSwitchedTopicRef.current = firstTopic.id - selectTopic(firstTopic.id) - const cmd = switchTopic(firstTopic.id) - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + 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 + return; } if (content.startsWith('/')) { - const parts = content.slice(1).split(' ') - const command = parts[0] - const args = parts.slice(1) + const parts = content.slice(1).split(' '); + const command = parts[0]; + const args = parts.slice(1); - let cmd: Command + let cmd: Command; switch (command) { case 'new': - cmd = createTopic(args.join(' ') || undefined) - break + cmd = createTopic(args.join(' ') || undefined); + break; case 'list': - cmd = { type: 'list_sessions', include_archived: args[0] === 'all' } - break + cmd = { type: 'list_sessions', include_archived: args[0] === 'all' }; + break; case 'use': if (args[0]) { - cmd = { type: 'switch_topic', topic_id: args[0] } + cmd = { type: 'switch_topic', topic_id: args[0] }; } else { - alert('Usage: /use ') - return + alert('Usage: /use '); + return; } - break + break; case 'save': - cmd = { type: 'save_topic', filepath: args[0] || undefined, include_subagents: false } - break + cmd = { type: 'save_topic', filepath: args[0] || undefined, include_subagents: false }; + break; case 'stop': - cmd = { type: 'stop_execution' } - break + cmd = { type: 'stop_execution' }; + break; default: - alert(`Unknown command: /${command}`) - return + alert(`Unknown command: /${command}`); + return; } - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); } else { - handleMessage(content, attachments) + handleMessage(content, attachments); sendMessage({ type: 'message', content, attachments, chat_id: chatId, - }) + }); } }, - [sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly] - ) + [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 cmd = handleStop(); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); + }, [sendMessage, handleCommand, handleStop]); const handleCreateTopic = useCallback(() => { if (isReadOnly || !sessionId) { - return + return; } - const cmd = createTopic() - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) - }, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) + 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 (!sessionId) return; + const cmd = requestTopicList(); if (cmd) { - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); } - }, [sessionId, requestTopicList, handleCommand, sendMessage]) + }, [sessionId, requestTopicList, handleCommand, sendMessage]); const handleSwitchTopic = useCallback( (topicId: string) => { - selectTopic(topicId) - const cmd = switchTopic(topicId) - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + selectTopic(topicId); + const cmd = switchTopic(topicId); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); }, - [sendMessage, handleCommand, switchTopic, selectTopic] - ) + [sendMessage, handleCommand, switchTopic, selectTopic], + ); const handleDeleteTopic = useCallback( (topicId: string) => { - const cmd = deleteTopic(topicId) - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + const cmd = deleteTopic(topicId); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); // 如果删除的是当前选中话题,清空选中状态和消息 if (topicId === selectedTopic) { - selectTopic('') - clearMessages() + selectTopic(''); + clearMessages(); } }, - [sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages] - ) + [sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages], + ); const handleRenameTopic = useCallback( (topicId: string, title: string) => { - const cmd = renameTopic(topicId, title) - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + const cmd = renameTopic(topicId, title); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); }, - [sendMessage, handleCommand, renameTopic] - ) + [sendMessage, handleCommand, renameTopic], + ); const handleNavigateToSubAgent = useCallback( (taskId: string, description: string, subagentType?: string) => { - const cmd = enterSubAgentView(taskId, description, subagentType) - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + const cmd = enterSubAgentView(taskId, description, subagentType); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); }, - [enterSubAgentView, handleCommand, sendMessage] - ) + [enterSubAgentView, handleCommand, sendMessage], + ); const handleExitSubAgentView = useCallback(() => { - const command = exitSubAgentView() + const command = exitSubAgentView(); if (command) { - sendMessage({ type: 'command', payload: JSON.stringify(command) }) + sendMessage({ type: 'command', payload: JSON.stringify(command) }); } - }, [exitSubAgentView, sendMessage]) + }, [exitSubAgentView, sendMessage]); - const handleNavigateToSubAgentLevel = useCallback((index: number) => { - const command = navigateToSubAgentLevel(index) - if (command) { - sendMessage({ type: 'command', payload: JSON.stringify(command) }) - } - }, [navigateToSubAgentLevel, sendMessage]) + const handleNavigateToSubAgentLevel = useCallback( + (index: number) => { + const command = navigateToSubAgentLevel(index); + if (command) { + sendMessage({ type: 'command', payload: JSON.stringify(command) }); + } + }, + [navigateToSubAgentLevel, sendMessage], + ); // 切换到定时任务 tab 时自动获取列表 useEffect(() => { if (sidebarTab === 'scheduler' && status === 'connected') { - const cmd = requestSchedulerJobList() - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + const cmd = requestSchedulerJobList(); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); } - }, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList]) + }, [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) }) + 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]) + }, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList]); // 连接就绪、切换 topic、或进出子代理视图时刷新 todo 列表 - const prevTodoTriggerRef = useRef('') + const prevTodoTriggerRef = useRef(''); useEffect(() => { - if (status !== 'connected') return - const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}` - if (key === prevTodoTriggerRef.current) return - prevTodoTriggerRef.current = key - setTodos([]) // 先清空,防止切换时短暂显示旧 scope 的 todos + 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]) + : 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 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 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]) + 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]) + return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList(); + }, [subAgentView, requestTodoList, requestSubAgentTodoList]); // 点击待办项后滚动到对应消息 - const handleTodoClick = useCallback((todo: TodoItemSummary) => { - if (!todo.created_by_message_id) { - alert('该待办的完成记录无法定位,可能是历史数据') - return - } - // 仅定时任务视图需要退出(其消息源是另一个 chat_id) - if (schedulerView) { - exitSchedulerJobView() - } - // 先清再设,确保同一 todo 重复点击也能触发 useEffect - const msgId = todo.created_by_message_id - setHighlightedMessageId(null) - // 延迟一帧,等视图切换后消息列表渲染完成再滚动 - setTimeout(() => { - setHighlightedMessageId(msgId) - }, 50) - }, [setHighlightedMessageId, schedulerView, exitSchedulerJobView]) + const handleTodoClick = useCallback( + (todo: TodoItemSummary) => { + if (!todo.created_by_message_id) { + alert('该待办的完成记录无法定位,可能是历史数据'); + return; + } + // 仅定时任务视图需要退出(其消息源是另一个 chat_id) + if (schedulerView) { + exitSchedulerJobView(); + } + // 先清再设,确保同一 todo 重复点击也能触发 useEffect + const msgId = todo.created_by_message_id; + setHighlightedMessageId(null); + // 延迟一帧,等视图切换后消息列表渲染完成再滚动 + setTimeout(() => { + setHighlightedMessageId(msgId); + }, 50); + }, + [setHighlightedMessageId, schedulerView, exitSchedulerJobView], + ); const handleRefreshSchedulerJobs = useCallback(() => { - const cmd = requestSchedulerJobList() - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) - }, [handleCommand, sendMessage, requestSchedulerJobList]) + 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) }) + const cmd = enterSchedulerJobView(lookup, jobId, description); + handleCommand(cmd); + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); }, - [enterSchedulerJobView, handleCommand, sendMessage] - ) + [enterSchedulerJobView, handleCommand, sendMessage], + ); const handleExitSchedulerJobView = useCallback(() => { - exitSchedulerJobView() - }, [exitSchedulerJobView]) + exitSchedulerJobView(); + }, [exitSchedulerJobView]); const handleSwitchChannel = useCallback( (channelId: string) => { - if (channelId === selectedChannel) return - lastAutoSwitchedTopicRef.current = null - selectChannel(channelId) + if (channelId === selectedChannel) return; + lastAutoSwitchedTopicRef.current = null; + selectChannel(channelId); }, - [selectedChannel, selectChannel] - ) + [selectedChannel, selectChannel], + ); const handleSelectSession = useCallback( (sessionId: string) => { - if (sessionId === selectedSessionId) return - lastAutoSwitchedTopicRef.current = null - selectSession(sessionId) + if (sessionId === selectedSessionId) return; + lastAutoSwitchedTopicRef.current = null; + selectSession(sessionId); }, - [selectedSessionId, selectSession] - ) + [selectedSessionId, selectSession], + ); const chatMessages = useMemo(() => { - const result: ChatMessage[] = [] - const toolCallIndex = new Map() + 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) + 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) + 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) + const idx = toolCallIndex.get(msg.toolCallId || msg.id); if (idx !== undefined) { result[idx] = { ...result[idx], status: 'pending', resultContent: msg.content, - } + }; } } else { - result.push(msg) + result.push(msg); } } // 过滤无实质内容的 merged_tool:result 到达后才显示保留;calling/pending 有 callContent 也保留 - return result.filter(msg => { - if (msg.type !== 'merged_tool') return true - if (msg.status === 'calling' || msg.status === 'pending') return true - return !!(msg.resultContent && msg.resultContent.trim()) - }) - }, [messages]) + return result.filter((msg) => { + if (msg.type !== 'merged_tool') return true; + if (msg.status === 'calling' || msg.status === 'pending') return true; + return !!(msg.resultContent && msg.resultContent.trim()); + }); + }, [messages]); // 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置 const viewKey = useMemo(() => { - if (schedulerView) return `scheduler:${schedulerView.jobId}` - if (subAgentView) return `subagent:${subAgentView.taskId}` - return 'main' - }, [schedulerView, subAgentView]) + if (schedulerView) return `scheduler:${schedulerView.jobId}`; + if (subAgentView) return `subagent:${subAgentView.taskId}`; + return 'main'; + }, [schedulerView, subAgentView]); return (
@@ -567,7 +605,7 @@ function App() { {/* 主题切换按钮 */}
{/* 展开按钮 */} {/* Breadcrumb: each sub-agent level */} {subAgentStack.map((level, idx) => { - const isLast = idx === subAgentStack.length - 1 + const isLast = idx === subAgentStack.length - 1; const statusText = - level.status === 'completed' ? '已完成' : - level.status === 'failed' ? '失败' : - level.status === 'timeout' ? '超时' : - level.status === 'running' ? '执行中' : - level.status === 'loading' ? '加载中...' : - level.status === 'unknown' ? '未知' : - level.status + level.status === 'completed' + ? '已完成' + : level.status === 'failed' + ? '失败' + : level.status === 'timeout' + ? '超时' + : level.status === 'running' + ? '执行中' + : level.status === 'loading' + ? '加载中...' + : level.status === 'unknown' + ? '未知' + : level.status; const statusColor = - level.status === 'completed' ? 'text-emerald-400' : - level.status === 'failed' ? 'text-red-400' : - level.status === 'timeout' ? 'text-amber-400' : - level.status === 'running' ? 'text-amber-400' : - 'text-[var(--text-secondary)]' + level.status === 'completed' + ? 'text-emerald-400' + : level.status === 'failed' + ? 'text-red-400' + : level.status === 'timeout' + ? 'text-amber-400' + : level.status === 'running' + ? 'text-amber-400' + : 'text-[var(--text-secondary)]'; return (
{isLast ? (
- {level.description} + + {level.description} + {level.subagentType && ( - {level.subagentType} + + {level.subagentType} + )} - {statusText} + + {statusText} +
) : ( )}
- ) + ); })}
)} @@ -808,9 +880,13 @@ function App() { isLoading={isLoading} isReadOnly={subAgentView || schedulerView ? true : isReadOnly} channelName={ - schedulerView ? `定时任务: ${schedulerView.description}` : - subAgentView ? `子智能体: ${subAgentView.description}` : - (session?.title ?? channels.find(c => c.id === selectedChannel)?.name ?? 'PicoBot') + schedulerView + ? `定时任务: ${schedulerView.description}` + : subAgentView + ? `子智能体: ${subAgentView.description}` + : (session?.title ?? + channels.find((c) => c.id === selectedChannel)?.name ?? + 'PicoBot') } onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage} onNavigateToSubAgent={handleNavigateToSubAgent} @@ -821,8 +897,8 @@ function App() { sessionId={sessionId} settingsClosedTick={settingsClosedTick} onOpenSettings={() => { - setConfigInitialTab('experts') - setConfigPageOpen(true) + setConfigInitialTab('experts'); + setConfigPageOpen(true); }} />
@@ -876,7 +952,11 @@ function App() { > 技能 - @@ -899,10 +979,7 @@ function App() { sendCommand={sendMemoryCommand} /> ) : ( - + )} @@ -914,15 +991,15 @@ function App() { {configPageOpen && ( { - setConfigPageOpen(false) - setSettingsClosedTick(t => t + 1) + setConfigPageOpen(false); + setSettingsClosedTick((t) => t + 1); }} onSaveConnection={handleSaveConnection} initialTab={configInitialTab} /> )} - ) + ); } -export default App +export default App; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2a82237..3d04d90 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -20,7 +20,7 @@ export const API = { expertsSelect: '/api/experts/select', sessionSelectModel: '/api/session/select-model', sessionSelectedModel: '/api/session/selected-model', -} as const +} as const; /** * 基础 fetch 封装:自动添加 JSON headers,解析响应。 @@ -28,7 +28,7 @@ export const API = { */ export async function apiFetch( endpoint: string, - options?: { method?: string; body?: unknown; signal?: AbortSignal } + options?: { method?: string; body?: unknown; signal?: AbortSignal }, ): Promise<[T | null, { status: number; message: string } | null]> { try { const resp = await fetch(endpoint, { @@ -36,14 +36,17 @@ export async function apiFetch( headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, body: options?.body ? JSON.stringify(options.body) : undefined, signal: options?.signal, - }) - const data = await resp.json().catch(() => null) + }); + const data = await resp.json().catch(() => null); if (!resp.ok) { - return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }] + return [ + null, + { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }, + ]; } - return [data as T, null] + return [data as T, null]; } catch (e) { - return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }] + return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }]; } } @@ -52,10 +55,10 @@ export async function apiFetch( */ export async function apiGetSilent(endpoint: string): Promise { try { - const resp = await fetch(endpoint) - if (!resp.ok) return null - return await resp.json() as T + const resp = await fetch(endpoint); + if (!resp.ok) return null; + return (await resp.json()) as T; } catch { - return null + return null; } } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 1af6208..0134587 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -1,32 +1,35 @@ -import { API, apiFetch } from './client' -import type { AppConfig } from '../components/Settings/types' +import { API, apiFetch } from './client'; +import type { AppConfig } from '../components/Settings/types'; export interface RestartResponse { - success: boolean - message?: string + success: boolean; + message?: string; } export async function getAppConfig(): Promise<[AppConfig | null, string | null]> { - const [data, err] = await apiFetch(API.config) - return [data, err?.message ?? null] + const [data, err] = await apiFetch(API.config); + return [data, err?.message ?? null]; } export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> { - const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } }) - return err ? [false, err.message] : [true, null] + const [, err] = await apiFetch<{ success: boolean }>(API.config, { + method: 'PUT', + body: { config }, + }); + return err ? [false, err.message] : [true, null]; } export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> { - const resp = await fetch(API.restart, { method: 'POST' }) - const data = await resp.json().catch(() => ({ success: false })) - return { status: resp.status, data } + const resp = await fetch(API.restart, { method: 'POST' }); + const data = await resp.json().catch(() => ({ success: false })); + return { status: resp.status, data }; } export async function checkHealth(): Promise { try { - const resp = await fetch(API.health) - return resp.ok + const resp = await fetch(API.health); + return resp.ok; } catch { - return false + return false; } } diff --git a/web/src/api/experts.ts b/web/src/api/experts.ts index 05983b2..6675def 100644 --- a/web/src/api/experts.ts +++ b/web/src/api/experts.ts @@ -1,77 +1,113 @@ -import { API, apiGetSilent } from './client' -import type { ExpertListResponse, ExpertItem, CapabilityPolicy, ModelOptionsResponse } from '../components/Settings/types' +import { API, apiGetSilent } from './client'; +import type { + ExpertListResponse, + ExpertItem, + CapabilityPolicy, + ModelOptionsResponse, +} from '../components/Settings/types'; export function listExperts(): Promise { - return apiGetSilent(API.experts) + return apiGetSilent(API.experts); } export function listModelOptions(): Promise { - return apiGetSilent(API.modelOptions) + return apiGetSilent(API.modelOptions); } -export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise { +export async function toggleExpert( + name: string, + scope: string, + enabled: boolean, +): Promise { return fetch(API.expertsToggle, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), - }) + }); } -export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise { +export async function createExpert(payload: { + name: string; + description: string; + body: string; + scope: string; + capability?: CapabilityPolicy; + provider?: string; + model?: string; +}): Promise { return fetch(API.expertsCreate, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) + }); } -export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise { +export async function updateExpert(payload: { + name: string; + scope: string; + description?: string; + body?: string; + capability?: CapabilityPolicy; + provider?: string; + model?: string; +}): Promise { return fetch(API.expertsUpdate, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) + }); } export async function deleteExpert(name: string, scope: string): Promise { - const params = new URLSearchParams({ name, scope }) - return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }) + const params = new URLSearchParams({ name, scope }); + return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }); } -export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { - const params = new URLSearchParams({ session_id: sessionId }) - const resp = await fetch(`${API.expertsSelected}?${params}`) - if (!resp.ok) return { expert_name: null, expert: null } - return resp.json() +export async function getSelectedExpert( + sessionId: string, +): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { + const params = new URLSearchParams({ session_id: sessionId }); + const resp = await fetch(`${API.expertsSelected}?${params}`); + if (!resp.ok) return { expert_name: null, expert: null }; + return resp.json(); } -export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { +export async function selectExpert( + sessionId: string, + expertName: string | null, +): Promise<{ success: boolean; error?: string }> { const resp = await fetch(API.expertsSelect, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), - }) - const data = await resp.json().catch(() => ({})) - if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } - return { success: true } + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }; + return { success: true }; } /** 设置(或清除)session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */ -export async function selectModel(sessionId: string, provider: string | null, model: string | null): Promise<{ success: boolean; error?: string }> { +export async function selectModel( + sessionId: string, + provider: string | null, + model: string | null, +): Promise<{ success: boolean; error?: string }> { const resp = await fetch(API.sessionSelectModel, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, provider, model }), - }) - const data = await resp.json().catch(() => ({})) - if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' } - return { success: true } + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }; + return { success: true }; } /** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */ -export async function getSelectedModel(sessionId: string): Promise<{ provider: string | null; model: string | null }> { - const params = new URLSearchParams({ session_id: sessionId }) - const resp = await fetch(`${API.sessionSelectedModel}?${params}`) - if (!resp.ok) return { provider: null, model: null } - return resp.json() +export async function getSelectedModel( + sessionId: string, +): Promise<{ provider: string | null; model: string | null }> { + const params = new URLSearchParams({ session_id: sessionId }); + const resp = await fetch(`${API.sessionSelectedModel}?${params}`); + if (!resp.ok) return { provider: null, model: null }; + return resp.json(); } diff --git a/web/src/api/mcp.ts b/web/src/api/mcp.ts index aaae0bc..2d23c96 100644 --- a/web/src/api/mcp.ts +++ b/web/src/api/mcp.ts @@ -1,6 +1,6 @@ -import { API, apiGetSilent } from './client' -import type { McpStatusResponse } from '../components/Settings/types' +import { API, apiGetSilent } from './client'; +import type { McpStatusResponse } from '../components/Settings/types'; export function getMcpStatus(): Promise { - return apiGetSilent(API.mcpStatus) + return apiGetSilent(API.mcpStatus); } diff --git a/web/src/api/skills.ts b/web/src/api/skills.ts index bab6c80..6987ccf 100644 --- a/web/src/api/skills.ts +++ b/web/src/api/skills.ts @@ -1,14 +1,18 @@ -import { API, apiGetSilent } from './client' -import type { SkillListResponse } from '../components/Settings/types' +import { API, apiGetSilent } from './client'; +import type { SkillListResponse } from '../components/Settings/types'; export function listSkills(): Promise { - return apiGetSilent(API.skills) + return apiGetSilent(API.skills); } -export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise { +export async function toggleSkill( + name: string, + scope: string, + enabled: boolean, +): Promise { return fetch(API.skillsToggle, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), - }) + }); } diff --git a/web/src/api/subagents.ts b/web/src/api/subagents.ts index 4d3c9eb..30771ef 100644 --- a/web/src/api/subagents.ts +++ b/web/src/api/subagents.ts @@ -1,29 +1,33 @@ -import { API, apiGetSilent } from './client' -import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types' +import { API, apiGetSilent } from './client'; +import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'; export function listSubagents(): Promise { - return apiGetSilent(API.subagents) + return apiGetSilent(API.subagents); } -export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise { +export async function toggleSubagent( + name: string, + scope: string, + enabled: boolean, +): Promise { return fetch(API.subagentsToggle, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), - }) + }); } export async function updateSubagent(payload: { - name: string - description?: string - body?: string - capability?: CapabilityPolicy - provider?: string - model?: string + name: string; + description?: string; + body?: string; + capability?: CapabilityPolicy; + provider?: string; + model?: string; }): Promise { return fetch(API.subagentsUpdate, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) + }); } diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 133e10e..48e607b 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -1,6 +1,6 @@ -import { API, apiGetSilent } from './client' -import type { ToolsListResponse } from '../components/Settings/types' +import { API, apiGetSilent } from './client'; +import type { ToolsListResponse } from '../components/Settings/types'; export function listTools(): Promise { - return apiGetSilent(API.tools) + return apiGetSilent(API.tools); } diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index e0e329d..608ffd3 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -1,29 +1,29 @@ -import { useState } from 'react' -import { MessageList } from './MessageList' -import { MessageInput } from './MessageInput' -import { ExpertSelector } from './ExpertSelector' -import { ModelSelector } from './ModelSelector' -import type { ChatMessage, Attachment } from '../../types/protocol' +import { useState } from 'react'; +import { MessageList } from './MessageList'; +import { MessageInput } from './MessageInput'; +import { ExpertSelector } from './ExpertSelector'; +import { ModelSelector } from './ModelSelector'; +import type { ChatMessage, Attachment } from '../../types/protocol'; interface ChatContainerProps { - messages: ChatMessage[] - isLoading: boolean - isReadOnly?: boolean - channelName?: string - onSendMessage: (content: string, attachments: Attachment[]) => void - onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void - onStop?: () => void - showThinking?: boolean + messages: ChatMessage[]; + isLoading: boolean; + isReadOnly?: boolean; + channelName?: string; + onSendMessage: (content: string, attachments: Attachment[]) => void; + onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void; + onStop?: () => void; + showThinking?: boolean; /** 视图标识,用于保存/恢复滚动位置 */ - viewKey?: string + viewKey?: string; /** 高亮的消息 ID */ - highlightedMessageId?: string | null + highlightedMessageId?: string | null; /** 当前 session ID,用于专家选择 */ - sessionId?: string | null + sessionId?: string | null; /** 打开设置页(用于专家管理入口) */ - onOpenSettings?: () => void + onOpenSettings?: () => void; /** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */ - settingsClosedTick?: number + settingsClosedTick?: number; } export function ChatContainer({ @@ -41,12 +41,21 @@ export function ChatContainer({ onOpenSettings, settingsClosedTick, }: ChatContainerProps) { - const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null) + const [selectedExpert, setSelectedExpert] = useState<{ + name: string; + description: string; + } | null>(null); return (
- +
- +
- ) + ); } diff --git a/web/src/components/Chat/ExpertSelector.tsx b/web/src/components/Chat/ExpertSelector.tsx index ba27ea3..29a4b59 100644 --- a/web/src/components/Chat/ExpertSelector.tsx +++ b/web/src/components/Chat/ExpertSelector.tsx @@ -1,145 +1,150 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react' -import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts' +import { useState, useEffect, useRef, useCallback } from 'react'; +import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'; +import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'; interface ExpertItem { - name: string - description: string - source: string - path?: string - body?: string - disabled_in_scopes: string[] + name: string; + description: string; + source: string; + path?: string; + body?: string; + disabled_in_scopes: string[]; } interface SelectedExpert { - name: string - description: string + name: string; + description: string; } interface ExpertSelectorProps { - sessionId: string | null - onManageExperts?: () => void - onSelectionChange?: (expert: SelectedExpert | null) => void + sessionId: string | null; + onManageExperts?: () => void; + onSelectionChange?: (expert: SelectedExpert | null) => void; /** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */ - settingsClosedTick?: number + settingsClosedTick?: number; } -export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) { - const [selectedExpert, setSelectedExpert] = useState(null) - const [expertList, setExpertList] = useState([]) - const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(false) - const [listLoading, setListLoading] = useState(false) - const [error, setError] = useState(null) +export function ExpertSelector({ + sessionId, + onManageExperts, + onSelectionChange, + settingsClosedTick, +}: ExpertSelectorProps) { + const [selectedExpert, setSelectedExpert] = useState(null); + const [expertList, setExpertList] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [listLoading, setListLoading] = useState(false); + const [error, setError] = useState(null); - const containerRef = useRef(null) + const containerRef = useRef(null); // 刷新当前会话选中的专家(后端会对禁用专家返回 null) const refreshSelection = useCallback(() => { if (!sessionId) { - setSelectedExpert(null) - onSelectionChange?.(null) - return + setSelectedExpert(null); + onSelectionChange?.(null); + return; } - setLoading(true) - setError(null) + setLoading(true); + setError(null); getSelectedExpert(sessionId) - .then(data => { + .then((data) => { if (data?.expert) { - setSelectedExpert({ name: data.expert.name, description: data.expert.description }) - onSelectionChange?.({ name: data.expert.name, description: data.expert.description }) + setSelectedExpert({ name: data.expert.name, description: data.expert.description }); + onSelectionChange?.({ name: data.expert.name, description: data.expert.description }); } else { // 已选专家被禁用/删除时,后端返回 null,前端同步清除 - setSelectedExpert(null) - onSelectionChange?.(null) + setSelectedExpert(null); + onSelectionChange?.(null); } }) .catch(() => { // Silent fail: default to no expert - setSelectedExpert(null) - onSelectionChange?.(null) + setSelectedExpert(null); + onSelectionChange?.(null); }) - .finally(() => setLoading(false)) - }, [sessionId, onSelectionChange]) + .finally(() => setLoading(false)); + }, [sessionId, onSelectionChange]); // Load current selection whenever sessionId changes useEffect(() => { - refreshSelection() - }, [refreshSelection]) + refreshSelection(); + }, [refreshSelection]); // 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况) useEffect(() => { - if (settingsClosedTick === undefined) return - refreshSelection() + if (settingsClosedTick === undefined) return; + refreshSelection(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settingsClosedTick]) + }, [settingsClosedTick]); // Click outside to close dropdown useEffect(() => { - if (!open) return + if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) + setOpen(false); } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); const fetchExpertList = useCallback(async () => { - setListLoading(true) - const data = await listExperts() + setListLoading(true); + const data = await listExperts(); if (data) { // Only show enabled experts (disabled_in_scopes.length === 0) const enabled = (data.experts ?? []).filter( - (e: ExpertItem) => e.disabled_in_scopes.length === 0 - ) - setExpertList(enabled) + (e: ExpertItem) => e.disabled_in_scopes.length === 0, + ); + setExpertList(enabled); } - setListLoading(false) - }, []) + setListLoading(false); + }, []); const handleToggleOpen = () => { - const next = !open - setOpen(next) + const next = !open; + setOpen(next); // 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映 if (next) { - fetchExpertList() - refreshSelection() + fetchExpertList(); + refreshSelection(); } - } + }; const handleSelect = async (expert: SelectedExpert | null) => { - if (!sessionId) return + if (!sessionId) return; // Optimistic update - const prev = selectedExpert - setSelectedExpert(expert) - onSelectionChange?.(expert) - setOpen(false) + const prev = selectedExpert; + setSelectedExpert(expert); + onSelectionChange?.(expert); + setOpen(false); try { - const result = await selectExpert(sessionId, expert?.name ?? null) + const result = await selectExpert(sessionId, expert?.name ?? null); if (!result.success) { // Revert - setSelectedExpert(prev) - onSelectionChange?.(prev) - setError(result.error || '切换专家失败') - setTimeout(() => setError(null), 3000) + setSelectedExpert(prev); + onSelectionChange?.(prev); + setError(result.error || '切换专家失败'); + setTimeout(() => setError(null), 3000); } } catch { - setSelectedExpert(prev) - onSelectionChange?.(prev) - setError('网络错误,切换专家失败') - setTimeout(() => setError(null), 3000) + setSelectedExpert(prev); + onSelectionChange?.(prev); + setError('网络错误,切换专家失败'); + setTimeout(() => setError(null), 3000); } - } + }; const handleManage = () => { - setOpen(false) - onManageExperts?.() - } + setOpen(false); + onManageExperts?.(); + }; // If sessionId is null, render nothing - if (!sessionId) return null + if (!sessionId) return null; return (
@@ -148,7 +153,9 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, onClick={handleToggleOpen} disabled={loading} className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50" - title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'} + title={ + selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家' + } > {loading ? ( @@ -175,9 +182,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, {open && ( -
+
{listLoading && expertList.length === 0 ? (
加载中... @@ -197,12 +202,14 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, {expertList.length > 0 ? (
- {expertList.map(expert => { - const isSelected = selectedExpert?.name === expert.name + {expertList.map((expert) => { + const isSelected = selectedExpert?.name === expert.name; return ( - ) + ); })}
) : ( @@ -249,9 +256,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
)}
- {error && ( - {error} - )} + {error && {error}}
- ) + ); } diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index d79cf1d..9b94fda 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -1,13 +1,43 @@ -import { useState, useEffect } from 'react' -import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader, X, Brain, Maximize2 } from 'lucide-react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol' -import { ToolDetailModal } from './ToolDetailModal' +import { useState, useEffect } from 'react'; +import { + User, + Bot, + Wrench, + CheckCircle, + AlertCircle, + Terminal, + File, + Image, + FileText, + Music, + Video, + Download, + ChevronDown, + ChevronRight, + Copy, + Check, + Loader2, + XCircle, + Clock, + Loader, + X, + Brain, + Maximize2, +} from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'; +import { ToolDetailModal } from './ToolDetailModal'; // 状态图标组件 -function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout', size?: number }) { - const iconClass = `transition-all duration-300` +function StatusIcon({ + status, + size = 14, +}: { + status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout'; + size?: number; +}) { + const iconClass = `transition-all duration-300`; switch (status) { case 'calling': @@ -17,7 +47,7 @@ function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pen style={{ width: size, height: size }} strokeWidth={2.5} /> - ) + ); case 'result': case 'success': return ( @@ -26,7 +56,7 @@ function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pen style={{ width: size, height: size }} strokeWidth={2.5} /> - ) + ); case 'failed': return ( - ) + ); case 'timeout': return ( - ) + ); case 'pending': return ( - ) + ); default: - return null + return null; } } interface MessageBubbleProps { - message: ChatMessage - onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void - showThinking?: boolean + message: ChatMessage; + onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void; + showThinking?: boolean; } function getAttachmentIcon(mediaType: string) { switch (mediaType) { - case 'image': return - case 'audio': return - case 'video': return
- ) + ); } function parseTaskResult(content: string): TaskToolResult | null { - if (!content) return null + if (!content) return null; try { - const parsed = JSON.parse(content) + const parsed = JSON.parse(content); if ( parsed && typeof parsed.status === 'string' && @@ -282,21 +341,29 @@ function parseTaskResult(content: string): TaskToolResult | null { typeof parsed.summary === 'string' && typeof parsed.task_id === 'string' ) { - return parsed as TaskToolResult + return parsed as TaskToolResult; } - return null + return null; } catch { - return null + return null; } } -export function MessageBubble({ message, onNavigateToSubAgent, showThinking = true }: MessageBubbleProps) { - const isUser = message.role === 'user' - const isTool = message.role === 'tool' - const isMergedTool = message.type === 'merged_tool' - const [toolExpanded, setToolExpanded] = useState(false) - const [showDetailModal, setShowDetailModal] = useState(false) - const [lightboxImage, setLightboxImage] = useState<{ base64: string; mimeType: string; fileName?: string } | null>(null) +export function MessageBubble({ + message, + onNavigateToSubAgent, + showThinking = true, +}: MessageBubbleProps) { + const isUser = message.role === 'user'; + const isTool = message.role === 'tool'; + const isMergedTool = message.type === 'merged_tool'; + const [toolExpanded, setToolExpanded] = useState(false); + const [showDetailModal, setShowDetailModal] = useState(false); + const [lightboxImage, setLightboxImage] = useState<{ + base64: string; + mimeType: string; + fileName?: string; + } | null>(null); const lightboxElement = lightboxImage ? ( setLightboxImage(null)} /> - ) : null + ) : null; if (isMergedTool) { - const status = message.status || 'calling' - const hasResult = !!(message.resultContent) - const hasArgs = message.arguments !== undefined && message.arguments !== null + const status = message.status || 'calling'; + const hasResult = !!message.resultContent; + const hasArgs = message.arguments !== undefined && message.arguments !== null; const statusConfig = { calling: { @@ -334,52 +401,63 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr avatarBg: 'bg-orange-500/20', avatarIcon: 'text-orange-400', }, - }[status] + }[status]; const formatJSON = (text: string): string => { try { - return JSON.stringify(JSON.parse(text), null, 2) + return JSON.stringify(JSON.parse(text), null, 2); } catch { - return text + return text; } - } + }; function stripToolResultPrefix(text: string): string { - const lines = text.split('\n') + const lines = text.split('\n'); if (lines[0]?.startsWith('工具结果')) { - let start = 1 + let start = 1; while (start < lines.length && lines[start].trim() === '') { - start++ + start++; } - return lines.slice(start).join('\n') + return lines.slice(start).join('\n'); } - return text + return text; } - const argsPreview = hasArgs - ? JSON.stringify(message.arguments).slice(0, 500) - : '' + const argsPreview = hasArgs ? JSON.stringify(message.arguments).slice(0, 500) : ''; - const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : '' + const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : ''; - const isTaskTool = message.toolName === 'task' - const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null - const subagentType = (message.arguments as Record | null)?.subagent_type as string || 'general' - const taskDescription = (message.arguments as Record | null)?.description as string || '' - const taskPrompt = (message.arguments as Record | null)?.prompt as string || '' + const isTaskTool = message.toolName === 'task'; + const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null; + const subagentType = + ((message.arguments as Record | null)?.subagent_type as string) || 'general'; + const taskDescription = + ((message.arguments as Record | null)?.description as string) || ''; + const taskPrompt = + ((message.arguments as Record | null)?.prompt as string) || ''; // task tool 专用的状态配色 const taskStatusConfig = { - success: { dot: 'bg-emerald-400', borderColor: 'border-emerald-500/40', iconColor: 'text-emerald-400' }, + success: { + dot: 'bg-emerald-400', + borderColor: 'border-emerald-500/40', + iconColor: 'text-emerald-400', + }, failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' }, - timeout: { dot: 'bg-amber-400', borderColor: 'border-amber-500/40', iconColor: 'text-amber-400' }, - } as const + timeout: { + dot: 'bg-amber-400', + borderColor: 'border-amber-500/40', + iconColor: 'text-amber-400', + }, + } as const; return (
-
+
{isTaskTool ? ( ) : ( @@ -388,13 +466,17 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
- {message.toolName || 'Tool'} + + {message.toolName || 'Tool'} + {isTaskTool && ( 子智能体·{subagentType} )} - {formatTime(message.timestamp)} + + {formatTime(message.timestamp)} +
setToolExpanded(!toolExpanded)} @@ -404,15 +486,21 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr > {/* Header row */}
- + - {isTaskTool ? (taskDescription || '子智能体任务') : (message.toolName || 'Tool')} + {isTaskTool ? taskDescription || '子智能体任务' : message.toolName || 'Tool'} - + {taskResult ? ( ) : ( @@ -426,7 +514,10 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr )} {hasResult && }
- ) + ); } // 隐藏无可见内容的助手消息(无文本,且无思考或思考被关闭) - const hasVisibleContent = !!(message.content && message.content.trim()) || (showThinking && message.reasoningContent) + const hasVisibleContent = + !!(message.content && message.content.trim()) || (showThinking && message.reasoningContent); if (!isUser && !isTool && !isMergedTool && !hasVisibleContent) { - return null + return null; } const getIcon = () => { - if (isUser) return + if (isUser) return ; if (isTool) { - if (message.type === 'tool_call') return - if (message.type === 'tool_result') return - if (message.type === 'tool_pending') return - return + if (message.type === 'tool_call') return ; + if (message.type === 'tool_result') return ; + if (message.type === 'tool_pending') return ; + return ; } - return - } + return ; + }; const getContainerStyles = () => { if (isUser) { - return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]' + return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]'; } if (isTool) { - if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100' - if (message.type === 'tool_result') return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100' - if (message.type === 'tool_pending') return 'bg-orange-500/10 border-orange-500/30 text-orange-100' - return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]' + if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100'; + if (message.type === 'tool_result') + return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100'; + if (message.type === 'tool_pending') + return 'bg-orange-500/10 border-orange-500/30 text-orange-100'; + return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]'; } - return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]' - } + return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]'; + }; const getAvatarStyles = () => { - if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]' + if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]'; if (isTool) { - if (message.type === 'tool_call') return 'bg-amber-500' - if (message.type === 'tool_result') return 'bg-emerald-500' - if (message.type === 'tool_pending') return 'bg-orange-500' - return 'bg-zinc-700' + if (message.type === 'tool_call') return 'bg-amber-500'; + if (message.type === 'tool_result') return 'bg-emerald-500'; + if (message.type === 'tool_pending') return 'bg-orange-500'; + return 'bg-zinc-700'; } - return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]' - } + return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]'; + }; return ( -
+
@@ -656,7 +793,9 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
- + {isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'} {formatTime(message.timestamp)} @@ -677,113 +816,144 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr ) : ( // 模型思考内容(仅助手消息,非工具消息) <> - {showThinking && !isTool && message.reasoningContent && ( -
- -
- )} - {/* AI 和工具消息使用 Markdown 渲染 */} - {message.content.trim() && ( -
- + +
+ )} + {/* AI 和工具消息使用 Markdown 渲染 */} + {message.content.trim() && ( +
+ + {children} + + ); + } + return ( +
+                            
+                              {children}
+                            
+                          
+ ); + }, + // 标题样式 + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + // 段落 + p: ({ children }) =>

{children}

, + // 列表 + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + // 链接 + a: ({ href, children }) => ( + {children} - - ) - } - return ( -
    -                        
    +                        
    +                      ),
    +                      // 表格
    +                      table: ({ children }) => (
    +                        {children}
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + {children} -
    -
    - ) - }, - // 标题样式 - h1: ({ children }) => ( -

    {children}

    - ), - h2: ({ children }) => ( -

    {children}

    - ), - h3: ({ children }) => ( -

    {children}

    - ), - // 段落 - p: ({ children }) =>

    {children}

    , - // 列表 - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - // 链接 - a: ({ href, children }) => ( - - {children} - - ), - // 表格 - table: ({ children }) => ( - {children}
    - ), - thead: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ), - // 引用块 - blockquote: ({ children }) => ( -
    - {children} -
    - ), - // 分隔线 - hr: () =>
    , - // 加粗和斜体 - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - }} - > - {message.content} -
    -
    - )} - )} + + ), + td: ({ children }) => ( + + {children} + + ), + // 引用块 + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // 分隔线 + hr: () =>
    , + // 加粗和斜体 + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => ( + {children} + ), + }} + > + {message.content} + +
    + )} + + )} {message.attachments && message.attachments.length > 0 && (
    - {message.attachments.some(att => att.media_type === 'image' && att.content_base64) && ( + {message.attachments.some( + (att) => att.media_type === 'image' && att.content_base64, + ) && (
    {message.attachments - .filter(att => att.media_type === 'image' && att.content_base64) + .filter((att) => att.media_type === 'image' && att.content_base64) .map((att, idx) => ( {att.file_name setLightboxImage({ base64: att.content_base64!, mimeType: att.mime_type || 'image/png', fileName: att.file_name || att.path })} + onClick={() => + setLightboxImage({ + base64: att.content_base64!, + mimeType: att.mime_type || 'image/png', + fileName: att.file_name || att.path, + }) + } /> ))}
    )} {message.attachments - .filter(att => att.media_type !== 'image' || !att.content_base64) + .filter((att) => att.media_type !== 'image' || !att.content_base64) .map((att, idx) => ( ))} @@ -793,5 +963,5 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
    {lightboxElement}
    - ) + ); } diff --git a/web/src/components/Chat/MessageInput.tsx b/web/src/components/Chat/MessageInput.tsx index aaf9f6c..87478e2 100644 --- a/web/src/components/Chat/MessageInput.tsx +++ b/web/src/components/Chat/MessageInput.tsx @@ -1,33 +1,45 @@ -import { Send, Loader2, Square, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react' -import { useState, useRef, useEffect } from 'react' -import type { Attachment } from '../../types/protocol' +import { + Send, + Loader2, + Square, + Sparkles, + Eye, + Paperclip, + X, + FileIcon, + ImageIcon, + MusicIcon, + VideoIcon, +} from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import type { Attachment } from '../../types/protocol'; -const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB interface MessageInputProps { - onSend: (content: string, attachments: Attachment[]) => void - onStop?: () => void - disabled?: boolean - isLoading?: boolean - placeholder?: string - isReadOnly?: boolean - channelName?: string - selectedExpert?: { name: string; description: string } | null + onSend: (content: string, attachments: Attachment[]) => void; + onStop?: () => void; + disabled?: boolean; + isLoading?: boolean; + placeholder?: string; + isReadOnly?: boolean; + channelName?: string; + selectedExpert?: { name: string; description: string } | null; } interface FileAttachment { - id: string - file: File - attachment: Attachment - preview?: string // 用于图片预览 + id: string; + file: File; + attachment: Attachment; + preview?: string; // 用于图片预览 } // 根据 MIME 类型判断 media_type function getMediaType(mimeType: string): string { - if (mimeType.startsWith('image/')) return 'image' - if (mimeType.startsWith('audio/')) return 'audio' - if (mimeType.startsWith('video/')) return 'video' - return 'file' + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (mimeType.startsWith('video/')) return 'video'; + return 'file'; } export function MessageInput({ @@ -40,49 +52,50 @@ export function MessageInput({ channelName, selectedExpert, }: MessageInputProps) { - const effectivePlaceholder = placeholder - ?? (selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令') - const [content, setContent] = useState('') - const [attachments, setAttachments] = useState([]) - const [isDragging, setIsDragging] = useState(false) - const [error, setError] = useState(null) - const textareaRef = useRef(null) - const fileInputRef = useRef(null) - const wasLoadingRef = useRef(false) + const effectivePlaceholder = + placeholder ?? + (selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令'); + const [content, setContent] = useState(''); + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const wasLoadingRef = useRef(false); useEffect(() => { - const textarea = textareaRef.current + const textarea = textareaRef.current; if (textarea) { - textarea.style.height = 'auto' - textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } - }, [content]) + }, [content]); // 当 isLoading 从 true 变为 false 时,自动聚焦输入框 useEffect(() => { if (wasLoadingRef.current && !isLoading && !isReadOnly) { - textareaRef.current?.focus() + textareaRef.current?.focus(); } - wasLoadingRef.current = isLoading - }, [isLoading, isReadOnly]) + wasLoadingRef.current = isLoading; + }, [isLoading, isReadOnly]); // 处理文件选择 const handleFileSelect = async (files: FileList | null) => { - if (!files) return - setError(null) + if (!files) return; + setError(null); - const newAttachments: FileAttachment[] = [] + const newAttachments: FileAttachment[] = []; for (const file of Array.from(files)) { // 检查文件大小 if (file.size > MAX_FILE_SIZE) { - setError(`文件 "${file.name}" 超过 50MB 限制`) - continue + setError(`文件 "${file.name}" 超过 50MB 限制`); + continue; } // 读取文件为 base64 - const base64 = await readFileAsBase64(file) - const mimeType = file.type || 'application/octet-stream' - const mediaType = getMediaType(mimeType) + const base64 = await readFileAsBase64(file); + const mimeType = file.type || 'application/octet-stream'; + const mediaType = getMediaType(mimeType); const attachment: Attachment = { path: file.name, @@ -90,78 +103,78 @@ export function MessageInput({ mime_type: mimeType, content_base64: base64, file_name: file.name, - } + }; const fileAttachment: FileAttachment = { id: crypto.randomUUID(), file, attachment, preview: mediaType === 'image' ? base64 : undefined, - } + }; - newAttachments.push(fileAttachment) + newAttachments.push(fileAttachment); } - setAttachments(prev => [...prev, ...newAttachments]) - } + setAttachments((prev) => [...prev, ...newAttachments]); + }; // 读取文件为 base64 const readFileAsBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { - const reader = new FileReader() + const reader = new FileReader(); reader.onload = () => { - const result = reader.result as string + const result = reader.result as string; // 移除 data:xxx;base64, 前缀 - const base64 = result.split(',')[1] - resolve(base64) - } - reader.onerror = reject - reader.readAsDataURL(file) - }) - } + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; // 点击附件按钮 const handleAttachClick = () => { - fileInputRef.current?.click() - } + fileInputRef.current?.click(); + }; // 删除附件 const handleRemoveAttachment = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)) - } + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }; // 粘贴事件处理 const handlePaste = async (e: React.ClipboardEvent) => { - if (disabled || isReadOnly) return + if (disabled || isReadOnly) return; - const clipboardData = e.clipboardData - const items = clipboardData.items + const clipboardData = e.clipboardData; + const items = clipboardData.items; // 检查是否有文件(图片或其他文件) - const files: File[] = [] + const files: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file') { - const file = item.getAsFile() + const file = item.getAsFile(); if (file) { - files.push(file) + files.push(file); } } } // 如果有文件,处理文件并阻止默认粘贴行为 if (files.length > 0) { - e.preventDefault() + e.preventDefault(); // 直接处理文件数组 - setError(null) + setError(null); for (const file of files) { if (file.size > MAX_FILE_SIZE) { - setError(`文件 "${file.name}" 超过 50MB 限制`) - continue + setError(`文件 "${file.name}" 超过 50MB 限制`); + continue; } - const base64 = await readFileAsBase64(file) - const mimeType = file.type || 'application/octet-stream' - const mediaType = getMediaType(mimeType) + const base64 = await readFileAsBase64(file); + const mimeType = file.type || 'application/octet-stream'; + const mediaType = getMediaType(mimeType); const attachment: Attachment = { path: file.name, @@ -169,91 +182,91 @@ export function MessageInput({ mime_type: mimeType, content_base64: base64, file_name: file.name, - } + }; const fileAttachment: FileAttachment = { id: crypto.randomUUID(), file, attachment, preview: mediaType === 'image' ? base64 : undefined, - } + }; - setAttachments(prev => [...prev, fileAttachment]) + setAttachments((prev) => [...prev, fileAttachment]); } } // 否则让默认的文本粘贴行为继续 - } + }; // 拖拽事件 const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); if (!disabled && !isReadOnly) { - setIsDragging(true) + setIsDragging(true); } - } + }; const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); // 检查是否真的离开了拖拽区域(而不是进入子元素) - const relatedTarget = e.relatedTarget as Node | null - const currentTarget = e.currentTarget + const relatedTarget = e.relatedTarget as Node | null; + const currentTarget = e.currentTarget; if (!relatedTarget || !currentTarget.contains(relatedTarget)) { - setIsDragging(false) + setIsDragging(false); } - } + }; const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } + e.preventDefault(); + e.stopPropagation(); + }; const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); if (!disabled && !isReadOnly) { - handleFileSelect(e.dataTransfer.files) + handleFileSelect(e.dataTransfer.files); } - } + }; const handleSend = () => { - const hasContent = content.trim() || attachments.length > 0 + const hasContent = content.trim() || attachments.length > 0; if (hasContent && !disabled && !isReadOnly) { onSend( content.trim(), - attachments.map(a => a.attachment) - ) - setContent('') - setAttachments([]) - setError(null) + attachments.map((a) => a.attachment), + ); + setContent(''); + setAttachments([]); + setError(null); if (textareaRef.current) { - textareaRef.current.style.height = 'auto' + textareaRef.current.style.height = 'auto'; } } - } + }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() + e.preventDefault(); + handleSend(); } - } + }; // 获取附件图标 const getAttachmentIcon = (mediaType: string) => { switch (mediaType) { case 'image': - return + return ; case 'audio': - return + return ; case 'video': - return + return ; default: - return + return ; } - } + }; // 只读模式:显示提示占位符 if (isReadOnly) { @@ -273,14 +286,12 @@ export function MessageInput({ '当前通道仅支持查看历史消息' )}

    -

    - 请切换至 WebSocket 通道进行输入 -

    +

    请切换至 WebSocket 通道进行输入

    - ) + ); } return ( @@ -337,9 +348,7 @@ export function MessageInput({ {/* 拖拽提示 */} {isDragging && (
    -
    - 拖放文件到这里 -
    +
    拖放文件到这里
    )} @@ -405,5 +414,5 @@ export function MessageInput({
    - ) + ); } diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 5b9b6f1..98b6ede 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -1,141 +1,147 @@ -import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react' -import { MessageBubble } from './MessageBubble' -import type { ChatMessage } from '../../types/protocol' -import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react' +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; +import { MessageBubble } from './MessageBubble'; +import type { ChatMessage } from '../../types/protocol'; +import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'; interface MessageListProps { - messages: ChatMessage[] - onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void - showThinking?: boolean + messages: ChatMessage[]; + onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void; + showThinking?: boolean; /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ - viewKey?: string + viewKey?: string; /** 高亮的消息 ID,点击待办项后滚动并高亮显示 */ - highlightedMessageId?: string | null + highlightedMessageId?: string | null; } -export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) { - const bottomRef = useRef(null) - const containerRef = useRef(null) - const isAtBottomRef = useRef(true) - const prevShowBottomRef = useRef(false) - const prevViewKeyRef = useRef(viewKey) - const viewKeyRef = useRef(viewKey) - viewKeyRef.current = viewKey +export function MessageList({ + messages, + onNavigateToSubAgent, + showThinking = true, + viewKey, + highlightedMessageId, +}: MessageListProps) { + const bottomRef = useRef(null); + const containerRef = useRef(null); + const isAtBottomRef = useRef(true); + const prevShowBottomRef = useRef(false); + const prevViewKeyRef = useRef(viewKey); + const viewKeyRef = useRef(viewKey); + viewKeyRef.current = viewKey; // Per-view scroll position memory - const scrollPositionsRef = useRef>(new Map()) + const scrollPositionsRef = useRef>(new Map()); - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const [newMessageCount, setNewMessageCount] = useState(0) + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [newMessageCount, setNewMessageCount] = useState(0); // ---- scroll helpers ---- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { - isAtBottomRef.current = true - setShowScrollToBottom(false) - setNewMessageCount(0) - bottomRef.current?.scrollIntoView({ behavior }) - }, []) + isAtBottomRef.current = true; + setShowScrollToBottom(false); + setNewMessageCount(0); + bottomRef.current?.scrollIntoView({ behavior }); + }, []); const scrollToTop = useCallback(() => { - containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) - }, []) + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); // ---- scroll event: track whether user is at bottom ---- const handleScroll = useCallback(() => { - const el = containerRef.current - if (!el) return + const el = containerRef.current; + if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - const nearBottom = distanceFromBottom < 120 + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + const nearBottom = distanceFromBottom < 120; // Save scroll position for current view - const key = viewKeyRef.current + const key = viewKeyRef.current; if (key) { - scrollPositionsRef.current.set(key, el.scrollTop) + scrollPositionsRef.current.set(key, el.scrollTop); } - isAtBottomRef.current = nearBottom + isAtBottomRef.current = nearBottom; // 回到底部:距底部 > 200px 时显示(同时显示回到顶部) - const shouldShowBottom = distanceFromBottom > 200 + const shouldShowBottom = distanceFromBottom > 200; if (shouldShowBottom !== prevShowBottomRef.current) { - prevShowBottomRef.current = shouldShowBottom - setShowScrollToBottom(shouldShowBottom) + prevShowBottomRef.current = shouldShowBottom; + setShowScrollToBottom(shouldShowBottom); } // 滚回底部时清除新消息计数 if (nearBottom) { - setNewMessageCount(0) + setNewMessageCount(0); } - }, []) + }, []); // ---- auto-scroll: handle view switches and message updates ---- useLayoutEffect(() => { - const prevKey = prevViewKeyRef.current - const viewChanged = prevKey !== viewKey - prevViewKeyRef.current = viewKey + const prevKey = prevViewKeyRef.current; + const viewChanged = prevKey !== viewKey; + prevViewKeyRef.current = viewKey; if (messages.length === 0) { - isAtBottomRef.current = true - return + isAtBottomRef.current = true; + return; } if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position - const key = viewKey ?? '' - const savedPos = scrollPositionsRef.current.get(key) + const key = viewKey ?? ''; + const savedPos = scrollPositionsRef.current.get(key); if (savedPos !== undefined && containerRef.current) { - containerRef.current.scrollTop = savedPos - const el = containerRef.current - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - isAtBottomRef.current = distanceFromBottom < 120 - return + containerRef.current.scrollTop = savedPos; + const el = containerRef.current; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + isAtBottomRef.current = distanceFromBottom < 120; + return; } // First time viewing this view: scroll to bottom - isAtBottomRef.current = true - bottomRef.current?.scrollIntoView({ behavior: 'instant' }) - return + isAtBottomRef.current = true; + bottomRef.current?.scrollIntoView({ behavior: 'instant' }); + return; } // Same view, messages changed: normal auto-scroll logic - const lastMessage = messages[messages.length - 1] + const lastMessage = messages[messages.length - 1]; if (lastMessage.role === 'user' || isAtBottomRef.current) { - bottomRef.current?.scrollIntoView({ behavior: 'instant' }) + bottomRef.current?.scrollIntoView({ behavior: 'instant' }); } else { - setNewMessageCount((prev) => prev + 1) + setNewMessageCount((prev) => prev + 1); } - }, [messages, viewKey]) + }, [messages, viewKey]); // ---- mount: always scroll to bottom if messages already loaded ---- useEffect(() => { if (messages.length > 0) { - scrollToBottom('instant') + scrollToBottom('instant'); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); // ---- highlight and scroll to todo message ---- useEffect(() => { - if (!highlightedMessageId) return + if (!highlightedMessageId) return; - const container = containerRef.current - if (!container) return + const container = containerRef.current; + if (!container) return; - const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`) - if (!targetElement) return + const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`); + if (!targetElement) return; - targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) - targetElement.classList.add('todo-highlight') + targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + targetElement.classList.add('todo-highlight'); setTimeout(() => { - targetElement.classList.remove('todo-highlight') - }, 2000) - }, [highlightedMessageId]) + targetElement.classList.remove('todo-highlight'); + }, 2000); + }, [highlightedMessageId]); // ---- empty state ---- @@ -149,12 +155,16 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru

    开始新的对话

    在下方输入消息开始与 AI 助手聊天

    - /new 创建话题 - /list 查看列表 + + /new 创建话题 + + + /list 查看列表 +
    - ) + ); } // ---- main render ---- @@ -167,7 +177,12 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru className="h-full overflow-y-auto p-6 space-y-6" > {messages.map((message) => ( - + ))}
    @@ -205,7 +220,9 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru animate-fade-in" aria-label="回到底部" > - 0 ? 'animate-bounce' : ''}`} /> + 0 ? 'animate-bounce' : ''}`} + /> {newMessageCount > 0 ? ( {newMessageCount} 条新消息 @@ -223,5 +240,5 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru )} - ) + ); } diff --git a/web/src/components/Chat/ModelSelector.tsx b/web/src/components/Chat/ModelSelector.tsx index d2333e0..36a6929 100644 --- a/web/src/components/Chat/ModelSelector.tsx +++ b/web/src/components/Chat/ModelSelector.tsx @@ -1,151 +1,157 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react' -import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts' -import type { ModelOptionsResponse } from '../Settings/types' +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'; +import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'; +import type { ModelOptionsResponse } from '../Settings/types'; interface ModelSelectorProps { - sessionId: string | null + sessionId: string | null; /** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */ - settingsClosedTick?: number + settingsClosedTick?: number; /** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */ - onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void + onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void; } -export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange }: ModelSelectorProps) { - const [modelOptions, setModelOptions] = useState(null) - const [userProvider, setUserProvider] = useState(null) - const [userModel, setUserModel] = useState(null) - const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(false) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) +export function ModelSelector({ + sessionId, + settingsClosedTick, + onSelectionChange, +}: ModelSelectorProps) { + const [modelOptions, setModelOptions] = useState(null); + const [userProvider, setUserProvider] = useState(null); + const [userModel, setUserModel] = useState(null); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); // 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交 - const [draftProvider, setDraftProvider] = useState('') - const [draftModel, setDraftModel] = useState('') + const [draftProvider, setDraftProvider] = useState(''); + const [draftModel, setDraftModel] = useState(''); - const containerRef = useRef(null) + const containerRef = useRef(null); // 刷新当前会话的用户模型覆盖 const refreshSelection = useCallback(() => { if (!sessionId) { - setUserProvider(null) - setUserModel(null) - return + setUserProvider(null); + setUserModel(null); + return; } - setLoading(true) - setError(null) + setLoading(true); + setError(null); getSelectedModel(sessionId) - .then(data => { - setUserProvider(data.provider) - setUserModel(data.model) + .then((data) => { + setUserProvider(data.provider); + setUserModel(data.model); }) .catch(() => { - setUserProvider(null) - setUserModel(null) + setUserProvider(null); + setUserModel(null); }) - .finally(() => setLoading(false)) - }, [sessionId]) + .finally(() => setLoading(false)); + }, [sessionId]); // 加载模型选项(全局缓存,仅加载一次) useEffect(() => { - if (modelOptions) return - listModelOptions().then(data => { - if (data) setModelOptions(data) - }) - }, [modelOptions]) + if (modelOptions) return; + listModelOptions().then((data) => { + if (data) setModelOptions(data); + }); + }, [modelOptions]); // sessionId 变化时刷新用户选择 useEffect(() => { - refreshSelection() - }, [refreshSelection]) + refreshSelection(); + }, [refreshSelection]); // 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更) useEffect(() => { - if (settingsClosedTick === undefined) return - refreshSelection() + if (settingsClosedTick === undefined) return; + refreshSelection(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settingsClosedTick]) + }, [settingsClosedTick]); // 计算生效模型并通知父组件 - const overridden = userProvider !== null || userModel !== null - const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '' - const effectiveModel = userModel ?? modelOptions?.current.model ?? '' + const overridden = userProvider !== null || userModel !== null; + const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? ''; + const effectiveModel = userModel ?? modelOptions?.current.model ?? ''; useEffect(() => { - if (!modelOptions) return + if (!modelOptions) return; onSelectionChange?.({ provider: effectiveProvider, model: effectiveModel, overridden, - }) + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveProvider, effectiveModel, overridden, modelOptions]) + }, [effectiveProvider, effectiveModel, overridden, modelOptions]); // 点击外部关闭 dropdown useEffect(() => { - if (!open) return + if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) + setOpen(false); } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); const handleToggleOpen = () => { - const next = !open - setOpen(next) + const next = !open; + setOpen(next); if (next) { // 打开时刷新选项与当前选择,同步草稿 if (!modelOptions) { - listModelOptions().then(data => { if (data) setModelOptions(data) }) + listModelOptions().then((data) => { + if (data) setModelOptions(data); + }); } - refreshSelection() - setDraftProvider(userProvider ?? '') - setDraftModel(userModel ?? '') + refreshSelection(); + setDraftProvider(userProvider ?? ''); + setDraftModel(userModel ?? ''); } - } + }; const handleApply = async () => { - if (!sessionId) return - const provider = draftProvider.trim() || null - const model = draftModel.trim() || null - setSaving(true) - setError(null) + if (!sessionId) return; + const provider = draftProvider.trim() || null; + const model = draftModel.trim() || null; + setSaving(true); + setError(null); try { - const result = await selectModel(sessionId, provider, model) + const result = await selectModel(sessionId, provider, model); if (!result.success) { - setError(result.error || '切换模型失败') - setTimeout(() => setError(null), 3000) - return + setError(result.error || '切换模型失败'); + setTimeout(() => setError(null), 3000); + return; } - setUserProvider(provider) - setUserModel(model) - setOpen(false) + setUserProvider(provider); + setUserModel(model); + setOpen(false); } catch { - setError('网络错误,切换模型失败') - setTimeout(() => setError(null), 3000) + setError('网络错误,切换模型失败'); + setTimeout(() => setError(null), 3000); } finally { - setSaving(false) + setSaving(false); } - } + }; const handleReset = () => { - setDraftProvider('') - setDraftModel('') - } + setDraftProvider(''); + setDraftModel(''); + }; - if (!sessionId) return null + if (!sessionId) return null; // 草稿是否与已保存状态不同(用于启用"应用"按钮) const draftChanged = (draftProvider || null) !== (userProvider ?? null) || - (draftModel || null) !== (userModel ?? null) + (draftModel || null) !== (userModel ?? null); const buttonLabel = overridden ? `${effectiveProvider}/${effectiveModel}` - : `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}` + : `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`; return (
    @@ -154,7 +160,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange onClick={handleToggleOpen} disabled={loading} className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50" - title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`} + title={ + overridden + ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` + : `继承默认: ${effectiveProvider}/${effectiveModel}` + } > {loading ? ( @@ -163,7 +173,9 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`} /> )} - + {buttonLabel} {open && ( -
    +
    {overridden ? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)` @@ -186,12 +196,16 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange Provider @@ -200,20 +214,22 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange Model
    - {error && ( -
    {error}
    - )} + {error &&
    {error}
    }
    @@ -240,9 +260,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
    )}
    - {error && ( - {error} - )} + {error && {error}}
    - ) + ); } diff --git a/web/src/components/Chat/ToolDetailModal.tsx b/web/src/components/Chat/ToolDetailModal.tsx index c9e6a43..c3c140e 100644 --- a/web/src/components/Chat/ToolDetailModal.tsx +++ b/web/src/components/Chat/ToolDetailModal.tsx @@ -1,34 +1,34 @@ -import { useEffect } from 'react' -import { X, Terminal, Clock, Maximize2 } from 'lucide-react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' +import { useEffect } from 'react'; +import { X, Terminal, Clock, Maximize2 } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; interface ToolDetailModalProps { - toolName: string - status: string - statusLabel: string - arguments?: unknown - resultContent: string - callContent: string - durationMs?: number - onClose: () => void + toolName: string; + status: string; + statusLabel: string; + arguments?: unknown; + resultContent: string; + callContent: string; + durationMs?: number; + onClose: () => void; } function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms` - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` - const minutes = Math.floor(ms / 60000) - const seconds = Math.floor((ms % 60000) / 1000) - return `${minutes}m ${seconds}s` + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}m ${seconds}s`; } function formatJSON(text: string): string { - if (!text) return '' + if (!text) return ''; try { - const parsed = JSON.parse(text) - return JSON.stringify(parsed, null, 2) + const parsed = JSON.parse(text); + return JSON.stringify(parsed, null, 2); } catch { - return text + return text; } } @@ -44,20 +44,23 @@ export function ToolDetailModal({ }: ToolDetailModalProps) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [onClose]) + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); - const displayContent = resultContent || callContent - const formattedContent = formatJSON(displayContent) + const displayContent = resultContent || callContent; + const formattedContent = formatJSON(displayContent); const statusColor = - status === 'calling' ? 'var(--accent-amber)' : - status === 'result' ? 'var(--accent-green)' : - status === 'pending' ? '#f59e0b' : - 'var(--text-muted)' + status === 'calling' + ? 'var(--accent-amber)' + : status === 'result' + ? 'var(--accent-green)' + : status === 'pending' + ? '#f59e0b' + : 'var(--text-muted)'; return (
    - - {formattedContent} - + {formattedContent}
    )} @@ -142,5 +143,5 @@ export function ToolDetailModal({ - ) + ); } diff --git a/web/src/components/ConnectionStatus.tsx b/web/src/components/ConnectionStatus.tsx index b7af230..ec6f720 100644 --- a/web/src/components/ConnectionStatus.tsx +++ b/web/src/components/ConnectionStatus.tsx @@ -1,8 +1,8 @@ -import { Wifi, WifiOff, Loader2 } from 'lucide-react' -import type { ConnectionStatus } from '../types/protocol' +import { Wifi, WifiOff, Loader2 } from 'lucide-react'; +import type { ConnectionStatus } from '../types/protocol'; interface ConnectionStatusProps { - status: ConnectionStatus + status: ConnectionStatus; } export function ConnectionStatus({ status }: ConnectionStatusProps) { @@ -13,36 +13,38 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) { icon: , text: '连接中', className: 'text-amber-400 bg-amber-400/10 border-amber-400/30', - } + }; case 'connected': return { icon: , text: '已连接', className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30', - } + }; case 'disconnected': return { icon: , text: '已断开', className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30', - } + }; case 'error': return { icon: , text: '连接错误', className: 'text-red-400 bg-red-400/10 border-red-400/30', - } + }; } - } + }; - const config = getStatusConfig() + const config = getStatusConfig(); - if (!config) return null + if (!config) return null; return ( -
    +
    {config.icon} {config.text}
    - ) + ); } diff --git a/web/src/components/Header/ChannelSelector.tsx b/web/src/components/Header/ChannelSelector.tsx index 2a648f8..6f6ab11 100644 --- a/web/src/components/Header/ChannelSelector.tsx +++ b/web/src/components/Header/ChannelSelector.tsx @@ -1,12 +1,12 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { createPortal } from 'react-dom' -import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react' -import type { Channel } from '../../types/protocol' +import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react'; +import type { Channel } from '../../types/protocol'; interface ChannelSelectorProps { - channels: Channel[] - selectedChannel: string - onSelectChannel: (channelId: string) => void + channels: Channel[]; + selectedChannel: string; + onSelectChannel: (channelId: string) => void; } const CHANNEL_ICONS: Record = { @@ -30,73 +30,78 @@ const CHANNEL_ICONS: Record = icon: , color: 'var(--accent-green)', }, -} +}; const DEFAULT_ICON = { icon: , color: 'var(--text-muted)', -} +}; export function ChannelSelector({ channels, selectedChannel, onSelectChannel, }: ChannelSelectorProps) { - const [isOpen, setIsOpen] = useState(false) - const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }) - const triggerRef = useRef(null) - const dropdownRef = useRef(null) - const selected = channels.find((c) => c.id === selectedChannel) - const iconConfig = selected ? (CHANNEL_ICONS[selected.id] || DEFAULT_ICON) : DEFAULT_ICON + const [isOpen, setIsOpen] = useState(false); + const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ + top: 0, + right: 0, + }); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const selected = channels.find((c) => c.id === selectedChannel); + const iconConfig = selected ? CHANNEL_ICONS[selected.id] || DEFAULT_ICON : DEFAULT_ICON; // Calculate dropdown position when opening const updatePosition = useCallback(() => { if (triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect() + const rect = triggerRef.current.getBoundingClientRect(); setDropdownPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, - }) + }); } - }, []) + }, []); useEffect(() => { if (isOpen) { - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; } - }, [isOpen, updatePosition]) + }, [isOpen, updatePosition]); // Close on outside click useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleClick = (e: MouseEvent) => { - const target = e.target as Node + const target = e.target as Node; if ( - dropdownRef.current && !dropdownRef.current.contains(target) && - triggerRef.current && !triggerRef.current.contains(target) + dropdownRef.current && + !dropdownRef.current.contains(target) && + triggerRef.current && + !triggerRef.current.contains(target) ) { - setIsOpen(false) + setIsOpen(false); } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [isOpen]) + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen]); // Close on Escape useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsOpen(false) - } - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [isOpen]) + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen]); return ( <> @@ -107,9 +112,10 @@ export function ChannelSelector({ className={` group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between transition-all duration-200 ease-out - ${isOpen - ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' - : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]' + ${ + isOpen + ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' + : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]' } `} > @@ -131,7 +137,9 @@ export function ChannelSelector({ {selected ? ( ) : ( @@ -146,115 +154,117 @@ export function ChannelSelector({ {/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */} - {isOpen && createPortal( -
    + {isOpen && + createPortal(
    +
    - {/* Channel List */} -
    - {channels.length === 0 ? ( -
    - 暂无可用通道 -
    - ) : ( - channels.map((channel, index) => { - const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON - const isActive = channel.id === selectedChannel + > + {/* Channel List */} +
    + {channels.length === 0 ? ( +
    + 暂无可用通道 +
    + ) : ( + channels.map((channel, index) => { + const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON; + const isActive = channel.id === selectedChannel; - return ( - - ) - }) - )} + > + {channel.isWritable ? ( + + ) : ( + + )} + {channel.isWritable ? '可写' : '只读'} + + + ); + }) + )} +
    -
    -
    , - document.body - )} +
    , + document.body, + )} - ) + ); } diff --git a/web/src/components/Header/SessionSelector.tsx b/web/src/components/Header/SessionSelector.tsx index d59b598..2c119be 100644 --- a/web/src/components/Header/SessionSelector.tsx +++ b/web/src/components/Header/SessionSelector.tsx @@ -1,12 +1,12 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { createPortal } from 'react-dom' -import { MessageSquare, ChevronDown } from 'lucide-react' -import type { SessionSummary } from '../../types/protocol' +import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { MessageSquare, ChevronDown } from 'lucide-react'; +import type { SessionSummary } from '../../types/protocol'; interface SessionSelectorProps { - sessions: SessionSummary[] - selectedSessionId: string | null - onSelectSession: (sessionId: string) => void + sessions: SessionSummary[]; + selectedSessionId: string | null; + onSelectSession: (sessionId: string) => void; } export function SessionSelector({ @@ -14,59 +14,64 @@ export function SessionSelector({ selectedSessionId, onSelectSession, }: SessionSelectorProps) { - const [isOpen, setIsOpen] = useState(false) - const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }) - const triggerRef = useRef(null) - const dropdownRef = useRef(null) - const selected = sessions.find((s) => s.session_id === selectedSessionId) + const [isOpen, setIsOpen] = useState(false); + const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ + top: 0, + right: 0, + }); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const selected = sessions.find((s) => s.session_id === selectedSessionId); const updatePosition = useCallback(() => { if (triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect() + const rect = triggerRef.current.getBoundingClientRect(); setDropdownPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, - }) + }); } - }, []) + }, []); useEffect(() => { if (isOpen) { - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; } - }, [isOpen, updatePosition]) + }, [isOpen, updatePosition]); useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleClick = (e: MouseEvent) => { - const target = e.target as Node + const target = e.target as Node; if ( - dropdownRef.current && !dropdownRef.current.contains(target) && - triggerRef.current && !triggerRef.current.contains(target) + dropdownRef.current && + !dropdownRef.current.contains(target) && + triggerRef.current && + !triggerRef.current.contains(target) ) { - setIsOpen(false) + setIsOpen(false); } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [isOpen]) + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen]); useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsOpen(false) - } - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [isOpen]) + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen]); - if (sessions.length === 0) return null + if (sessions.length === 0) return null; return ( <> @@ -77,11 +82,12 @@ export function SessionSelector({ className={` group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between transition-all duration-200 ease-out - ${sessions.length <= 1 - ? 'border-[var(--border-color)] cursor-default' - : isOpen - ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' - : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer' + ${ + sessions.length <= 1 + ? 'border-[var(--border-color)] cursor-default' + : isOpen + ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' + : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer' } `} > @@ -104,73 +110,79 @@ export function SessionSelector({ )} - {isOpen && sessions.length > 1 && createPortal( -
    + {isOpen && + sessions.length > 1 && + createPortal(
    +
    -
    - {sessions.map((s, index) => { - const isActive = s.session_id === selectedSessionId - return ( - - ) - })} + + {s.message_count} + + + ); + })} +
    -
    -
    , - document.body - )} +
    , + document.body, + )} - ) + ); } diff --git a/web/src/components/Panel/MemoryPanel.tsx b/web/src/components/Panel/MemoryPanel.tsx index accba17..582fc11 100644 --- a/web/src/components/Panel/MemoryPanel.tsx +++ b/web/src/components/Panel/MemoryPanel.tsx @@ -1,80 +1,167 @@ -import { useState } from 'react' -import { Brain, User, Library, History, Cpu, Globe, Star, Package, RefreshCw, X, ChevronDown, ChevronRight, Plus, Pencil, Trash2, Check } from 'lucide-react' -import type { MemorySummary, Command } from '../../types/protocol' +import { useState } from 'react'; +import { + Brain, + User, + Library, + History, + Cpu, + Globe, + Star, + Package, + RefreshCw, + X, + ChevronDown, + ChevronRight, + Plus, + Pencil, + Trash2, + Check, +} from 'lucide-react'; +import type { MemorySummary, Command } from '../../types/protocol'; /* ── types ────────────────────────────────────────────── */ interface MemoryPanelProps { - memories: MemorySummary[] - onRefresh: () => void - onClose?: () => void - onCreateMemory: (ns: string, key: string, content: string) => Command - onUpdateMemory: (id: string, content: string) => Command - onDeleteMemory: (id: string) => Command - sendCommand: (cmd: Command) => void + memories: MemorySummary[]; + onRefresh: () => void; + onClose?: () => void; + onCreateMemory: (ns: string, key: string, content: string) => Command; + onUpdateMemory: (id: string, content: string) => Command; + onDeleteMemory: (id: string) => Command; + sendCommand: (cmd: Command) => void; } -interface NamespaceConfig { label: string; icon: typeof Brain; accent: string; accentBorder: string } +interface NamespaceConfig { + label: string; + icon: typeof Brain; + accent: string; + accentBorder: string; +} const NS: Record = { - user: { label: '用户记忆', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-400/40' }, - semantic: { label: '语义记忆', icon: Library, accent: 'text-amber-400', accentBorder: 'border-amber-400/40' }, - episodic: { label: '情景记忆', icon: History, accent: 'text-purple-400', accentBorder: 'border-purple-400/40' }, - skill: { label: '技能记忆', icon: Cpu, accent: 'text-green-400', accentBorder: 'border-green-400/40' }, - environment: { label: '环境记忆', icon: Globe, accent: 'text-sky-400', accentBorder: 'border-sky-400/40' }, - reflection: { label: '反思记忆', icon: Star, accent: 'text-rose-400', accentBorder: 'border-rose-400/40' }, - other: { label: '其他', icon: Package, accent: 'text-stone-400', accentBorder: 'border-stone-400/40' }, -} + user: { + label: '用户记忆', + icon: User, + accent: 'text-cyan-400', + accentBorder: 'border-cyan-400/40', + }, + semantic: { + label: '语义记忆', + icon: Library, + accent: 'text-amber-400', + accentBorder: 'border-amber-400/40', + }, + episodic: { + label: '情景记忆', + icon: History, + accent: 'text-purple-400', + accentBorder: 'border-purple-400/40', + }, + skill: { + label: '技能记忆', + icon: Cpu, + accent: 'text-green-400', + accentBorder: 'border-green-400/40', + }, + environment: { + label: '环境记忆', + icon: Globe, + accent: 'text-sky-400', + accentBorder: 'border-sky-400/40', + }, + reflection: { + label: '反思记忆', + icon: Star, + accent: 'text-rose-400', + accentBorder: 'border-rose-400/40', + }, + other: { + label: '其他', + icon: Package, + accent: 'text-stone-400', + accentBorder: 'border-stone-400/40', + }, +}; function cfg(ns: string): NamespaceConfig { - return NS[ns] ?? { label: ns, icon: Package, accent: 'text-[var(--text-secondary)]', accentBorder: 'border-[var(--border-color)]' } + return ( + NS[ns] ?? { + label: ns, + icon: Package, + accent: 'text-[var(--text-secondary)]', + accentBorder: 'border-[var(--border-color)]', + } + ); } -function fmtKey(k: string) { return k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } +function fmtKey(k: string) { + return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} -const NS_OPTIONS = Object.entries(NS) +const NS_OPTIONS = Object.entries(NS); /* ── Memory card with edit/delete ──────────────────────── */ -function MemoryCard({ memory, config, onUpdate, onDelete }: - { memory: MemorySummary; config: NamespaceConfig; onUpdate: (id: string, content: string) => void; onDelete: (id: string) => void }) { - - const [editing, setEditing] = useState(false) - const [editContent, setEditContent] = useState(memory.content) - const [confirmDelete, setConfirmDelete] = useState(false) +function MemoryCard({ + memory, + config, + onUpdate, + onDelete, +}: { + memory: MemorySummary; + config: NamespaceConfig; + onUpdate: (id: string, content: string) => void; + onDelete: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(memory.content); + const [confirmDelete, setConfirmDelete] = useState(false); const handleSave = () => { if (editContent.trim() && editContent !== memory.content) { - onUpdate(memory.id, editContent.trim()) + onUpdate(memory.id, editContent.trim()); } - setEditing(false) - } + setEditing(false); + }; const handleDelete = () => { if (confirmDelete) { - onDelete(memory.id) + onDelete(memory.id); } else { - setConfirmDelete(true) - setTimeout(() => setConfirmDelete(false), 3000) + setConfirmDelete(true); + setTimeout(() => setConfirmDelete(false), 3000); } - } + }; return ( -
    +
    {/* header row */}
    - + {fmtKey(memory.memory_key)} {/* action buttons — visible on hover */}
    - -
    @@ -83,11 +170,23 @@ function MemoryCard({ memory, config, onUpdate, onDelete }: {/* content */} {editing ? (
    -