From 7038d58207eecec8e0f8ac1a162f9d38ef2a4ee2 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Wed, 8 Jul 2026 11:24:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=20useChat=20?= =?UTF-8?q?=E7=9A=84=E9=A2=86=E5=9F=9F=E5=AD=90=20hook=20=E4=B8=8E=20messa?= =?UTF-8?q?geMappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原上帝 Hook 的状态域拆分为 7 个职责单一的子 hook:useConnection/useSessions/useTopics/useMessages/useSubAgentView/useSchedulerView/useSideData,并将消息映射纯函数提取到 messageMappers.ts。各子 hook 自管 ref 同步 effect,跨域依赖通过 options 注入,为组合根重写奠定基础。 --- web/src/hooks/chat/messageMappers.ts | 125 ++++++++ web/src/hooks/chat/types.ts | 22 ++ web/src/hooks/chat/useConnection.ts | 34 +++ web/src/hooks/chat/useMessages.ts | 297 +++++++++++++++++++ web/src/hooks/chat/useSchedulerView.ts | 98 +++++++ web/src/hooks/chat/useSessions.ts | 56 ++++ web/src/hooks/chat/useSideData.ts | 104 +++++++ web/src/hooks/chat/useSubAgentView.ts | 387 +++++++++++++++++++++++++ web/src/hooks/chat/useTopics.ts | 104 +++++++ 9 files changed, 1227 insertions(+) create mode 100644 web/src/hooks/chat/messageMappers.ts create mode 100644 web/src/hooks/chat/types.ts create mode 100644 web/src/hooks/chat/useConnection.ts create mode 100644 web/src/hooks/chat/useMessages.ts create mode 100644 web/src/hooks/chat/useSchedulerView.ts create mode 100644 web/src/hooks/chat/useSessions.ts create mode 100644 web/src/hooks/chat/useSideData.ts create mode 100644 web/src/hooks/chat/useSubAgentView.ts create mode 100644 web/src/hooks/chat/useTopics.ts diff --git a/web/src/hooks/chat/messageMappers.ts b/web/src/hooks/chat/messageMappers.ts new file mode 100644 index 0000000..c9b4f3a --- /dev/null +++ b/web/src/hooks/chat/messageMappers.ts @@ -0,0 +1,125 @@ +import type { + ChatMessage, + WsOutbound, + AssistantResponse, + ToolCall, + ToolResult, + ToolPending, + StreamDelta, + StreamEnd, + ExecutionCompleted, + WsError, +} from '../../types/protocol' + +// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref) +let messageIdCounter = 0 + +export function generateMessageId(): string { + messageIdCounter += 1 + return `msg_${Date.now()}_${messageIdCounter}` +} + +/** 重置计数器(仅测试使用) */ +export function _resetMessageIdCounterForTests(): void { + messageIdCounter = 0 +} + +/** 从服务端消息中提取 subagent_task_id(如果该消息类型携带此字段) */ +export function 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 +} + +/** 将服务端消息转换为 UI ChatMessage;不兼容的消息类型返回 null */ +export function 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 + } +} diff --git a/web/src/hooks/chat/types.ts b/web/src/hooks/chat/types.ts new file mode 100644 index 0000000..4600287 --- /dev/null +++ b/web/src/hooks/chat/types.ts @@ -0,0 +1,22 @@ +import type { ChatMessage } from '../../types/protocol' + +/** 子智能体视图(栈中的一层) */ +export interface SubAgentView { + taskId: string + description: string + subagentType: string + status: string + summary?: string + messages: ChatMessage[] +} + +/** 定时任务执行对话查看视图 */ +export interface SchedulerJobView { + jobId: string + description: string + channel: string + chatId: string + messages: ChatMessage[] +} + +export const DEFAULT_CHAT_ID = 'default' diff --git a/web/src/hooks/chat/useConnection.ts b/web/src/hooks/chat/useConnection.ts new file mode 100644 index 0000000..43b7aae --- /dev/null +++ b/web/src/hooks/chat/useConnection.ts @@ -0,0 +1,34 @@ +import { useState, useCallback, useMemo, useRef } from 'react' +import type { WsInbound, Command } from '../../types/protocol' + +export interface UseConnectionReturn { + connectionId: string | null + isConnected: boolean + setConnectionId: (id: string | null) => void + setSendMessage: (fn: (msg: WsInbound) => boolean) => void + /** 发送命令到后端(封装 command payload 序列化) */ + sendCommand: (cmd: Command) => void +} + +export function useConnection(): UseConnectionReturn { + const [connectionId, setConnectionId] = useState(null) + const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null) + + const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => { + sendMessageRef.current = fn + }, []) + + const sendCommand = useCallback((cmd: Command) => { + sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) }) + }, []) + + const isConnected = useMemo(() => connectionId !== null, [connectionId]) + + return { + connectionId, + isConnected, + setConnectionId, + setSendMessage, + sendCommand, + } +} diff --git a/web/src/hooks/chat/useMessages.ts b/web/src/hooks/chat/useMessages.ts new file mode 100644 index 0000000..5e976ad --- /dev/null +++ b/web/src/hooks/chat/useMessages.ts @@ -0,0 +1,297 @@ +import { useState, useCallback, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' +import type { + ChatMessage, + WsOutbound, + Topic, + StreamDelta, + AssistantResponse, + ToolCall, + ToolResult, + ToolPending, + ExecutionCompleted, + WsError, + TaskStarted, + Attachment, + Command, +} from '../../types/protocol' +import { generateMessageId, getSubagentTaskId } from './messageMappers' + +interface UseMessagesOptions { + selectedTopicRef: MutableRefObject + topicsRef: MutableRefObject + bumpTopicRefreshTrigger: () => void +} + +export interface UseMessagesReturn { + messages: ChatMessage[] + setMessages: Dispatch> + isLoading: boolean + setIsLoading: Dispatch> + handleMessage: (content: string, attachments?: Attachment[]) => void + clearMessages: () => void + handleStop: () => Command + /** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */ + handleMainViewMessage: (message: WsOutbound) => boolean +} + +export function useMessages(options: UseMessagesOptions): UseMessagesReturn { + const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const syncedUserMessageIdsRef = useRef>(new Set()) + + 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 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 clearMessages = useCallback(() => { + setMessages([]) + }, []) + + const handleStop = useCallback((): Command => { + return { type: 'stop_execution' } + }, []) + + const handleMainViewMessage = useCallback((message: WsOutbound): boolean => { + switch (message.type) { + case 'task_started': { + const msg = message as TaskStarted + // 只 backfill 当前话题的 task tool_call,避免跨话题串扰 + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + // 孙智能体的 TaskStarted 不应 backfill 到主视图 + if (msg.parent_task_id) return true + + 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 + }) + return true + } + + case 'stream_delta': { + const msg = message as StreamDelta + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + 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, + }, + ] + }) + if (msg.user_message_id) applyUserMessageId(msg.user_message_id) + return true + } + + case 'stream_end': { + return true + } + + case 'execution_completed': { + const msg = message as ExecutionCompleted + if (getSubagentTaskId(message)) return true + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + setIsLoading(false) + return true + } + + case 'assistant_response': { + const msg = message as AssistantResponse + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant' + setMessages((prev) => { + 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] + }) + // 当前话题无描述时,可能刚触发了异步生成,标记需要刷新 + const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current) + if (currentTopic && !currentTopic.description) { + bumpTopicRefreshTrigger() + } + if (msg.user_message_id) applyUserMessageId(msg.user_message_id) + return true + } + + case 'tool_call': { + const msg = message as ToolCall + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + 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) + return true + } + + case 'tool_result': { + const msg = message as ToolResult + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + 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, + }, + ]) + return true + } + + case 'tool_pending': { + const msg = message as ToolPending + if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true + 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, + }, + ]) + return true + } + + 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) + return true + } + + case 'error': { + if (getSubagentTaskId(message)) return true + setMessages((prev) => [ + ...prev, + { + id: generateMessageId(), + role: 'assistant', + content: `Error: ${(message as WsError).message}`, + timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), + type: 'message', + }, + ]) + setIsLoading(false) + return true + } + + default: + return false + } + }, [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId]) + + return { + messages, + setMessages, + isLoading, + setIsLoading, + handleMessage, + clearMessages, + handleStop, + handleMainViewMessage, + } +} diff --git a/web/src/hooks/chat/useSchedulerView.ts b/web/src/hooks/chat/useSchedulerView.ts new file mode 100644 index 0000000..8ee6be5 --- /dev/null +++ b/web/src/hooks/chat/useSchedulerView.ts @@ -0,0 +1,98 @@ +import { useState, useCallback, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' +import type { + WsOutbound, + SchedulerJobSummary, + SchedulerJobSessionLookup, + Command, +} from '../../types/protocol' +import { serverMessageToChatMessage } from './messageMappers' +import type { SchedulerJobView } from './types' + +export interface UseSchedulerViewReturn { + schedulerView: SchedulerJobView | null + setSchedulerView: Dispatch> + schedulerViewRef: MutableRefObject + schedulerJobs: SchedulerJobSummary[] + setSchedulerJobs: Dispatch> + sidebarTab: 'topics' | 'scheduler' + setSidebarTab: (tab: 'topics' | 'scheduler') => void + requestSchedulerJobList: () => Command + enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command + exitSchedulerJobView: () => void + /** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */ + handleSchedulerMessage: (message: WsOutbound) => boolean +} + +export function useSchedulerView(): UseSchedulerViewReturn { + const [schedulerView, setSchedulerView] = useState(null) + const [schedulerJobs, setSchedulerJobs] = useState([]) + const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') + + const schedulerViewRef = useRef(null) + + useEffect(() => { + schedulerViewRef.current = schedulerView + }, [schedulerView]) + + 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) + }, []) + + /** Tier 1 路由:调度器视图激活时,chat 消息追加到 schedulerView;非 chat 消息 fall through */ + const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => { + const currentSchedulerView = schedulerViewRef.current + if (!currentSchedulerView) return false + + const chatMsg = serverMessageToChatMessage(message) + if (chatMsg) { + setSchedulerView((prev) => + prev + ? { ...prev, messages: [...prev.messages, chatMsg] } + : prev + ) + return true + } + // Non-chat messages (session_list, topic_list, etc.) fall through to main handler + return false + }, []) + + // scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置 + return { + schedulerView, + setSchedulerView, + schedulerViewRef, + schedulerJobs, + setSchedulerJobs, + sidebarTab, + setSidebarTab, + requestSchedulerJobList, + enterSchedulerJobView, + exitSchedulerJobView, + handleSchedulerMessage, + } +} diff --git a/web/src/hooks/chat/useSessions.ts b/web/src/hooks/chat/useSessions.ts new file mode 100644 index 0000000..6e18563 --- /dev/null +++ b/web/src/hooks/chat/useSessions.ts @@ -0,0 +1,56 @@ +import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' +import type { SessionSummary, Command } from '../../types/protocol' + +export interface UseSessionsReturn { + sessions: SessionSummary[] + setSessions: Dispatch> + selectedSessionId: string | null + setSelectedSessionId: Dispatch> + session: SessionSummary | null + sessionId: string | null + chatId: string + selectSession: (sessionId: string) => void + requestSessionList: (selectedChannel: string) => Command +} + +interface UseSessionsOptions { + /** selectSession 时额外执行的副作用 */ + onSessionChange?: () => void +} + +export function useSessions(options?: UseSessionsOptions): UseSessionsReturn { + const [sessions, setSessions] = useState([]) + const [selectedSessionId, setSelectedSessionId] = useState(null) + + 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', [sessionId]) + + const selectSession = useCallback((id: string) => { + setSelectedSessionId(id) + options?.onSessionChange?.() + }, [options]) + + const requestSessionList = useCallback((selectedChannel: string): Command => { + return { + type: 'list_sessions_by_channel', + channel_name: selectedChannel, + include_archived: false, + } + }, []) + + return { + sessions, + setSessions, + selectedSessionId, + setSelectedSessionId, + session: selectedSession, + sessionId, + chatId, + selectSession, + requestSessionList, + } +} diff --git a/web/src/hooks/chat/useSideData.ts b/web/src/hooks/chat/useSideData.ts new file mode 100644 index 0000000..2c69dd4 --- /dev/null +++ b/web/src/hooks/chat/useSideData.ts @@ -0,0 +1,104 @@ +import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' +import type { + MemorySummary, + SkillSummary, + TodoItemSummary, + Channel, + Command, +} from '../../types/protocol' + +export interface UseSideDataReturn { + memories: MemorySummary[] + setMemories: Dispatch> + skills: SkillSummary[] + setSkills: Dispatch> + todos: TodoItemSummary[] + setTodos: Dispatch> + highlightedMessageId: string | null + setHighlightedMessageId: Dispatch> + + channels: Channel[] + setChannels: Dispatch> + selectedChannel: string + setSelectedChannel: Dispatch> + isWritable: boolean + + requestMemoryList: () => Command + createMemory: (namespace: string, key: string, content: string) => Command + updateMemory: (id: string, content: string) => Command + deleteMemory: (id: string) => Command + requestSkillList: () => Command + requestTodoList: () => Command + requestSubAgentTodoList: (subTaskId: string) => Command + requestChannelList: () => Command +} + +export function useSideData(): UseSideDataReturn { + const [memories, setMemories] = useState([]) + const [skills, setSkills] = useState([]) + const [todos, setTodos] = useState([]) + const [highlightedMessageId, setHighlightedMessageId] = useState(null) + const [channels, setChannels] = useState([]) + const [selectedChannel, setSelectedChannel] = useState('websocket') + + const isWritable = useMemo( + () => channels.find(c => c.id === selectedChannel)?.isWritable ?? false, + [channels, selectedChannel] + ) + + 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 requestChannelList = useCallback((): Command => { + return { type: 'list_channels' } + }, []) + + return { + memories, + setMemories, + skills, + setSkills, + todos, + setTodos, + highlightedMessageId, + setHighlightedMessageId, + channels, + setChannels, + selectedChannel, + setSelectedChannel, + isWritable, + requestMemoryList, + createMemory, + updateMemory, + deleteMemory, + requestSkillList, + requestTodoList, + requestSubAgentTodoList, + requestChannelList, + } +} diff --git a/web/src/hooks/chat/useSubAgentView.ts b/web/src/hooks/chat/useSubAgentView.ts new file mode 100644 index 0000000..cd433a9 --- /dev/null +++ b/web/src/hooks/chat/useSubAgentView.ts @@ -0,0 +1,387 @@ +import { useState, useCallback, useMemo, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' +import type { + ChatMessage, + WsOutbound, + StreamDelta, + WsError, + ToolCall, + TaskStarted, + TaskMessagesLoaded, + Command, +} from '../../types/protocol' +import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers' +import type { SubAgentView } from './types' + +interface UseSubAgentViewOptions { + /** 发送命令到后端(用于子代理 todo_write 后刷新待办) */ + sendCommand: (cmd: Command) => void + /** 构建子代理待办刷新命令 */ + requestSubAgentTodoList: (subTaskId: string) => Command +} + +export interface UseSubAgentViewReturn { + subAgentStack: SubAgentView[] + setSubAgentStack: Dispatch> + subAgentView: SubAgentView | null + subAgentViewRef: MutableRefObject + subAgentStackRef: MutableRefObject + enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command + exitSubAgentView: () => Command | null + navigateToSubAgentLevel: (index: number) => Command | null + /** 处理子智能体视图的消息路由(Tier 2),返回是否已处理 */ + handleSubAgentMessage: (message: WsOutbound) => boolean +} + +export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn { + const { sendCommand, requestSubAgentTodoList } = options + const [subAgentStack, setSubAgentStack] = useState([]) + const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack]) + + const subAgentViewRef = useRef(null) + const subAgentStackRef = useRef([]) + const pendingTaskNavsRef = useRef>(new Map()) + + // ref 同步:确保回调中读到最新值 + useEffect(() => { + subAgentViewRef.current = subAgentView + }, [subAgentView]) + + useEffect(() => { + subAgentStackRef.current = subAgentStack + }, [subAgentStack]) + + // 追加消息到栈顶视图(含流式累加) + const appendToSubAgentViewMessage = useCallback((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 并发调用导致重复。 + 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 匹配) + const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => { + setSubAgentStack((prev) => { + const idx = prev.findIndex(v => v.taskId === taskId) + if (idx < 0) return prev + const layer = prev[idx] + + if (message.type === 'execution_completed') { + const newStack = [...prev] + newStack[idx] = { ...layer, status: 'completed' } + return newStack + } + 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 + } + 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 + } + if (message.type === 'stream_end') return prev + 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') { + 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 + }) + }, []) + + const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => { + const newView: SubAgentView = { + taskId, + description, + subagentType: subagentType || '', + status: 'loading', + messages: [], + } + // 同步设置 ref,消除竞态窗口 + subAgentViewRef.current = newView + subAgentStackRef.current = [...subAgentStackRef.current, newView] + setSubAgentStack((prev) => [...prev, newView]) + return { type: 'load_task_messages', task_id: taskId } + }, []) + + const exitSubAgentView = useCallback((): Command | null => { + 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] + 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 navigateToSubAgentLevel = useCallback((index: number): Command | null => { + const current = subAgentStackRef.current + if (index < 0) { + 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] + 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 } + }, []) + + /** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */ + const handleSubAgentMessage = useCallback((message: WsOutbound): boolean => { + const currentSubAgentView = subAgentViewRef.current + if (!currentSubAgentView) return false + + 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] + 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 true + } + + 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] + + 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 + } + } + 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) { + const key = msg.tool_call_id || `fallback:${msg.task_id}` + pendingTaskNavsRef.current.set(key, msg.task_id) + } + return true + } + } + + 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 { tool_name: string }).tool_name === 'todo_write') { + const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId) + sendCommand(refreshCmd) + } + return true + } + + // 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId + if (msgSubagentTaskId) { + appendToSubAgentLayerMessage(msgSubagentTaskId, message) + return true + } + + // 消息不属于子智能体路由,fall through 到主视图 + return false + }, [appendToSubAgentViewMessage, appendToSubAgentLayerMessage, sendCommand, requestSubAgentTodoList]) + + return { + subAgentStack, + setSubAgentStack, + subAgentView, + subAgentViewRef, + subAgentStackRef, + enterSubAgentView, + exitSubAgentView, + navigateToSubAgentLevel, + handleSubAgentMessage, + } +} diff --git a/web/src/hooks/chat/useTopics.ts b/web/src/hooks/chat/useTopics.ts new file mode 100644 index 0000000..6cfeb55 --- /dev/null +++ b/web/src/hooks/chat/useTopics.ts @@ -0,0 +1,104 @@ +import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' +import type { Topic, TopicList, TopicSummary, Command } from '../../types/protocol' + +export interface UseTopicsReturn { + topics: Topic[] + setTopics: Dispatch> + selectedTopic: string | null + setSelectedTopic: Dispatch> + topicRefreshTrigger: number + bumpTopicRefreshTrigger: () => void + topicsRef: MutableRefObject + selectedTopicRef: MutableRefObject + pendingNewTopicRef: MutableRefObject + /** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ + handleTopicList: (msg: TopicList) => boolean + createTopic: (title?: string) => Command + switchTopic: (topicId: string) => Command + deleteTopic: (topicId: string) => Command + requestTopicList: (sessionId: string | null) => Command | null +} + +export function useTopics(): UseTopicsReturn { + const [topics, setTopics] = useState([]) + const [selectedTopic, setSelectedTopic] = useState(null) + const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0) + + const topicsRef = useRef([]) + const selectedTopicRef = useRef(null) + const pendingNewTopicRef = useRef(false) + + // ref 同步:确保回调中读到最新值 + useEffect(() => { + topicsRef.current = topics + }, [topics]) + + useEffect(() => { + selectedTopicRef.current = selectedTopic + }, [selectedTopic]) + + const bumpTopicRefreshTrigger = useCallback(() => { + setTopicRefreshTrigger(n => n + 1) + }, []) + + const handleTopicList = useCallback((msg: TopicList): boolean => { + 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) + return true + } + } + return false + }, []) + + 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 requestTopicList = useCallback((sessionId: string | null): Command | null => { + if (!sessionId) return null + return { type: 'list_topics', session_id: sessionId } + }, []) + + return { + topics, + setTopics, + selectedTopic, + setSelectedTopic, + topicRefreshTrigger, + bumpTopicRefreshTrigger, + topicsRef, + selectedTopicRef, + pendingNewTopicRef, + handleTopicList, + createTopic, + switchTopic, + deleteTopic, + requestTopicList, + } +}