PicoBot/web/src/App.tsx

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