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:
parent
f5ccf490ba
commit
7038d58207
125
web/src/hooks/chat/messageMappers.ts
Normal file
125
web/src/hooks/chat/messageMappers.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
web/src/hooks/chat/types.ts
Normal file
22
web/src/hooks/chat/types.ts
Normal 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'
|
||||||
34
web/src/hooks/chat/useConnection.ts
Normal file
34
web/src/hooks/chat/useConnection.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
297
web/src/hooks/chat/useMessages.ts
Normal file
297
web/src/hooks/chat/useMessages.ts
Normal 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
|
||||||
|
/** 处理主视图的消息类 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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
98
web/src/hooks/chat/useSchedulerView.ts
Normal file
98
web/src/hooks/chat/useSchedulerView.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
56
web/src/hooks/chat/useSessions.ts
Normal file
56
web/src/hooks/chat/useSessions.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
104
web/src/hooks/chat/useSideData.ts
Normal file
104
web/src/hooks/chat/useSideData.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
387
web/src/hooks/chat/useSubAgentView.ts
Normal file
387
web/src/hooks/chat/useSubAgentView.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
104
web/src/hooks/chat/useTopics.ts
Normal file
104
web/src/hooks/chat/useTopics.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user