657 lines
18 KiB
TypeScript
657 lines
18 KiB
TypeScript
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)
|
||
|
||
// 自动选择第一个 Session(WebSocket 通道只有一个)
|
||
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,
|
||
}
|
||
}
|