PicoBot/web/src/hooks/useChat.ts

657 lines
18 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 } from 'react'
import type {
Command,
ChatMessage,
Topic,
WsOutbound,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
SessionEstablished,
SessionList,
TopicList,
TopicSummary,
Session,
TaskMessagesLoaded,
TaskStarted,
Attachment,
SchedulerJobList,
SchedulerJobSummary,
SchedulerJobSessionLookup,
} from '../types/protocol'
// 简化后的层级状态
interface UseChatReturn {
// 连接状态
connectionId: string | null
isConnected: boolean
// 简化的层级状态
session: Session | null
sessionId: string | null
chatId: string
topics: Topic[]
selectedTopic: string | null
// 消息
messages: ChatMessage[]
isLoading: boolean
// 是否只读WebSocket 通道始终可写)
isReadOnly: boolean
// 子智能体视图
subAgentView: SubAgentView | null
// 方法
handleMessage: (content: string, attachments?: Attachment[]) => void
handleCommand: (command: Command) => void
clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void
// Topic 方法
selectTopic: (topicId: string) => void
createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command
// 初始化方法
requestSessionList: () => Command
requestTopicList: () => Command | null
// 子智能体导航方法
enterSubAgentView: (taskId: string, description: string) => Command
exitSubAgentView: () => void
// 定时任务状态
schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
// 定时任务执行对话查看
schedulerView: SchedulerJobView | null
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
// 停止当前 Agent 执行
handleStop: () => Command
}
interface SubAgentView {
taskId: string
description: string
subagentType: string
status: string
summary?: string
messages: ChatMessage[]
}
interface SchedulerJobView {
jobId: string
description: string
channel: string
chatId: string
messages: ChatMessage[]
}
const DEFAULT_CHANNEL = 'websocket'
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 [session, setSession] = useState<Session | null>(null)
const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
// 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 isConnected = useMemo(() => connectionId !== null, [connectionId])
const sessionId = useMemo(() => session?.id ?? null, [session])
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
// Extract subagent_task_id from a message if present
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
if (message.type === 'tool_call' || message.type === 'tool_result'
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id
}
return undefined
}
// Convert a server message to ChatMessage (extracted from handleServerMessage logic)
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
switch (message.type) {
case 'assistant_response': {
const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
return {
id: msg.id,
role: role as ChatMessage['role'],
content: msg.content,
timestamp: Date.now(),
type: 'message',
attachments: msg.attachments,
subagentTaskId: msg.subagent_task_id,
}
}
case 'tool_call': {
const msg = message as ToolCall
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
}
}
case 'tool_result': {
const msg = message as ToolResult
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
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: Date.now(),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
}
}
case 'error': {
return {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: Date.now(),
type: 'message',
}
}
default:
return null
}
}
// Append a server message to the sub-agent view
const appendToSubAgentViewMessage = (message: WsOutbound) => {
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSubAgentView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
}
}
const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message)
// Route to scheduler job view if active
const currentSchedulerView = schedulerViewRef.current
if (currentSchedulerView) {
// Route all chat messages to the scheduler view
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSchedulerView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
}
return
}
// Route to sub-agent view if active
const currentSubAgentView = subAgentViewRef.current
if (currentSubAgentView) {
if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded
setSubAgentView((prev) =>
prev
? {
...prev,
subagentType: msg.subagent_type,
status: msg.status,
summary: msg.summary,
}
: prev
)
return
}
// Only accept messages explicitly tagged with matching subagent_task_id.
// History messages are now tagged by the backend (send_task_messages),
// and live sub-agent messages are tagged by SubAgentEmitter.
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message)
return
}
}
// In main view, skip sub-agent messages (they belong to sub-agent view).
// But use the task_id to associate with the running task tool card.
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId) {
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], subagentTaskId: msgSubagentTaskId }
return updated
}
}
return prev
})
return
}
switch (message.type) {
case 'session_established': {
const msg = message as SessionEstablished
setConnectionId(msg.session_id)
console.log('Connection established:', msg.session_id)
break
}
case 'task_started': {
const msg = message as TaskStarted
// 立即更新对应的 task tool_call让用户可以点击查看实时进度
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], subagentTaskId: msg.task_id }
return updated
}
}
return prev
})
break
}
case 'session_list': {
const msg = message as SessionList
console.log('Session list received:', msg)
// 自动选择第一个 SessionWebSocket 通道只有一个)
if (msg.sessions.length > 0) {
const firstSession = msg.sessions[0]
setSession({
id: firstSession.session_id,
title: firstSession.title,
channel_name: firstSession.channel_name,
created_at: Date.now(),
updated_at: Date.now(),
})
}
setIsLoading(false)
break
}
case 'session_created': {
// 创建新 Topic 后更新列表
setIsLoading(false)
break
}
case 'session_loaded': {
setIsLoading(false)
break
}
case 'topic_list': {
const msg = message as TopicList
console.log('Topic list received:', msg)
// 转换 topics 格式
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
id: t.topic_id,
session_id: t.session_id,
title: t.title,
description: t.description || undefined,
message_count: Number(t.message_count),
created_at: t.created_at,
updated_at: t.last_active_at,
}))
setTopics(newTopics)
// 默认选中第一个 Topic如果没有选中
setIsLoading(false)
break
}
case 'assistant_response': {
const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => [
...prev,
{
id: msg.id,
role,
content: msg.content,
timestamp: Date.now(),
type: 'message',
attachments: msg.attachments,
},
])
setIsLoading(false)
break
}
case 'tool_call': {
const msg = message as ToolCall
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
},
])
break
}
case 'tool_result': {
const msg = message as ToolResult
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
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
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: Date.now(),
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: Date.now(),
type: 'message',
},
])
setIsLoading(false)
break
}
case 'error': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: Date.now(),
type: 'message',
},
])
setIsLoading(false)
break
}
case 'scheduler_job_list': {
const msg = message as SchedulerJobList
setSchedulerJobs(msg.jobs)
break
}
case 'channel_list':
case 'pong':
// 忽略这些消息
break
}
}, [])
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'user',
content,
timestamp: Date.now(),
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 'list_topics':
setIsLoading(true)
break
}
}, [])
const clearMessages = useCallback(() => {
setMessages([])
}, [])
// Topic 操作方法
const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId)
setMessages([])
}, [])
const createTopic = useCallback((title?: string): Command => {
return {
type: 'create_session',
title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
}
}, [])
const switchTopic = useCallback((topicId: string): Command => {
return {
type: 'switch_topic',
topic_id: topicId,
}
}, [])
// 初始化方法
const requestSessionList = useCallback((): Command => {
return {
type: 'list_sessions_by_channel',
channel_name: DEFAULT_CHANNEL,
include_archived: false,
}
}, [])
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])
const enterSubAgentView = useCallback((taskId: string, description: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: '',
status: 'loading',
messages: [],
}
// Sync ref immediately so WebSocket response routing works correctly
subAgentViewRef.current = newView
setSubAgentView(newView)
return {
type: 'load_task_messages',
task_id: taskId,
}
}, [])
const exitSubAgentView = useCallback(() => {
subAgentViewRef.current = null
setSubAgentView(null)
}, [])
// 定时任务方法
const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' }
}, [])
const enterSchedulerJobView = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
const newView: SchedulerJobView = {
jobId,
description,
channel: lookup.channel,
chatId: lookup.chat_id,
messages: [],
}
schedulerViewRef.current = newView
setSchedulerView(newView)
return {
type: 'load_chat_messages',
channel: lookup.channel,
chat_id: lookup.chat_id,
}
},
[]
)
const exitSchedulerJobView = useCallback(() => {
schedulerViewRef.current = null
setSchedulerView(null)
}, [])
const handleStop = useCallback((): Command => {
return { type: 'stop_execution' }
}, [])
// Memoize messages: sub-agent view > scheduler view > main
const resolvedMessages = useMemo(() => {
if (subAgentView) {
return subAgentView.messages
}
if (schedulerView) {
return schedulerView.messages
}
return messages
}, [subAgentView, schedulerView, messages])
// WebSocket 通道始终可写
const isReadOnly = false
return {
connectionId,
isConnected,
session,
sessionId,
chatId,
topics,
selectedTopic,
messages: resolvedMessages,
isLoading,
isReadOnly,
subAgentView,
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
selectTopic,
createTopic,
switchTopic,
requestSessionList,
requestTopicList,
enterSubAgentView,
exitSubAgentView,
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
handleStop,
}
}