From 7c578af0bc28f8b5fd6e95e232bebfb532d1f36f Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Wed, 8 Jul 2026 11:28:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E5=86=99=20useChat=20?= =?UTF-8?q?=E4=B8=BA=E7=BB=84=E5=90=88=E6=A0=B9=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E4=B8=8A=E5=B8=9D=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 1290 行的 useChat 重写为 360 行组合根:handleServerMessage 从 462 行降为 80 行纯路由分发(Tier1 调度器→Tier2 子智能体→Tier3 主视图),消除组合根层 5 个跨域 ref 同步 effect。UseChatReturn 接口零变更,App.tsx 无需改动。对抗式审查修复 1 个 Critical 项(todo_write 自动刷新的 topic_id 检查)。 --- web/src/hooks/useChat.ts | 1306 ++++++-------------------------------- 1 file changed, 188 insertions(+), 1118 deletions(-) diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 5eb7ff8..ccea9ba 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -1,38 +1,28 @@ -import { useState, useCallback, useEffect, useRef, useMemo, type Dispatch, type SetStateAction } from 'react' +import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import type { Command, ChatMessage, Topic, WsOutbound, - AssistantResponse, - ToolCall, - ToolResult, - ToolPending, - SessionEstablished, - SessionList, - SessionSummary, - TopicList, - TopicSummary, - TaskMessagesLoaded, - TaskStarted, + WsInbound, Attachment, + SessionSummary, MemorySummary, - MemoryList, SkillSummary, - SkillList, TodoItemSummary, - TodoList, - SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, Channel, - ChannelList, - StreamDelta, - StreamEnd, - ExecutionCompleted, - WsError, - WsInbound, } from '../types/protocol' +import type { SubAgentView, SchedulerJobView } from './chat/types' +import { getSubagentTaskId } from './chat/messageMappers' +import { useConnection } from './chat/useConnection' +import { useSideData } from './chat/useSideData' +import { useSessions } from './chat/useSessions' +import { useTopics } from './chat/useTopics' +import { useMessages } from './chat/useMessages' +import { useSubAgentView } from './chat/useSubAgentView' +import { useSchedulerView } from './chat/useSchedulerView' // 简化后的层级状态 interface UseChatReturn { @@ -127,852 +117,113 @@ interface UseChatReturn { handleStop: () => Command } -interface SubAgentView { - taskId: string - description: string - subagentType: string - status: string - summary?: string - messages: ChatMessage[] -} - -interface SchedulerJobView { - jobId: string - description: string - channel: string - chatId: string - messages: ChatMessage[] -} - -const DEFAULT_CHAT_ID = 'default' - export function useChat(): UseChatReturn { - const [messages, setMessages] = useState([]) - const [isLoading, setIsLoading] = useState(false) - - // 简化的状态管理 - const [connectionId, setConnectionId] = useState(null) - const [topics, setTopics] = useState([]) - const [selectedTopic, setSelectedTopic] = useState(null) - const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0) - const [sessions, setSessions] = useState([]) - const [selectedSessionId, setSelectedSessionId] = useState(null) - const [subAgentStack, setSubAgentStack] = useState([]) - const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack]) - const [memories, setMemories] = useState([]) - const [skills, setSkills] = useState([]) - const [todos, setTodos] = useState([]) - const [highlightedMessageId, setHighlightedMessageId] = useState(null) - const [schedulerJobs, setSchedulerJobs] = useState([]) - const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') - const [schedulerView, setSchedulerView] = useState(null) - const [channels, setChannels] = useState([]) - const [selectedChannel, setSelectedChannel] = useState('websocket') - - // Track user message IDs already synced from backend to avoid duplicate updates - const syncedUserMessageIdsRef = useRef>(new Set()) - - // Message ID generator - const messageIdCounter = useRef(0) - const generateMessageId = () => { - messageIdCounter.current += 1 - return `msg_${Date.now()}_${messageIdCounter.current}` - } - - // Ref to track subAgentView and schedulerView for use in callbacks - const subAgentViewRef = useRef(null) - // 同步追踪 subAgentStack,供 exit/navigate 在回调中读取最新栈计算新栈顶 - // ref 必须在事件处理器返回前同步更新,避免 WebSocket 消息路由竞态 - const subAgentStackRef = useRef([]) - const schedulerViewRef = useRef(null) - const topicsRef = useRef([]) - const selectedTopicRef = useRef(null) - const pendingNewTopicRef = useRef(false) - - // Pending task navigations: tool_call_id -> task_id - // Used when task_started arrives before the tool_call is in the sub-agent view - const pendingTaskNavsRef = useRef>(new Map()) - - // Ref to send commands from within handleServerMessage (set by App.tsx) - const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null) - const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => { - sendMessageRef.current = fn - }, []) - - const isConnected = useMemo(() => connectionId !== null, [connectionId]) - const selectedSession = useMemo( - () => sessions.find(s => s.session_id === selectedSessionId) ?? null, - [sessions, selectedSessionId] - ) - const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]) - const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId]) - const isWritable = useMemo( - () => channels.find(c => c.id === selectedChannel)?.isWritable ?? false, - [channels, selectedChannel] - ) - - // Extract subagent_task_id from a message if present - const getSubagentTaskId = (message: WsOutbound): string | undefined => { - if (message.type === 'tool_call' || message.type === 'tool_result' - || message.type === 'tool_pending' || message.type === 'assistant_response') { - return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id - } - if (message.type === 'stream_delta' || message.type === 'stream_end') { - return (message as StreamDelta | StreamEnd).subagent_task_id - } - if (message.type === 'execution_completed' || message.type === 'error') { - return (message as ExecutionCompleted | WsError).subagent_task_id - } - return undefined - } - - // Convert a server message to ChatMessage (extracted from handleServerMessage logic) - const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => { - switch (message.type) { - case 'assistant_response': { - const msg = message as AssistantResponse - const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant' - return { - id: msg.id, - role: role as ChatMessage['role'], - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - attachments: msg.attachments, - subagentTaskId: msg.subagent_task_id, - reasoningContent: msg.reasoning_content, - } - } - case 'tool_call': { - const msg = message as ToolCall - return { - id: msg.id, - role: 'tool', - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_call', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - arguments: msg.arguments, - subagentTaskId: msg.subagent_task_id, - reasoningContent: msg.reasoning_content, - } - } - case 'tool_result': { - const msg = message as ToolResult - return { - id: msg.id, - role: 'tool', - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_result', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - subagentTaskId: msg.subagent_task_id, - durationMs: msg.duration_ms, - } - } - case 'tool_pending': { - const msg = message as ToolPending - return { - id: msg.id, - role: 'tool', - content: `${msg.content}\n\n${msg.resume_hint}`, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_pending', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - subagentTaskId: msg.subagent_task_id, - } - } - case 'stream_delta': { - const msg = message as StreamDelta - return { - id: msg.id, - role: 'assistant' as const, - content: msg.delta, - timestamp: Math.floor(Date.now() / 1000), - type: 'message' as const, - subagentTaskId: msg.subagent_task_id, - reasoningContent: msg.reasoning_delta, - } - } - case 'error': { - return { - id: generateMessageId(), - role: 'assistant', - content: `Error: ${message.message}`, - timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - } - } - default: - return null - } - } - - // Append a server message to the sub-agent view (with streaming delta accumulation) - const appendToSubAgentViewMessage = (message: WsOutbound) => { - // stream_delta: accumulate into existing message by ID, or create new - if (message.type === 'stream_delta') { - const msg = message as StreamDelta - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message') - if (existingIdx >= 0) { - const updated = [...top.messages] - const existing = updated[existingIdx] - updated[existingIdx] = { - ...existing, - content: existing.content + msg.delta, - reasoningContent: msg.reasoning_delta - ? (existing.reasoningContent || '') + msg.reasoning_delta - : existing.reasoningContent, - } - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: updated } - return newStack - } - const chatMsg = serverMessageToChatMessage(message) - if (!chatMsg) return prev - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] } - return newStack - }) - return - } - // stream_end: no-op, assistant_response will replace - if (message.type === 'stream_end') return - // execution_completed: 更新栈顶 status 为 completed - if (message.type === 'execution_completed') { - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, status: 'completed' } - return newStack - }) - return - } - // error: 更新栈顶 status 为 error,并追加错误消息 - if (message.type === 'error') { - const errMsg = message as WsError - const errorChatMsg: ChatMessage = { - id: generateMessageId(), - role: 'assistant', - content: `Error: ${errMsg.message}`, - timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - } - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, status: 'error', messages: [...top.messages, errorChatMsg] } - return newStack - }) - return - } - // Other messages: assistant_response replaces streamed message by ID - const chatMsg = serverMessageToChatMessage(message) - if (chatMsg) { - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - if (message.type === 'assistant_response') { - const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') - if (existingIdx >= 0) { - const updated = [...top.messages] - updated[existingIdx] = chatMsg - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: updated } - return newStack - } - } else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') { - // 按 id + type 去重,避免 load_task_messages 并发调用导致重复。 - // 注意:后端为同一次工具调用的 tool_call/tool_result/tool_pending 生成相同的 id(= tool_call_id), - // 仅按 id 去重会误伤 tool_result,导致工具永远停留在 calling 状态。 - const exists = top.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type) - if (exists) return prev - } - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] } - return newStack - }) - } - } - - // 追加消息到栈中非栈顶的匹配层(按 taskId 匹配),在 setSubAgentStack updater 内部完成所有判断 - const appendToSubAgentLayerMessage = (taskId: string, message: WsOutbound) => { - setSubAgentStack((prev) => { - const idx = prev.findIndex(v => v.taskId === taskId) - if (idx < 0) return prev - const layer = prev[idx] - - // execution_completed: 更新该层 status 为 completed - if (message.type === 'execution_completed') { - const newStack = [...prev] - newStack[idx] = { ...layer, status: 'completed' } - return newStack - } - // error: 更新该层 status 为 error,并追加错误消息 - if (message.type === 'error') { - const errMsg = message as WsError - const errorChatMsg: ChatMessage = { - id: generateMessageId(), - role: 'assistant', - content: `Error: ${errMsg.message}`, - timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - } - const newStack = [...prev] - newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] } - return newStack - } - // stream_delta: accumulate into existing message by ID, or create new - if (message.type === 'stream_delta') { - const msg = message as StreamDelta - const existingIdx = layer.messages.findIndex(m => m.id === msg.id && m.type === 'message') - if (existingIdx >= 0) { - const updated = [...layer.messages] - const existing = updated[existingIdx] - updated[existingIdx] = { - ...existing, - content: existing.content + msg.delta, - reasoningContent: msg.reasoning_delta - ? (existing.reasoningContent || '') + msg.reasoning_delta - : existing.reasoningContent, - } - const newStack = [...prev] - newStack[idx] = { ...layer, messages: updated } - return newStack - } - const chatMsg = serverMessageToChatMessage(message) - if (!chatMsg) return prev - const newStack = [...prev] - newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] } - return newStack - } - // stream_end: no-op - if (message.type === 'stream_end') return prev - // 其他消息:assistant_response 按 id 替换,tool_call/tool_result/tool_pending 按 id 去重 - const chatMsg = serverMessageToChatMessage(message) - if (!chatMsg) return prev - if (message.type === 'assistant_response') { - const existingIdx = layer.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') - if (existingIdx >= 0) { - const updated = [...layer.messages] - updated[existingIdx] = chatMsg - const newStack = [...prev] - newStack[idx] = { ...layer, messages: updated } - return newStack - } - } else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') { - // 按 id + type 去重(同上,三者共享 id,必须加 type 区分) - const exists = layer.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type) - if (exists) return prev - } - const newStack = [...prev] - newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] } - return newStack - }) - } - - // Sync backend user message ID to the last local user message, - // so that created_by_message_id (backend UUID) can match DOM data-message-id - const applyUserMessageId = useCallback((userMessageId: string) => { - if (syncedUserMessageIdsRef.current.has(userMessageId)) return - syncedUserMessageIdsRef.current.add(userMessageId) - setMessages(prev => { - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].role === 'user') { - const updated = [...prev] - updated[i] = { ...updated[i], id: userMessageId } - return updated - } - } - return prev - }) - }, []) + // 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList) + const conn = useConnection() + const sideData = useSideData() + const sessions = useSessions() + const topics = useTopics() + const messages = useMessages({ + selectedTopicRef: topics.selectedTopicRef, + topicsRef: topics.topicsRef, + bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger, + }) + const subAgent = useSubAgentView({ + sendCommand: conn.sendCommand, + requestSubAgentTodoList: sideData.requestSubAgentTodoList, + }) + const scheduler = useSchedulerView() + // ---- handleServerMessage: 纯路由分发(Tier 1 → Tier 2 → Tier 3) ---- + // 所有被调用的方法都是稳定引用(useState setter 或 useCallback([])), + // 因此空依赖数组安全,不会产生过期闭包。 const handleServerMessage = useCallback((message: WsOutbound) => { - // Route to scheduler job view if active - const currentSchedulerView = schedulerViewRef.current - if (currentSchedulerView) { - // Route chat messages to the scheduler view - const chatMsg = serverMessageToChatMessage(message) - if (chatMsg) { - setSchedulerView((prev) => - prev - ? { ...prev, messages: [...prev.messages, chatMsg] } - : prev - ) - return - } - // Non-chat messages (session_list, topic_list, etc.) fall through to main handler - } + // Tier 1: 调度器视图激活时,chat 消息路由到 schedulerView + if (scheduler.handleSchedulerMessage(message)) return - // Route to sub-agent view if active - const currentSubAgentView = subAgentViewRef.current - if (currentSubAgentView) { - if (message.type === 'task_messages_loaded') { - const msg = message as TaskMessagesLoaded - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - // 校验 task_id 匹配,避免快速切换视图时 A 的状态写到 B - if (msg.task_id !== top.taskId) return prev - const newStack = [...prev] - newStack[newStack.length - 1] = { - ...top, - subagentType: msg.subagent_type, - status: msg.status, - summary: msg.summary, - } - return newStack - }) - return - } + // Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack + if (subAgent.handleSubAgentMessage(message)) return - // When the sub-agent spawns a grandchild, set navigateToTaskId - // on the task tool_call so "查看实时进度" navigates correctly. - if (message.type === 'task_started') { - const msg = message as TaskStarted - if (msg.parent_task_id === currentSubAgentView.taskId) { - let matched = false - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - const updatedMessages = [...top.messages] - - // 优先:按 tool_call_id 精确匹配 - if (msg.tool_call_id) { - const idx = updatedMessages.findIndex(m => - m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task') - if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) { - updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id } - matched = true - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: updatedMessages } - return newStack - } - } - // 回退:backward-search (兼容无 tool_call_id 的旧版本) - for (let i = updatedMessages.length - 1; i >= 0; i--) { - const m = updatedMessages[i] - if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) { - updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id } - matched = true - break - } - } - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: updatedMessages } - return newStack - }) - if (!matched) { - // tool_call 尚未到达,存储 pending navigation 等后续 tool_call 到达时回填 - const key = msg.tool_call_id || `fallback:${msg.task_id}` - pendingTaskNavsRef.current.set(key, msg.task_id) - } - return - } - } - - // Only accept messages explicitly tagged with matching subagent_task_id. - // History messages are now tagged by the backend (send_task_messages), - // and live sub-agent messages are tagged by SubAgentEmitter. - const msgSubagentTaskId = getSubagentTaskId(message) - if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) { - appendToSubAgentViewMessage(message) - - // 检查 pending navigation:当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId - if (message.type === 'tool_call') { - const tc = message as ToolCall - if (tc.tool_name === 'task' && tc.tool_call_id) { - const key = tc.tool_call_id - const pendingTaskId = pendingTaskNavsRef.current.get(key) - if (pendingTaskId) { - pendingTaskNavsRef.current.delete(key) - setSubAgentStack((prev) => { - if (prev.length === 0) return prev - const top = prev[prev.length - 1] - const updatedMessages = [...top.messages] - const idx = updatedMessages.findIndex(m => - m.toolCallId === tc.tool_call_id && m.type === 'tool_call') - if (idx >= 0) { - updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId } - const newStack = [...prev] - newStack[newStack.length - 1] = { ...top, messages: updatedMessages } - return newStack - } - return prev - }) - } - } - } - - // 子代理 todo_write 完成后自动刷新待办列表 - if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') { - const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId) - sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(refreshCmd) }) - } - return - } - // 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId,命中则追加到对应层;未命中也丢弃 - if (msgSubagentTaskId) { - appendToSubAgentLayerMessage(msgSubagentTaskId, message) - return - } - } - - // In main view, skip sub-agent messages (they belong to sub-agent view). - const msgSubagentTaskId = getSubagentTaskId(message) - if (msgSubagentTaskId) { - return - } + // Tier 3: 主视图路由 + // 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中) + if (getSubagentTaskId(message)) return + // 3b: 非 chat 消息的 case 分发 switch (message.type) { - case 'session_established': { - const msg = message as SessionEstablished - setConnectionId(msg.session_id) - break - } + case 'session_established': + conn.setConnectionId(message.session_id) + return - case 'task_started': { - const msg = message as TaskStarted - // 只 backfill 当前话题的 task tool_call,避免跨话题串扰 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) { - break - } - // 孙智能体的 TaskStarted 不应 backfill 到主视图 - if (msg.parent_task_id) { - break - } - - // 设置 navigateToTaskId,让用户可以点击查看实时进度 - setMessages((prev) => { - // 优先:按 tool_call_id 精确匹配 - if (msg.tool_call_id) { - const idx = prev.findIndex(m => - m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task') - if (idx >= 0 && !prev[idx].navigateToTaskId) { - const updated = [...prev] - updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id } - return updated - } - } - // 回退:backward-search (兼容无 tool_call_id 的旧版本) - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) { - const updated = [...prev] - updated[i] = { ...updated[i], navigateToTaskId: msg.task_id } - return updated - } - } - return prev - }) - break - } - - case 'session_list': { - const msg = message as SessionList + case 'session_list': // 清空旧数据(切换通道时避免数据污染) - setTopics([]) - setSelectedTopic(null) - setMessages([]) - - // 存储全部 session - setSessions(msg.sessions) - + topics.setTopics([]) + topics.setSelectedTopic(null) + messages.setMessages([]) + sessions.setSessions(message.sessions) // 自动选中:优先保持当前选中,否则选第一个 - setSelectedSessionId(prev => { - if (prev && msg.sessions.some(s => s.session_id === prev)) { - return prev - } - return msg.sessions.length > 0 ? msg.sessions[0].session_id : null - }) + sessions.setSelectedSessionId(prev => + prev && message.sessions.some(s => s.session_id === prev) + ? prev + : message.sessions.length > 0 ? message.sessions[0].session_id : null + ) + messages.setIsLoading(false) + return - setIsLoading(false) - break - } - - case 'session_created': { - // 创建新 Topic 后更新列表 - setIsLoading(false) - break - } - - case 'session_loaded': { - setIsLoading(false) - break - } + case 'session_created': + case 'session_loaded': + messages.setIsLoading(false) + return case 'topic_list': { - const msg = message as TopicList - // 转换 topics 格式 - const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({ - id: t.topic_id, - session_id: t.session_id, - title: t.title, - description: t.description || undefined, - message_count: Number(t.message_count), - created_at: t.created_at, - updated_at: t.last_active_at, - })) - setTopics(newTopics) - - // 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) - if (pendingNewTopicRef.current) { - pendingNewTopicRef.current = false - if (newTopics.length > 0) { - setSelectedTopic(newTopics[0].id) - setMessages([]) - } - } - - setIsLoading(false) - break + const autoFocused = topics.handleTopicList(message) + if (autoFocused) messages.setMessages([]) + messages.setIsLoading(false) + return } - case 'stream_delta': { - const msg = message as StreamDelta - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - setMessages((prev) => { - const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message') - if (existingIdx >= 0) { - // 追加到已有消息 - const updated = [...prev] - const existing = updated[existingIdx] - updated[existingIdx] = { - ...existing, - content: existing.content + msg.delta, - reasoningContent: msg.reasoning_delta - ? (existing.reasoningContent || '') + msg.reasoning_delta - : existing.reasoningContent, - } - return updated - } - // 创建新消息 - return [ - ...prev, - { - id: msg.id, - role: 'assistant' as const, - content: msg.delta, - timestamp: Math.floor(Date.now() / 1000), - type: 'message' as const, - reasoningContent: msg.reasoning_delta, - }, - ] - }) - // 注意:stream_delta 期间不设置 isLoading=false,智能体仍在生成 - if (msg.user_message_id) applyUserMessageId(msg.user_message_id) - break - } + case 'scheduler_job_list': + scheduler.setSchedulerJobs(message.jobs) + return - case 'stream_end': { - // 流式结束,无需额外操作,后续 assistant_response 会替换完整内容 - break - } + case 'memory_list': + sideData.setMemories(message.memories) + return - case 'execution_completed': { - // 智能体执行完全结束(不再有后续工具调用或 LLM 迭代) - const msg = message as ExecutionCompleted - // 子智能体的完成事件不操作主视图(由子智能体视图分支处理或丢弃) - if (getSubagentTaskId(message)) return - // 按 topic_id 隔离:只处理当前话题的完成信号 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - setIsLoading(false) - break - } + case 'skill_list': + sideData.setSkills(message.skills) + return - case 'assistant_response': { - const msg = message as AssistantResponse - // 按 topic_id 隔离:如果消息属于其他话题则丢弃 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant' - setMessages((prev) => { - // 如果流式消息已存在(相同 id),替换它 - const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message') - const newMsg: ChatMessage = { - id: msg.id, - role, - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - attachments: msg.attachments, - reasoningContent: msg.reasoning_content, - } - if (existingIdx >= 0) { - const updated = [...prev] - updated[existingIdx] = newMsg - return updated - } - return [...prev, newMsg] - }) - // 注意:assistant_response 不设置 isLoading=false,智能体可能还会调用工具继续迭代 + case 'todo_list': + sideData.setTodos(message.todos) + return - // 当前话题无描述时,可能刚触发了异步生成,标记需要刷新 - const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current) - if (currentTopic && !currentTopic.description) { - setTopicRefreshTrigger(n => n + 1) - } - if (msg.user_message_id) applyUserMessageId(msg.user_message_id) - break - } + case 'channel_list': + sideData.setChannels(message.channels) + return - case 'tool_call': { - const msg = message as ToolCall - // 按 topic_id 隔离:如果消息属于其他话题则丢弃 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - setMessages((prev) => [ - ...prev, - { - id: msg.id, - role: 'tool', - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_call', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - arguments: msg.arguments, - subagentTaskId: msg.subagent_task_id, - reasoningContent: msg.reasoning_content, - }, - ]) - if (msg.user_message_id) applyUserMessageId(msg.user_message_id) - break - } - - case 'tool_result': { - const msg = message as ToolResult - // 按 topic_id 隔离:如果消息属于其他话题则丢弃 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - setMessages((prev) => [ - ...prev, - { - id: msg.id, - role: 'tool', - content: msg.content, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_result', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - subagentTaskId: msg.subagent_task_id, - durationMs: msg.duration_ms, - }, - ]) - break - } - - case 'tool_pending': { - const msg = message as ToolPending - // 按 topic_id 隔离:如果消息属于其他话题则丢弃 - if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return - setMessages((prev) => [ - ...prev, - { - id: msg.id, - role: 'tool', - content: `${msg.content}\n\n${msg.resume_hint}`, - timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), - type: 'tool_pending', - toolName: msg.tool_name, - toolCallId: msg.tool_call_id, - }, - ]) - break - } - - case 'execution_cancelled': { - setMessages((prev) => [ - ...prev, - { - id: generateMessageId(), - role: 'assistant', - content: (message as { type: 'execution_cancelled'; message: string }).message, - timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - }, - ]) - setIsLoading(false) - break - } - - case 'error': { - // 子智能体的错误事件不操作主视图(由子智能体视图分支处理或丢弃) - if (getSubagentTaskId(message)) return - setMessages((prev) => [ - ...prev, - { - id: generateMessageId(), - role: 'assistant', - content: `Error: ${message.message}`, - timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), - type: 'message', - }, - ]) - setIsLoading(false) - break - } - - case 'scheduler_job_list': { - const msg = message as SchedulerJobList - setSchedulerJobs(msg.jobs) - break - } - case 'memory_list': { - const msg = message as MemoryList - setMemories(msg.memories) - break - } - case 'skill_list': { - const msg = message as SkillList - setSkills(msg.skills) - break - } - case 'todo_list': { - const msg = message as TodoList - setTodos(msg.todos) - break - } - - case 'channel_list': { - const msg = message as ChannelList - setChannels(msg.channels) - break - } case 'pong': - // 忽略这些消息 - break - } + return - // 主视图 todo_write 完成后自动刷新待办列表 - if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') { - const refreshCmd = subAgentViewRef.current?.taskId - ? requestSubAgentTodoList(subAgentViewRef.current.taskId) - : requestTodoList() - sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(refreshCmd) }) + default: + // 3c: chat 类消息(task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled) + if (messages.handleMainViewMessage(message)) { + // 主视图 todo_write 完成后自动刷新待办列表 + // 注意:topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true, + // 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数, + // 因此这里需要再次检查 topic_id 以保持行为等价。 + if (message.type === 'tool_result' && message.tool_name === 'todo_write' + && (!message.topic_id || message.topic_id === topics.selectedTopicRef.current)) { + const cmd = subAgent.subAgentViewRef.current?.taskId + ? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId) + : sideData.requestTodoList() + conn.sendCommand(cmd) + } + } + return } }, []) - const handleMessage = useCallback((content: string, attachments?: Attachment[]) => { - setMessages((prev) => [ - ...prev, - { - id: generateMessageId(), - role: 'user', - content, - timestamp: Math.floor(Date.now() / 1000), - type: 'message', - attachments: attachments || [], - }, - ]) - setIsLoading(true) - }, []) - + // ---- handleCommand: 根据命令类型设置 loading 状态 ---- const handleCommand = useCallback((command: Command) => { switch (command.type) { case 'create_session': @@ -982,309 +233,128 @@ export function useChat(): UseChatReturn { case 'list_sessions_by_channel': case 'delete_topic': case 'list_topics': - setIsLoading(true) + messages.setIsLoading(true) break } }, []) - const clearMessages = useCallback(() => { - setMessages([]) - }, []) - - // Topic 操作方法 + // ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- const selectTopic = useCallback((topicId: string) => { - setSelectedTopic(topicId) - setMessages([]) - subAgentViewRef.current = null - subAgentStackRef.current = [] - setSubAgentStack([]) - }, []) - - const createTopic = useCallback((title?: string): Command => { - pendingNewTopicRef.current = true - return { - type: 'create_session', - title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`, - } - }, []) - - const switchTopic = useCallback((topicId: string): Command => { - return { - type: 'switch_topic', - topic_id: topicId, - } - }, []) - - const deleteTopic = useCallback((topicId: string): Command => { - return { - type: 'delete_topic', - topic_id: topicId, - } - }, []) - - // 初始化方法 - const requestSessionList = useCallback((): Command => { - return { - type: 'list_sessions_by_channel', - channel_name: selectedChannel, - include_archived: false, - } - }, [selectedChannel]) - - const requestChannelList = useCallback((): Command => { - return { type: 'list_channels' } + topics.setSelectedTopic(topicId) + messages.setMessages([]) + // ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致) + subAgent.subAgentViewRef.current = null + subAgent.subAgentStackRef.current = [] + subAgent.setSubAgentStack([]) }, []) + // ---- selectChannel: 切换通道,清空全部状态 ---- const selectChannel = useCallback((channelId: string) => { - if (channelId === selectedChannel) return - setSelectedChannel(channelId) - setSessions([]) - setSelectedSessionId(null) - setTopics([]) - setSelectedTopic(null) - setMessages([]) - subAgentViewRef.current = null - subAgentStackRef.current = [] - setSubAgentStack([]) - setIsLoading(true) - }, [selectedChannel]) + if (channelId === sideData.selectedChannel) return + sideData.setSelectedChannel(channelId) + sessions.setSessions([]) + sessions.setSelectedSessionId(null) + topics.setTopics([]) + topics.setSelectedTopic(null) + messages.setMessages([]) + subAgent.subAgentViewRef.current = null + subAgent.subAgentStackRef.current = [] + subAgent.setSubAgentStack([]) + messages.setIsLoading(true) + }, [sideData.selectedChannel]) + // ---- selectSession: 切换会话,清空 topics/messages/subAgent ---- const selectSession = useCallback((sessionId: string) => { - if (sessionId === selectedSessionId) return - setSelectedSessionId(sessionId) - setTopics([]) - setSelectedTopic(null) - setMessages([]) - subAgentViewRef.current = null - subAgentStackRef.current = [] - setSubAgentStack([]) - setIsLoading(true) - }, [selectedSessionId]) + if (sessionId === sessions.selectedSessionId) return + sessions.setSelectedSessionId(sessionId) + topics.setTopics([]) + topics.setSelectedTopic(null) + messages.setMessages([]) + subAgent.subAgentViewRef.current = null + subAgent.subAgentStackRef.current = [] + subAgent.setSubAgentStack([]) + messages.setIsLoading(true) + }, [sessions.selectedSessionId]) + + // ---- 委托方法 ---- + const requestSessionList = useCallback((): Command => { + return sessions.requestSessionList(sideData.selectedChannel) + }, [sideData.selectedChannel]) const requestTopicList = useCallback((): Command | null => { - if (!sessionId) return null - return { - type: 'list_topics', - session_id: sessionId, - } - }, [sessionId]) + return topics.requestTopicList(sessions.sessionId) + }, [sessions.sessionId]) - // Keep refs in sync with state (兜底,确保 ref 与 state 最终一致) - useEffect(() => { - subAgentViewRef.current = subAgentView - }, [subAgentView]) - - useEffect(() => { - subAgentStackRef.current = subAgentStack - }, [subAgentStack]) - - useEffect(() => { - schedulerViewRef.current = schedulerView - }, [schedulerView]) - - useEffect(() => { - topicsRef.current = topics - }, [topics]) - - useEffect(() => { - selectedTopicRef.current = selectedTopic - }, [selectedTopic]) - - const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => { - const newView: SubAgentView = { - taskId, - description, - subagentType: subagentType || '', - status: 'loading', - messages: [], - } - // 同步设置 ref,消除竞态窗口:updater 在 React render 阶段才执行, - // 期间 WebSocket 消息会读到过时的 ref。ref 必须在事件处理器返回前同步更新。 - subAgentViewRef.current = newView - subAgentStackRef.current = [...subAgentStackRef.current, newView] - // updater 变纯函数,不再有副作用 - setSubAgentStack((prev) => [...prev, newView]) - return { - type: 'load_task_messages', - task_id: taskId, - } + const requestChannelList = useCallback((): Command => { + return sideData.requestChannelList() }, []) - const exitSubAgentView = useCallback((): Command | null => { - // 从 ref 读取最新栈(useCallback 依赖为空,闭包中的 subAgentStack 是旧值) - const current = subAgentStackRef.current - if (current.length <= 1) { - subAgentViewRef.current = null - subAgentStackRef.current = [] - setSubAgentStack([]) - return null - } - const newStack = current.slice(0, -1) - const newTop = newStack[newStack.length - 1] - // 同步设置 ref,消除竞态窗口 - subAgentViewRef.current = newTop - // 清空目标层 messages + status 置 loading,准备重新拉取 - const clearedStack = [...newStack] - clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } - subAgentStackRef.current = clearedStack - setSubAgentStack(clearedStack) - return { type: 'load_task_messages', task_id: newTop.taskId } - }, []) - - const navigateToSubAgentLevel = useCallback((index: number): Command | null => { - const current = subAgentStackRef.current - if (index < 0) { - // -1 means go back to main session (clear all) - subAgentViewRef.current = null - subAgentStackRef.current = [] - setSubAgentStack([]) - return null - } - if (index >= current.length) return null - const newStack = current.slice(0, index + 1) - const newTop = newStack[newStack.length - 1] - // 同步设置 ref,消除竞态窗口 - subAgentViewRef.current = newTop - const clearedStack = [...newStack] - clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } - subAgentStackRef.current = clearedStack - setSubAgentStack(clearedStack) - return { type: 'load_task_messages', task_id: newTop.taskId } - }, []) - - // 记忆方法 - const requestMemoryList = useCallback((): Command => { - return { type: 'list_memories' } - }, []) - - const createMemory = useCallback((namespace: string, key: string, content: string): Command => { - return { type: 'create_memory', namespace, key, content } - }, []) - - const updateMemory = useCallback((id: string, content: string): Command => { - return { type: 'update_memory', id, content } - }, []) - - const deleteMemory = useCallback((id: string): Command => { - return { type: 'delete_memory', id } - }, []) - - const requestSkillList = useCallback((): Command => { - return { type: 'list_skills' } - }, []) - - const requestTodoList = useCallback((): Command => { - return { type: 'list_todos' } - }, []) - - const requestSubAgentTodoList = useCallback((subTaskId: string): Command => { - return { type: 'list_todos', task_id: subTaskId } - }, []) - - // 定时任务方法 - const requestSchedulerJobList = useCallback((): Command => { - return { type: 'list_scheduler_jobs' } - }, []) - - const enterSchedulerJobView = useCallback( - (lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => { - const newView: SchedulerJobView = { - jobId, - description, - channel: lookup.channel, - chatId: lookup.chat_id, - messages: [], - } - schedulerViewRef.current = newView - setSchedulerView(newView) - return { - type: 'load_chat_messages', - channel: lookup.channel, - chat_id: lookup.chat_id, - } - }, - [] - ) - - const exitSchedulerJobView = useCallback(() => { - schedulerViewRef.current = null - setSchedulerView(null) - }, []) - - const handleStop = useCallback((): Command => { - return { type: 'stop_execution' } - }, []) - - // Memoize messages: sub-agent view > scheduler view > main + // ---- 派生状态 ---- const resolvedMessages = useMemo(() => { - if (subAgentView) { - return subAgentView.messages - } - if (schedulerView) { - return schedulerView.messages - } - return messages - }, [subAgentView, schedulerView, messages]) + if (subAgent.subAgentView) return subAgent.subAgentView.messages + if (scheduler.schedulerView) return scheduler.schedulerView.messages + return messages.messages + }, [subAgent.subAgentView, scheduler.schedulerView, messages.messages]) - // 只读状态由当前通道决定 - const isReadOnly = !isWritable + const isReadOnly = !sideData.isWritable + // ---- 组装返回对象 ---- return { - connectionId, - isConnected, - sessions, - selectedSessionId, - session: selectedSession, - sessionId, - chatId, - topics, - selectedTopic, + connectionId: conn.connectionId, + isConnected: conn.isConnected, + sessions: sessions.sessions, + selectedSessionId: sessions.selectedSessionId, + session: sessions.session, + sessionId: sessions.sessionId, + chatId: sessions.chatId, + topics: topics.topics, + selectedTopic: topics.selectedTopic, messages: resolvedMessages, - isLoading, + isLoading: messages.isLoading, isReadOnly, - isWritable, - channels, - selectedChannel, - subAgentView, - subAgentStack, - handleMessage, + isWritable: sideData.isWritable, + channels: sideData.channels, + selectedChannel: sideData.selectedChannel, + subAgentView: subAgent.subAgentView, + subAgentStack: subAgent.subAgentStack, + handleMessage: messages.handleMessage, handleCommand, - clearMessages, + clearMessages: messages.clearMessages, handleServerMessage, - setSendMessage, + setSendMessage: conn.setSendMessage, selectTopic, - createTopic, - switchTopic, - deleteTopic, + createTopic: topics.createTopic, + switchTopic: topics.switchTopic, + deleteTopic: topics.deleteTopic, requestSessionList, requestTopicList, - topicRefreshTrigger, + topicRefreshTrigger: topics.topicRefreshTrigger, requestChannelList, selectChannel, selectSession, - enterSubAgentView, - exitSubAgentView, - navigateToSubAgentLevel, - memories, - requestMemoryList, - createMemory, - updateMemory, - deleteMemory, - skills, - requestSkillList, - todos, - setTodos, - requestTodoList, - requestSubAgentTodoList, - highlightedMessageId, - setHighlightedMessageId, - schedulerJobs, - sidebarTab, - setSidebarTab, - requestSchedulerJobList, - schedulerView, - enterSchedulerJobView, - exitSchedulerJobView, - handleStop, + enterSubAgentView: subAgent.enterSubAgentView, + exitSubAgentView: subAgent.exitSubAgentView, + navigateToSubAgentLevel: subAgent.navigateToSubAgentLevel, + memories: sideData.memories, + requestMemoryList: sideData.requestMemoryList, + createMemory: sideData.createMemory, + updateMemory: sideData.updateMemory, + deleteMemory: sideData.deleteMemory, + skills: sideData.skills, + requestSkillList: sideData.requestSkillList, + todos: sideData.todos, + setTodos: sideData.setTodos, + requestTodoList: sideData.requestTodoList, + requestSubAgentTodoList: sideData.requestSubAgentTodoList, + highlightedMessageId: sideData.highlightedMessageId, + setHighlightedMessageId: sideData.setHighlightedMessageId, + schedulerJobs: scheduler.schedulerJobs, + sidebarTab: scheduler.sidebarTab, + setSidebarTab: scheduler.setSidebarTab, + requestSchedulerJobList: scheduler.requestSchedulerJobList, + schedulerView: scheduler.schedulerView, + enterSchedulerJobView: scheduler.enterSchedulerJobView, + exitSchedulerJobView: scheduler.exitSchedulerJobView, + handleStop: messages.handleStop, } }