PicoBot/web/src/hooks/useChat.ts

1144 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useEffect, useRef, useMemo, type Dispatch, type SetStateAction } from 'react'
import type {
Command,
ChatMessage,
Topic,
WsOutbound,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
SessionEstablished,
SessionList,
SessionSummary,
TopicList,
TopicSummary,
TaskMessagesLoaded,
TaskStarted,
Attachment,
MemorySummary,
MemoryList,
SkillSummary,
SkillList,
TodoItemSummary,
TodoList,
SchedulerJobList,
SchedulerJobSummary,
SchedulerJobSessionLookup,
Channel,
ChannelList,
StreamDelta,
StreamEnd,
WsInbound,
} from '../types/protocol'
// 简化后的层级状态
interface UseChatReturn {
// 连接状态
connectionId: string | null
isConnected: boolean
// 简化的层级状态
sessions: SessionSummary[]
selectedSessionId: string | null
session: SessionSummary | null
sessionId: string | null
chatId: string
topics: Topic[]
selectedTopic: string | null
// 消息
messages: ChatMessage[]
isLoading: boolean
// 通道状态
channels: Channel[]
selectedChannel: string
isWritable: boolean
// 是否只读
isReadOnly: boolean
// 子智能体视图(栈结构,支持面包屑导航)
subAgentView: SubAgentView | null
subAgentStack: SubAgentView[]
// 方法
handleMessage: (content: string, attachments?: Attachment[]) => void
handleCommand: (command: Command) => void
clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
// Topic 方法
selectTopic: (topicId: string) => void
createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command
deleteTopic: (topicId: string) => Command
// 初始化方法
requestSessionList: () => Command
requestTopicList: () => Command | null
topicRefreshTrigger: number
requestChannelList: () => Command
selectChannel: (channelId: string) => void
selectSession: (sessionId: string) => void
// 子智能体导航方法
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
exitSubAgentView: () => void
navigateToSubAgentLevel: (index: number) => void
// 记忆状态
memories: MemorySummary[]
requestMemoryList: () => Command
createMemory: (namespace: string, key: string, content: string) => Command
updateMemory: (id: string, content: string) => Command
deleteMemory: (id: string) => Command
// 技能状态
skills: SkillSummary[]
requestSkillList: () => Command
// Todo 状态
todos: TodoItemSummary[]
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
// 高亮消息 ID点击待办后滚动到对应消息
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
// 定时任务状态
schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
// 定时任务执行对话查看
schedulerView: SchedulerJobView | null
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
// 停止当前 Agent 执行
handleStop: () => Command
}
interface SubAgentView {
taskId: string
description: string
subagentType: string
status: string
summary?: string
messages: ChatMessage[]
}
interface SchedulerJobView {
jobId: string
description: string
channel: string
chatId: string
messages: ChatMessage[]
}
const DEFAULT_CHAT_ID = 'default'
export function useChat(): UseChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
// 简化的状态管理
const [connectionId, setConnectionId] = useState<string | null>(null)
const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
const [sessions, setSessions] = useState<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
// Track user message IDs already synced from backend to avoid duplicate updates
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set())
// Message ID generator
const messageIdCounter = useRef(0)
const generateMessageId = () => {
messageIdCounter.current += 1
return `msg_${Date.now()}_${messageIdCounter.current}`
}
// Ref to track subAgentView and schedulerView for use in callbacks
const subAgentViewRef = useRef<SubAgentView | null>(null)
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
const topicsRef = useRef<Topic[]>([])
const selectedTopicRef = useRef<string | null>(null)
const pendingNewTopicRef = useRef(false)
// Pending task navigations: tool_call_id -> task_id
// Used when task_started arrives before the tool_call is in the sub-agent view
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
// Ref to send commands from within handleServerMessage (set by App.tsx)
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
sendMessageRef.current = fn
}, [])
const isConnected = useMemo(() => connectionId !== null, [connectionId])
const selectedSession = useMemo(
() => sessions.find(s => s.session_id === selectedSessionId) ?? null,
[sessions, selectedSessionId]
)
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession])
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
const isWritable = useMemo(
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
[channels, selectedChannel]
)
// Extract subagent_task_id from a message if present
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
if (message.type === 'tool_call' || message.type === 'tool_result'
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id
}
if (message.type === 'stream_delta' || message.type === 'stream_end') {
return (message as StreamDelta | StreamEnd).subagent_task_id
}
return undefined
}
// Convert a server message to ChatMessage (extracted from handleServerMessage logic)
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
switch (message.type) {
case 'assistant_response': {
const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
return {
id: msg.id,
role: role as ChatMessage['role'],
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
}
case 'tool_call': {
const msg = message as ToolCall
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
}
case 'tool_result': {
const msg = message as ToolResult
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
}
}
case 'tool_pending': {
const msg = message as ToolPending
return {
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
}
}
case 'stream_delta': {
const msg = message as StreamDelta
return {
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_delta,
}
}
case 'error': {
return {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
}
default:
return null
}
}
// Append a server message to the sub-agent view (with streaming delta accumulation)
const appendToSubAgentViewMessage = (message: WsOutbound) => {
// stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') {
const msg = message as StreamDelta
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...top.messages]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
}
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
return
}
// stream_end: no-op, assistant_response will replace
if (message.type === 'stream_end') return
// Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (message.type === 'assistant_response') {
const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...top.messages]
updated[existingIdx] = chatMsg
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
}
}
// Sync backend user message ID to the last local user message,
// so that created_by_message_id (backend UUID) can match DOM data-message-id
const applyUserMessageId = useCallback((userMessageId: string) => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return
syncedUserMessageIdsRef.current.add(userMessageId)
setMessages(prev => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'user') {
const updated = [...prev]
updated[i] = { ...updated[i], id: userMessageId }
return updated
}
}
return prev
})
}, [])
const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message)
// Route to scheduler job view if active
const currentSchedulerView = schedulerViewRef.current
if (currentSchedulerView) {
// Route chat messages to the scheduler view
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSchedulerView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
return
}
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
}
// Route to sub-agent view if active
const currentSubAgentView = subAgentViewRef.current
if (currentSubAgentView) {
if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const newStack = [...prev]
newStack[newStack.length - 1] = {
...top,
subagentType: msg.subagent_type,
status: msg.status,
summary: msg.summary,
}
return newStack
})
return
}
// When the sub-agent spawns a grandchild, set navigateToTaskId
// on the task tool_call so "查看实时进度" navigates correctly.
if (message.type === 'task_started') {
const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) {
let matched = false
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = updatedMessages.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
matched = true
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
matched = true
break
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
})
if (!matched) {
// tool_call 尚未到达,存储 pending navigation 等后续 tool_call 到达时回填
const key = msg.tool_call_id || `fallback:${msg.task_id}`
pendingTaskNavsRef.current.set(key, msg.task_id)
}
return
}
}
// Only accept messages explicitly tagged with matching subagent_task_id.
// History messages are now tagged by the backend (send_task_messages),
// and live sub-agent messages are tagged by SubAgentEmitter.
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message)
// 检查 pending navigation当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
if (message.type === 'tool_call') {
const tc = message as ToolCall
if (tc.tool_name === 'task' && tc.tool_call_id) {
const key = tc.tool_call_id
const pendingTaskId = pendingTaskNavsRef.current.get(key)
if (pendingTaskId) {
pendingTaskNavsRef.current.delete(key)
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
const idx = updatedMessages.findIndex(m =>
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
if (idx >= 0) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
return prev
})
}
}
}
// 子代理 todo_write 完成后自动刷新待办列表
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(refreshCmd) })
}
return
}
// 丢弃其他子智能体的消息,避免 fall through 到主消息处理
if (msgSubagentTaskId) {
return
}
}
// In main view, skip sub-agent messages (they belong to sub-agent view).
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId) {
return
}
switch (message.type) {
case 'session_established': {
const msg = message as SessionEstablished
setConnectionId(msg.session_id)
console.log('Connection established:', msg.session_id)
break
}
case 'task_started': {
const msg = message as TaskStarted
console.log('[useChat] task_started received:', { task_id: msg.task_id, topic_id: msg.topic_id, parent_task_id: msg.parent_task_id, selectedTopic: selectedTopicRef.current })
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) {
console.log('[useChat] task_started filtered by topic_id')
break
}
// 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) {
console.log('[useChat] task_started filtered by parent_task_id')
break
}
// 设置 navigateToTaskId让用户可以点击查看实时进度
setMessages((prev) => {
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length, 'tool_call_id:', msg.tool_call_id)
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = prev.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !prev[idx].navigateToTaskId) {
console.log('[useChat] task_started EXACT MATCH at index', idx, 'task_id:', msg.task_id)
const updated = [...prev]
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
return updated
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
console.log('[useChat] task_started BACKWARD MATCH at index', i, 'task_id:', msg.task_id)
const updated = [...prev]
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
return updated
}
}
console.log('[useChat] task_started NO matching task tool_call found in messages')
return prev
})
break
}
case 'session_list': {
const msg = message as SessionList
console.log('Session list received:', msg)
// 清空旧数据(切换通道时避免数据污染)
setTopics([])
setSelectedTopic(null)
setMessages([])
// 存储全部 session
setSessions(msg.sessions)
// 自动选中:优先保持当前选中,否则选第一个
setSelectedSessionId(prev => {
if (prev && msg.sessions.some(s => s.session_id === prev)) {
return prev
}
return msg.sessions.length > 0 ? msg.sessions[0].session_id : null
})
setIsLoading(false)
break
}
case 'session_created': {
// 创建新 Topic 后更新列表
setIsLoading(false)
break
}
case 'session_loaded': {
setIsLoading(false)
break
}
case 'topic_list': {
const msg = message as TopicList
console.log('Topic list received:', msg)
// 转换 topics 格式
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
id: t.topic_id,
session_id: t.session_id,
title: t.title,
description: t.description || undefined,
message_count: Number(t.message_count),
created_at: t.created_at,
updated_at: t.last_active_at,
}))
setTopics(newTopics)
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
if (pendingNewTopicRef.current) {
pendingNewTopicRef.current = false
if (newTopics.length > 0) {
setSelectedTopic(newTopics[0].id)
setMessages([])
}
}
setIsLoading(false)
break
}
case 'stream_delta': {
const msg = message as StreamDelta
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
// 追加到已有消息
const updated = [...prev]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
return updated
}
// 创建新消息
return [
...prev,
{
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
reasoningContent: msg.reasoning_delta,
},
]
})
setIsLoading(false)
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break
}
case 'stream_end': {
// 流式结束,无需额外操作,后续 assistant_response 会替换完整内容
break
}
case 'assistant_response': {
const msg = message as AssistantResponse
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => {
// 如果流式消息已存在(相同 id替换它
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
const newMsg: ChatMessage = {
id: msg.id,
role,
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
reasoningContent: msg.reasoning_content,
}
if (existingIdx >= 0) {
const updated = [...prev]
updated[existingIdx] = newMsg
return updated
}
return [...prev, newMsg]
})
setIsLoading(false)
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
if (currentTopic && !currentTopic.description) {
setTopicRefreshTrigger(n => n + 1)
}
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break
}
case 'tool_call': {
const msg = message as ToolCall
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
},
])
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break
}
case 'tool_result': {
const msg = message as ToolResult
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
},
])
break
}
case 'tool_pending': {
const msg = message as ToolPending
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
},
])
break
}
case 'execution_cancelled': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: (message as { type: 'execution_cancelled'; message: string }).message,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
break
}
case 'error': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: (message as any).timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
break
}
case 'scheduler_job_list': {
const msg = message as SchedulerJobList
setSchedulerJobs(msg.jobs)
break
}
case 'memory_list': {
const msg = message as MemoryList
setMemories(msg.memories)
break
}
case 'skill_list': {
const msg = message as SkillList
setSkills(msg.skills)
break
}
case 'todo_list': {
const msg = message as TodoList
setTodos(msg.todos)
break
}
case 'channel_list': {
const msg = message as ChannelList
console.log('Channel list received:', msg)
setChannels(msg.channels)
break
}
case 'pong':
// 忽略这些消息
break
}
// 主视图 todo_write 完成后自动刷新待办列表
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
const refreshCmd = subAgentViewRef.current?.taskId
? requestSubAgentTodoList(subAgentViewRef.current.taskId)
: requestTodoList()
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(refreshCmd) })
}
}, [])
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'user',
content,
timestamp: Math.floor(Date.now() / 1000),
type: 'message',
attachments: attachments || [],
},
])
setIsLoading(true)
}, [])
const handleCommand = useCallback((command: Command) => {
switch (command.type) {
case 'create_session':
case 'switch_topic':
case 'load_topic':
case 'list_sessions':
case 'list_sessions_by_channel':
case 'delete_topic':
case 'list_topics':
setIsLoading(true)
break
}
}, [])
const clearMessages = useCallback(() => {
setMessages([])
}, [])
// Topic 操作方法
const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId)
setMessages([])
setSubAgentStack([])
}, [])
const createTopic = useCallback((title?: string): Command => {
pendingNewTopicRef.current = true
return {
type: 'create_session',
title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
}
}, [])
const switchTopic = useCallback((topicId: string): Command => {
return {
type: 'switch_topic',
topic_id: topicId,
}
}, [])
const deleteTopic = useCallback((topicId: string): Command => {
return {
type: 'delete_topic',
topic_id: topicId,
}
}, [])
// 初始化方法
const requestSessionList = useCallback((): Command => {
return {
type: 'list_sessions_by_channel',
channel_name: selectedChannel,
include_archived: false,
}
}, [selectedChannel])
const requestChannelList = useCallback((): Command => {
return { type: 'list_channels' }
}, [])
const selectChannel = useCallback((channelId: string) => {
if (channelId === selectedChannel) return
setSelectedChannel(channelId)
setSessions([])
setSelectedSessionId(null)
setTopics([])
setSelectedTopic(null)
setMessages([])
setSubAgentStack([])
setIsLoading(true)
}, [selectedChannel])
const selectSession = useCallback((sessionId: string) => {
if (sessionId === selectedSessionId) return
setSelectedSessionId(sessionId)
setTopics([])
setSelectedTopic(null)
setMessages([])
setSubAgentStack([])
setIsLoading(true)
}, [selectedSessionId])
const requestTopicList = useCallback((): Command | null => {
if (!sessionId) return null
return {
type: 'list_topics',
session_id: sessionId,
}
}, [sessionId])
// Keep refs in sync with state
useEffect(() => {
subAgentViewRef.current = subAgentView
}, [subAgentView])
useEffect(() => {
schedulerViewRef.current = schedulerView
}, [schedulerView])
useEffect(() => {
topicsRef.current = topics
}, [topics])
useEffect(() => {
selectedTopicRef.current = selectedTopic
}, [selectedTopic])
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: subagentType || '',
status: 'loading',
messages: [],
}
setSubAgentStack((prev) => {
const newStack = [...prev, newView]
// Sync ref immediately so WebSocket response routing works correctly
subAgentViewRef.current = newView
return newStack
})
return {
type: 'load_task_messages',
task_id: taskId,
}
}, [])
const exitSubAgentView = useCallback(() => {
setSubAgentStack((prev) => {
if (prev.length <= 1) {
subAgentViewRef.current = null
return []
}
const newStack = prev.slice(0, -1)
subAgentViewRef.current = newStack[newStack.length - 1]
return newStack
})
}, [])
const navigateToSubAgentLevel = useCallback((index: number) => {
setSubAgentStack((prev) => {
if (index < 0) {
// -1 means go back to main session (clear all)
subAgentViewRef.current = null
return []
}
if (index >= prev.length) return prev
const newStack = prev.slice(0, index + 1)
subAgentViewRef.current = newStack.length > 0 ? newStack[newStack.length - 1] : null
return newStack
})
}, [])
// 记忆方法
const requestMemoryList = useCallback((): Command => {
return { type: 'list_memories' }
}, [])
const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
return { type: 'create_memory', namespace, key, content }
}, [])
const updateMemory = useCallback((id: string, content: string): Command => {
return { type: 'update_memory', id, content }
}, [])
const deleteMemory = useCallback((id: string): Command => {
return { type: 'delete_memory', id }
}, [])
const requestSkillList = useCallback((): Command => {
return { type: 'list_skills' }
}, [])
const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' }
}, [])
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
return { type: 'list_todos', task_id: subTaskId }
}, [])
// 定时任务方法
const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' }
}, [])
const enterSchedulerJobView = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
const newView: SchedulerJobView = {
jobId,
description,
channel: lookup.channel,
chatId: lookup.chat_id,
messages: [],
}
schedulerViewRef.current = newView
setSchedulerView(newView)
return {
type: 'load_chat_messages',
channel: lookup.channel,
chat_id: lookup.chat_id,
}
},
[]
)
const exitSchedulerJobView = useCallback(() => {
schedulerViewRef.current = null
setSchedulerView(null)
}, [])
const handleStop = useCallback((): Command => {
return { type: 'stop_execution' }
}, [])
// Memoize messages: sub-agent view > scheduler view > main
const resolvedMessages = useMemo(() => {
if (subAgentView) {
return subAgentView.messages
}
if (schedulerView) {
return schedulerView.messages
}
return messages
}, [subAgentView, schedulerView, messages])
// 只读状态由当前通道决定
const isReadOnly = !isWritable
return {
connectionId,
isConnected,
sessions,
selectedSessionId,
session: selectedSession,
sessionId,
chatId,
topics,
selectedTopic,
messages: resolvedMessages,
isLoading,
isReadOnly,
isWritable,
channels,
selectedChannel,
subAgentView,
subAgentStack,
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
setSendMessage,
selectTopic,
createTopic,
switchTopic,
deleteTopic,
requestSessionList,
requestTopicList,
topicRefreshTrigger,
requestChannelList,
selectChannel,
selectSession,
enterSubAgentView,
exitSubAgentView,
navigateToSubAgentLevel,
memories,
requestMemoryList,
createMemory,
updateMemory,
deleteMemory,
skills,
requestSkillList,
todos,
setTodos,
requestTodoList,
requestSubAgentTodoList,
highlightedMessageId,
setHighlightedMessageId,
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
handleStop,
}
}