refactor: 抽取 useChat 的领域子 hook 与 messageMappers

将原上帝 Hook 的状态域拆分为 7 个职责单一的子 hook:useConnection/useSessions/useTopics/useMessages/useSubAgentView/useSchedulerView/useSideData,并将消息映射纯函数提取到 messageMappers.ts。各子 hook 自管 ref 同步 effect,跨域依赖通过 options 注入,为组合根重写奠定基础。
This commit is contained in:
oudecheng 2026-07-08 11:24:42 +08:00
parent f5ccf490ba
commit 7038d58207
9 changed files with 1227 additions and 0 deletions

View File

@ -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
}
}

View File

@ -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'

View File

@ -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<string | null>(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,
}
}

View File

@ -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<string | null>
topicsRef: MutableRefObject<Topic[]>
bumpTopicRefreshTrigger: () => void
}
export interface UseMessagesReturn {
messages: ChatMessage[]
setMessages: Dispatch<SetStateAction<ChatMessage[]>>
isLoading: boolean
setIsLoading: Dispatch<SetStateAction<boolean>>
handleMessage: (content: string, attachments?: Attachment[]) => void
clearMessages: () => void
handleStop: () => Command
/** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */
handleMainViewMessage: (message: WsOutbound) => boolean
}
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const syncedUserMessageIdsRef = useRef<Set<string>>(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,
}
}

View File

@ -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<SetStateAction<SchedulerJobView | null>>
schedulerViewRef: MutableRefObject<SchedulerJobView | null>
schedulerJobs: SchedulerJobSummary[]
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>
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<SchedulerJobView | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const schedulerViewRef = useRef<SchedulerJobView | null>(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,
}
}

View File

@ -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<SetStateAction<SessionSummary[]>>
selectedSessionId: string | null
setSelectedSessionId: Dispatch<SetStateAction<string | null>>
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<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(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,
}
}

View File

@ -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<SetStateAction<MemorySummary[]>>
skills: SkillSummary[]
setSkills: Dispatch<SetStateAction<SkillSummary[]>>
todos: TodoItemSummary[]
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
channels: Channel[]
setChannels: Dispatch<SetStateAction<Channel[]>>
selectedChannel: string
setSelectedChannel: Dispatch<SetStateAction<string>>
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<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('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,
}
}

View File

@ -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<SetStateAction<SubAgentView[]>>
subAgentView: SubAgentView | null
subAgentViewRef: MutableRefObject<SubAgentView | null>
subAgentStackRef: MutableRefObject<SubAgentView[]>
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<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
const subAgentViewRef = useRef<SubAgentView | null>(null)
const subAgentStackRef = useRef<SubAgentView[]>([])
const pendingTaskNavsRef = useRef<Map<string, string>>(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,
}
}

View File

@ -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<SetStateAction<Topic[]>>
selectedTopic: string | null
setSelectedTopic: Dispatch<SetStateAction<string | null>>
topicRefreshTrigger: number
bumpTopicRefreshTrigger: () => void
topicsRef: MutableRefObject<Topic[]>
selectedTopicRef: MutableRefObject<string | null>
pendingNewTopicRef: MutableRefObject<boolean>
/** 处理 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<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
const topicsRef = useRef<Topic[]>([])
const selectedTopicRef = useRef<string | null>(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,
}
}