import { useState, useCallback, useEffect, useRef, useMemo, type Dispatch, type SetStateAction } from 'react' import type { Command, ChatMessage, Topic, WsOutbound, AssistantResponse, ToolCall, ToolResult, ToolPending, SessionEstablished, SessionList, SessionSummary, TopicList, TopicSummary, TaskMessagesLoaded, TaskStarted, Attachment, MemorySummary, MemoryList, SkillSummary, SkillList, TodoItemSummary, TodoList, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, Channel, ChannelList, StreamDelta, StreamEnd, WsInbound, } from '../types/protocol' // 简化后的层级状态 interface UseChatReturn { // 连接状态 connectionId: string | null isConnected: boolean // 简化的层级状态 sessions: SessionSummary[] selectedSessionId: string | null session: SessionSummary | null sessionId: string | null chatId: string topics: Topic[] selectedTopic: string | null // 消息 messages: ChatMessage[] isLoading: boolean // 通道状态 channels: Channel[] selectedChannel: string isWritable: boolean // 是否只读 isReadOnly: boolean // 子智能体视图(栈结构,支持面包屑导航) subAgentView: SubAgentView | null subAgentStack: SubAgentView[] // 方法 handleMessage: (content: string, attachments?: Attachment[]) => void handleCommand: (command: Command) => void clearMessages: () => void handleServerMessage: (message: WsOutbound) => void setSendMessage: (fn: (msg: WsInbound) => boolean) => void // Topic 方法 selectTopic: (topicId: string) => void createTopic: (title?: string) => Command switchTopic: (topicId: string) => Command deleteTopic: (topicId: string) => Command // 初始化方法 requestSessionList: () => Command requestTopicList: () => Command | null topicRefreshTrigger: number requestChannelList: () => Command selectChannel: (channelId: string) => void selectSession: (sessionId: string) => void // 子智能体导航方法 enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command exitSubAgentView: () => void navigateToSubAgentLevel: (index: number) => void // 记忆状态 memories: MemorySummary[] requestMemoryList: () => Command createMemory: (namespace: string, key: string, content: string) => Command updateMemory: (id: string, content: string) => Command deleteMemory: (id: string) => Command // 技能状态 skills: SkillSummary[] requestSkillList: () => Command // Todo 状态 todos: TodoItemSummary[] setTodos: Dispatch> requestTodoList: () => Command requestSubAgentTodoList: (subTaskId: string) => Command // 高亮消息 ID(点击待办后滚动到对应消息) highlightedMessageId: string | null setHighlightedMessageId: Dispatch> // 定时任务状态 schedulerJobs: SchedulerJobSummary[] sidebarTab: 'topics' | 'scheduler' setSidebarTab: (tab: 'topics' | 'scheduler') => void requestSchedulerJobList: () => Command // 定时任务执行对话查看 schedulerView: SchedulerJobView | null enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command exitSchedulerJobView: () => void // 停止当前 Agent 执行 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) 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 } 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: (message as any).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: (message as any).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: (message as any).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: (message as any).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 as any).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 // 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 } } const newStack = [...prev] newStack[newStack.length - 1] = { ...top, messages: [...top.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 }) }, []) const handleServerMessage = useCallback((message: WsOutbound) => { console.log('Received message:', message) // 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 } // 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] const newStack = [...prev] newStack[newStack.length - 1] = { ...top, subagentType: msg.subagent_type, status: msg.status, summary: msg.summary, } return newStack }) 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 } // 丢弃其他子智能体的消息,避免 fall through 到主消息处理 if (msgSubagentTaskId) { return } } // In main view, skip sub-agent messages (they belong to sub-agent view). const msgSubagentTaskId = getSubagentTaskId(message) if (msgSubagentTaskId) { return } switch (message.type) { case 'session_established': { const msg = message as SessionEstablished setConnectionId(msg.session_id) console.log('Connection established:', msg.session_id) break } case 'task_started': { const msg = message as TaskStarted console.log('[useChat] task_started received:', { task_id: msg.task_id, topic_id: msg.topic_id, parent_task_id: msg.parent_task_id, selectedTopic: selectedTopicRef.current }) // 只 backfill 当前话题的 task tool_call,避免跨话题串扰 if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) { console.log('[useChat] task_started filtered by topic_id') break } // 孙智能体的 TaskStarted 不应 backfill 到主视图 if (msg.parent_task_id) { console.log('[useChat] task_started filtered by parent_task_id') break } // 设置 navigateToTaskId,让用户可以点击查看实时进度 setMessages((prev) => { console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length, 'tool_call_id:', msg.tool_call_id) // 优先:按 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) { console.log('[useChat] task_started EXACT MATCH at index', idx, 'task_id:', msg.task_id) 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) { console.log('[useChat] task_started BACKWARD MATCH at index', i, 'task_id:', msg.task_id) const updated = [...prev] updated[i] = { ...updated[i], navigateToTaskId: msg.task_id } return updated } } console.log('[useChat] task_started NO matching task tool_call found in messages') return prev }) break } case 'session_list': { const msg = message as SessionList console.log('Session list received:', msg) // 清空旧数据(切换通道时避免数据污染) setTopics([]) setSelectedTopic(null) setMessages([]) // 存储全部 session setSessions(msg.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 }) setIsLoading(false) break } case 'session_created': { // 创建新 Topic 后更新列表 setIsLoading(false) break } case 'session_loaded': { setIsLoading(false) break } case 'topic_list': { const msg = message as TopicList console.log('Topic list received:', msg) // 转换 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 } 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, }, ] }) setIsLoading(false) if (msg.user_message_id) applyUserMessageId(msg.user_message_id) break } case 'stream_end': { // 流式结束,无需额外操作,后续 assistant_response 会替换完整内容 break } 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: (message as any).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] }) setIsLoading(false) // 当前话题无描述时,可能刚触发了异步生成,标记需要刷新 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 '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: (message as any).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: (message as any).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: (message as any).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 as any).timestamp ?? Math.floor(Date.now() / 1000), type: 'message', }, ]) setIsLoading(false) break } case 'error': { setMessages((prev) => [ ...prev, { id: generateMessageId(), role: 'assistant', content: `Error: ${message.message}`, timestamp: (message as any).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 console.log('Channel list received:', msg) setChannels(msg.channels) break } case 'pong': // 忽略这些消息 break } // 主视图 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) }) } }, []) 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) }, []) const handleCommand = useCallback((command: Command) => { switch (command.type) { case 'create_session': case 'switch_topic': case 'load_topic': case 'list_sessions': case 'list_sessions_by_channel': case 'delete_topic': case 'list_topics': setIsLoading(true) break } }, []) const clearMessages = useCallback(() => { setMessages([]) }, []) // Topic 操作方法 const selectTopic = useCallback((topicId: string) => { setSelectedTopic(topicId) setMessages([]) 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' } }, []) const selectChannel = useCallback((channelId: string) => { if (channelId === selectedChannel) return setSelectedChannel(channelId) setSessions([]) setSelectedSessionId(null) setTopics([]) setSelectedTopic(null) setMessages([]) setSubAgentStack([]) setIsLoading(true) }, [selectedChannel]) const selectSession = useCallback((sessionId: string) => { if (sessionId === selectedSessionId) return setSelectedSessionId(sessionId) setTopics([]) setSelectedTopic(null) setMessages([]) setSubAgentStack([]) setIsLoading(true) }, [selectedSessionId]) const requestTopicList = useCallback((): Command | null => { if (!sessionId) return null return { type: 'list_topics', session_id: sessionId, } }, [sessionId]) // Keep refs in sync with state useEffect(() => { subAgentViewRef.current = subAgentView }, [subAgentView]) 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: [], } setSubAgentStack((prev) => { const newStack = [...prev, newView] // Sync ref immediately so WebSocket response routing works correctly subAgentViewRef.current = newView return newStack }) return { type: 'load_task_messages', task_id: taskId, } }, []) const exitSubAgentView = useCallback(() => { setSubAgentStack((prev) => { if (prev.length <= 1) { subAgentViewRef.current = null return [] } const newStack = prev.slice(0, -1) subAgentViewRef.current = newStack[newStack.length - 1] return newStack }) }, []) const navigateToSubAgentLevel = useCallback((index: number) => { setSubAgentStack((prev) => { if (index < 0) { // -1 means go back to main session (clear all) subAgentViewRef.current = null return [] } if (index >= prev.length) return prev const newStack = prev.slice(0, index + 1) subAgentViewRef.current = newStack.length > 0 ? newStack[newStack.length - 1] : null return newStack }) }, []) // 记忆方法 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]) // 只读状态由当前通道决定 const isReadOnly = !isWritable return { connectionId, isConnected, sessions, selectedSessionId, session: selectedSession, sessionId, chatId, topics, selectedTopic, messages: resolvedMessages, isLoading, isReadOnly, isWritable, channels, selectedChannel, subAgentView, subAgentStack, handleMessage, handleCommand, clearMessages, handleServerMessage, setSendMessage, selectTopic, createTopic, switchTopic, deleteTopic, requestSessionList, requestTopicList, 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, } }