432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { Zap, Cpu, MessageSquare, ArrowLeft, Bot, Clock } from 'lucide-react'
|
|
import { ChatContainer } from './components/Chat/ChatContainer'
|
|
import { TopicList } from './components/Sidebar/TopicList'
|
|
import { SessionInfo } from './components/Sidebar/SessionInfo'
|
|
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
|
import { ToolPanel } from './components/Panel/ToolPanel'
|
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
|
import { useWebSocket } from './hooks/useWebSocket'
|
|
import { useChat } from './hooks/useChat'
|
|
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
|
|
|
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
|
|
|
function App() {
|
|
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
|
|
|
|
const {
|
|
// 连接状态
|
|
connectionId,
|
|
isConnected,
|
|
// Session 状态
|
|
session,
|
|
sessionId,
|
|
chatId,
|
|
// Topic 状态
|
|
topics,
|
|
selectedTopic,
|
|
// 消息状态
|
|
messages,
|
|
isLoading,
|
|
isReadOnly,
|
|
// 子智能体视图
|
|
subAgentView,
|
|
// 定时任务
|
|
schedulerJobs,
|
|
sidebarTab,
|
|
setSidebarTab,
|
|
requestSchedulerJobList,
|
|
schedulerView,
|
|
enterSchedulerJobView,
|
|
exitSchedulerJobView,
|
|
// 方法
|
|
handleMessage,
|
|
handleCommand,
|
|
handleServerMessage,
|
|
selectTopic,
|
|
createTopic,
|
|
switchTopic,
|
|
requestSessionList,
|
|
requestTopicList,
|
|
enterSubAgentView,
|
|
exitSubAgentView,
|
|
} = useChat()
|
|
|
|
const { status, sendMessage } = useWebSocket({
|
|
url: WS_URL,
|
|
onMessage: handleServerMessage,
|
|
})
|
|
|
|
// 连接建立后自动加载 Session
|
|
useEffect(() => {
|
|
if (isConnected && status === 'connected') {
|
|
// 1. 请求 Session 列表(会自动选择第一个)
|
|
const sessionCmd = requestSessionList()
|
|
handleCommand(sessionCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
|
|
}
|
|
}, [isConnected, status, handleCommand, sendMessage, requestSessionList])
|
|
|
|
// Session 加载后自动加载 Topics
|
|
useEffect(() => {
|
|
if (sessionId && status === 'connected') {
|
|
const topicCmd = requestTopicList()
|
|
if (topicCmd) {
|
|
handleCommand(topicCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) })
|
|
}
|
|
}
|
|
}, [sessionId, status, handleCommand, sendMessage, requestTopicList])
|
|
|
|
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息
|
|
useEffect(() => {
|
|
if (topics.length === 0 || status !== 'connected') {
|
|
return
|
|
}
|
|
|
|
const firstTopic = topics[0]
|
|
if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
|
|
return
|
|
}
|
|
|
|
lastAutoSwitchedTopicRef.current = firstTopic.id
|
|
selectTopic(firstTopic.id)
|
|
const cmd = switchTopic(firstTopic.id)
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
|
|
|
const handleSendMessage = useCallback(
|
|
(content: string, attachments: Attachment[] = []) => {
|
|
if (isReadOnly || !sessionId) {
|
|
return
|
|
}
|
|
|
|
if (content.startsWith('/')) {
|
|
const parts = content.slice(1).split(' ')
|
|
const command = parts[0]
|
|
const args = parts.slice(1)
|
|
|
|
let cmd: Command
|
|
switch (command) {
|
|
case 'new':
|
|
cmd = { type: 'create_session', title: args.join(' ') || undefined }
|
|
break
|
|
case 'list':
|
|
cmd = { type: 'list_sessions', include_archived: args[0] === 'all' }
|
|
break
|
|
case 'use':
|
|
if (args[0]) {
|
|
cmd = { type: 'switch_topic', topic_id: args[0] }
|
|
} else {
|
|
alert('Usage: /use <topic_id>')
|
|
return
|
|
}
|
|
break
|
|
case 'save':
|
|
cmd = { type: 'save_topic', filepath: args[0] || undefined, include_subagents: false }
|
|
break
|
|
default:
|
|
alert(`Unknown command: /${command}`)
|
|
return
|
|
}
|
|
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
} else {
|
|
handleMessage(content, attachments)
|
|
sendMessage({
|
|
type: 'message',
|
|
content,
|
|
attachments,
|
|
chat_id: chatId,
|
|
})
|
|
}
|
|
},
|
|
[sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly]
|
|
)
|
|
|
|
const handleCreateTopic = useCallback(() => {
|
|
if (isReadOnly || !sessionId) {
|
|
return
|
|
}
|
|
|
|
const cmd = createTopic()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly])
|
|
|
|
const handleSwitchTopic = useCallback(
|
|
(topicId: string) => {
|
|
selectTopic(topicId)
|
|
const cmd = switchTopic(topicId)
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
},
|
|
[sendMessage, handleCommand, switchTopic, selectTopic]
|
|
)
|
|
|
|
const handleNavigateToSubAgent = useCallback(
|
|
(taskId: string, description: string) => {
|
|
const cmd = enterSubAgentView(taskId, description)
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
},
|
|
[enterSubAgentView, handleCommand, sendMessage]
|
|
)
|
|
|
|
const handleExitSubAgentView = useCallback(() => {
|
|
exitSubAgentView()
|
|
}, [exitSubAgentView])
|
|
|
|
// 切换到定时任务 tab 时自动获取列表
|
|
useEffect(() => {
|
|
if (sidebarTab === 'scheduler' && status === 'connected') {
|
|
const cmd = requestSchedulerJobList()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}
|
|
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
|
|
|
|
const handleRefreshSchedulerJobs = useCallback(() => {
|
|
const cmd = requestSchedulerJobList()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [handleCommand, sendMessage, requestSchedulerJobList])
|
|
|
|
const handleViewSchedulerJob = useCallback(
|
|
(lookup: SchedulerJobSessionLookup, jobId: string, description: string) => {
|
|
const cmd = enterSchedulerJobView(lookup, jobId, description)
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
},
|
|
[enterSchedulerJobView, handleCommand, sendMessage]
|
|
)
|
|
|
|
const handleExitSchedulerJobView = useCallback(() => {
|
|
exitSchedulerJobView()
|
|
}, [exitSchedulerJobView])
|
|
|
|
const chatMessages = useMemo(() => {
|
|
const result: ChatMessage[] = []
|
|
const toolCallIndex = new Map<string, number>()
|
|
|
|
for (const msg of messages) {
|
|
if (msg.type === 'tool_call') {
|
|
toolCallIndex.set(msg.toolCallId || msg.id, result.length)
|
|
result.push({
|
|
...msg,
|
|
type: 'merged_tool',
|
|
status: 'calling',
|
|
callContent: msg.content,
|
|
resultContent: '',
|
|
})
|
|
} else if (msg.type === 'tool_result') {
|
|
const idx = toolCallIndex.get(msg.toolCallId || msg.id)
|
|
if (idx !== undefined) {
|
|
result[idx] = {
|
|
...result[idx],
|
|
status: 'result',
|
|
resultContent: msg.content,
|
|
durationMs: msg.durationMs,
|
|
}
|
|
}
|
|
} else if (msg.type === 'tool_pending') {
|
|
const idx = toolCallIndex.get(msg.toolCallId || msg.id)
|
|
if (idx !== undefined) {
|
|
result[idx] = {
|
|
...result[idx],
|
|
status: 'pending',
|
|
resultContent: msg.content,
|
|
}
|
|
}
|
|
} else {
|
|
result.push(msg)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}, [messages])
|
|
const toolMessages = messages
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col bg-[#0a0a0f] text-white overflow-hidden">
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between border-b border-white/8 bg-[#12121a]/80 backdrop-blur-md px-6 py-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-6 w-6 text-[#00f0ff]" />
|
|
<h1 className="text-xl font-bold tracking-tight">
|
|
<span className="text-white">Pico</span>
|
|
<span className="text-[#00f0ff] glow-text">Bot</span>
|
|
</h1>
|
|
</div>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<ConnectionStatus status={status} />
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-zinc-400">
|
|
<div className="flex items-center gap-2">
|
|
<Cpu className="h-4 w-4 text-[#00f0ff]" />
|
|
<span>AI Ready</span>
|
|
</div>
|
|
{session && (
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
|
<span className="font-mono text-xs">
|
|
{session.title}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left Sidebar */}
|
|
<div className={`w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
<SessionInfo
|
|
session={session}
|
|
connectionId={connectionId}
|
|
/>
|
|
<div className="border-b border-white/8" />
|
|
|
|
{/* Tab 栏 */}
|
|
<div className="flex border-b border-white/8">
|
|
<button
|
|
onClick={() => setSidebarTab('topics')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
sidebarTab === 'topics'
|
|
? 'text-[#00f0ff] border-b-2 border-[#00f0ff]'
|
|
: 'text-zinc-500 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
话题
|
|
</button>
|
|
<button
|
|
onClick={() => setSidebarTab('scheduler')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
sidebarTab === 'scheduler'
|
|
? 'text-[#00f0ff] border-b-2 border-[#00f0ff]'
|
|
: 'text-zinc-500 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
定时任务
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
{sidebarTab === 'topics' ? (
|
|
<TopicList
|
|
sessionId={sessionId}
|
|
sessionTitle={session?.title ?? ''}
|
|
topics={topics}
|
|
currentTopicId={selectedTopic}
|
|
isReadOnly={isReadOnly}
|
|
onCreateTopic={handleCreateTopic}
|
|
onSwitchTopic={handleSwitchTopic}
|
|
/>
|
|
) : (
|
|
<SchedulerJobList
|
|
jobs={schedulerJobs}
|
|
onRefresh={handleRefreshSchedulerJobs}
|
|
onViewJob={handleViewSchedulerJob}
|
|
sessionId={sessionId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center - Chat */}
|
|
<div className="flex-1 min-w-0 bg-[#0a0a0f] flex flex-col">
|
|
{/* Scheduler job view back bar */}
|
|
{schedulerView && (
|
|
<div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4">
|
|
<button
|
|
onClick={handleExitSchedulerJobView}
|
|
className="flex items-center gap-1.5 text-sm text-[#00f0ff] hover:text-[#00f0ff]/80 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span>返回定时任务列表</span>
|
|
</button>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<div className="flex items-center gap-1.5 text-sm text-zinc-300">
|
|
<Clock className="h-4 w-4 text-amber-400" />
|
|
<span className="text-zinc-500">定时任务:</span>
|
|
<span className="text-white font-medium font-mono text-xs truncate max-w-[250px]">{schedulerView.description}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-zinc-500">通道:</span>
|
|
<span className="text-zinc-300">{schedulerView.channel}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Sub-agent back bar */}
|
|
{subAgentView && (
|
|
<div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4">
|
|
<button
|
|
onClick={handleExitSubAgentView}
|
|
className="flex items-center gap-1.5 text-sm text-[#00f0ff] hover:text-[#00f0ff]/80 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span>返回主会话</span>
|
|
</button>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<div className="flex items-center gap-1.5 text-sm text-zinc-300">
|
|
<Bot className="h-4 w-4 text-violet-400" />
|
|
<span className="text-zinc-500">子智能体:</span>
|
|
<span className="text-white font-medium">{subAgentView.description}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-zinc-500">类型:</span>
|
|
<span className="text-zinc-300">{subAgentView.subagentType || '...'}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-white/20" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-zinc-500">状态:</span>
|
|
<span className={`font-medium ${
|
|
subAgentView.status === 'completed' ? 'text-emerald-400' :
|
|
subAgentView.status === 'failed' ? 'text-red-400' :
|
|
subAgentView.status === 'timeout' ? 'text-amber-400' :
|
|
subAgentView.status === 'running' ? 'text-amber-400' :
|
|
'text-zinc-400'
|
|
}`}>
|
|
{subAgentView.status === 'completed' ? '已完成' :
|
|
subAgentView.status === 'failed' ? '失败' :
|
|
subAgentView.status === 'timeout' ? '超时' :
|
|
subAgentView.status === 'running' ? '执行中' :
|
|
subAgentView.status === 'loading' ? '加载中...' :
|
|
subAgentView.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-h-0">
|
|
<ChatContainer
|
|
messages={chatMessages}
|
|
isLoading={isLoading}
|
|
isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
|
|
channelName={
|
|
schedulerView ? `定时任务: ${schedulerView.description}` :
|
|
subAgentView ? `子智能体: ${subAgentView.description}` :
|
|
(session?.title ?? 'PicoBot')
|
|
}
|
|
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
|
onNavigateToSubAgent={handleNavigateToSubAgent}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Sidebar - Tool Panel */}
|
|
<div className="w-80 shrink-0 border-l border-white/8 bg-[#12121a]/50">
|
|
<ToolPanel messages={toolMessages} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|