PicoBot/web/src/hooks/useChat.ts

826 lines
24 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,
SessionSummary,
TopicList,
TopicSummary,
TaskMessagesLoaded,
TaskStarted,
Attachment,
MemorySummary,
MemoryList,
SkillSummary,
SkillList,
SchedulerJobList,
SchedulerJobSummary,
SchedulerJobSessionLookup,
Channel,
ChannelList,
} 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
// 方法
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
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) => Command
exitSubAgentView: () => 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
// 定时任务状态
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 [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
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')
// 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)
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
}
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,
}
}
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 '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
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 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
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)
// 清空旧数据(切换通道时避免数据污染)
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 '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) => [
...prev,
{
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,
},
])
setIsLoading(false)
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
if (currentTopic && !currentTopic.description) {
setTopicRefreshTrigger(n => n + 1)
}
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,
},
])
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 'channel_list': {
const msg = message as ChannelList
console.log('Channel list received:', msg)
setChannels(msg.channels)
break
}
case 'pong':
// 忽略这些消息
break
}
}, [])
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([])
}, [])
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([])
setIsLoading(true)
}, [selectedChannel])
const selectSession = useCallback((sessionId: string) => {
if (sessionId === selectedSessionId) return
setSelectedSessionId(sessionId)
setTopics([])
setSelectedTopic(null)
setMessages([])
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): 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 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 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,
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
selectTopic,
createTopic,
switchTopic,
deleteTopic,
requestSessionList,
requestTopicList,
topicRefreshTrigger,
requestChannelList,
selectChannel,
selectSession,
enterSubAgentView,
exitSubAgentView,
memories,
requestMemoryList,
createMemory,
updateMemory,
deleteMemory,
skills,
requestSkillList,
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
handleStop,
}
}