import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import type { Command, ChatMessage, Topic, WsOutbound, AssistantResponse, ToolCall, ToolResult, ToolPending, SessionEstablished, SessionList, SessionSummary, TopicList, TopicSummary, TaskMessagesLoaded, TaskStarted, Attachment, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, Channel, ChannelList, } 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 // 方法 handleMessage: (content: string, attachments?: Attachment[]) => void handleCommand: (command: Command) => void clearMessages: () => void handleServerMessage: (message: WsOutbound) => 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) => Command exitSubAgentView: () => void // 定时任务状态 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 [subAgentView, setSubAgentView] = 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') // 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 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 } 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, } } 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, } } 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 '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 const appendToSubAgentViewMessage = (message: WsOutbound) => { const chatMsg = serverMessageToChatMessage(message) if (chatMsg) { setSubAgentView((prev) => prev ? { ...prev, messages: [...prev.messages, chatMsg] } : 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 setSubAgentView((prev) => prev ? { ...prev, subagentType: msg.subagent_type, status: msg.status, summary: msg.summary, } : prev ) 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) return } } // In main view, skip sub-agent messages (they belong to sub-agent view). // But use the task_id to associate with the running task tool card. const msgSubagentTaskId = getSubagentTaskId(message) if (msgSubagentTaskId) { setMessages((prev) => { for (let i = prev.length - 1; i >= 0; i--) { if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) { const updated = [...prev] updated[i] = { ...updated[i], subagentTaskId: msgSubagentTaskId } return updated } } return prev }) 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 // 立即更新对应的 task tool_call,让用户可以点击查看实时进度 setMessages((prev) => { for (let i = prev.length - 1; i >= 0; i--) { if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) { const updated = [...prev] updated[i] = { ...updated[i], subagentTaskId: msg.task_id } return updated } } 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) // 默认选中第一个 Topic(如果没有选中) setIsLoading(false) 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) => [ ...prev, { id: msg.id, role, content: msg.content, timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000), type: 'message', attachments: msg.attachments, }, ]) setIsLoading(false) // 当前话题无描述时,可能刚触发了异步生成,标记需要刷新 const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current) if (currentTopic && !currentTopic.description) { setTopicRefreshTrigger(n => n + 1) } 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, }, ]) 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 'channel_list': { const msg = message as ChannelList console.log('Channel list received:', msg) setChannels(msg.channels) break } case 'pong': // 忽略这些消息 break } }, []) 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([]) }, []) const createTopic = useCallback((title?: string): Command => { 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([]) setIsLoading(true) }, [selectedChannel]) const selectSession = useCallback((sessionId: string) => { if (sessionId === selectedSessionId) return setSelectedSessionId(sessionId) setTopics([]) setSelectedTopic(null) setMessages([]) 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): Command => { const newView: SubAgentView = { taskId, description, subagentType: '', status: 'loading', messages: [], } // Sync ref immediately so WebSocket response routing works correctly subAgentViewRef.current = newView setSubAgentView(newView) return { type: 'load_task_messages', task_id: taskId, } }, []) const exitSubAgentView = useCallback(() => { subAgentViewRef.current = null setSubAgentView(null) }, []) // 定时任务方法 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, handleMessage, handleCommand, clearMessages, handleServerMessage, selectTopic, createTopic, switchTopic, deleteTopic, requestSessionList, requestTopicList, topicRefreshTrigger, requestChannelList, selectChannel, selectSession, enterSubAgentView, exitSubAgentView, schedulerJobs, sidebarTab, setSidebarTab, requestSchedulerJobList, schedulerView, enterSchedulerJobView, exitSchedulerJobView, handleStop, } }