698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain } from 'lucide-react'
|
|
import { ChatContainer } from './components/Chat/ChatContainer'
|
|
import { TopicList } from './components/Sidebar/TopicList'
|
|
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
|
import { MemoryPanel } from './components/Panel/MemoryPanel'
|
|
import { SkillList } from './components/Panel/SkillList'
|
|
import { TodoPanel } from './components/Panel/TodoPanel'
|
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
|
import { ChannelSelector } from './components/Header/ChannelSelector'
|
|
import { SessionSelector } from './components/Header/SessionSelector'
|
|
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 {
|
|
// 连接状态
|
|
isConnected,
|
|
// Session 状态
|
|
session,
|
|
sessionId,
|
|
chatId,
|
|
// Topic 状态
|
|
topics,
|
|
selectedTopic,
|
|
// 消息状态
|
|
messages,
|
|
isLoading,
|
|
isReadOnly,
|
|
// 子智能体视图
|
|
subAgentView,
|
|
// 记忆
|
|
memories,
|
|
requestMemoryList,
|
|
createMemory,
|
|
updateMemory,
|
|
deleteMemory,
|
|
// 技能
|
|
skills,
|
|
requestSkillList,
|
|
todos,
|
|
requestTodoList,
|
|
// 定时任务
|
|
schedulerJobs,
|
|
sidebarTab,
|
|
setSidebarTab,
|
|
requestSchedulerJobList,
|
|
schedulerView,
|
|
enterSchedulerJobView,
|
|
exitSchedulerJobView,
|
|
// 通道
|
|
channels,
|
|
selectedChannel,
|
|
selectChannel,
|
|
requestChannelList,
|
|
// Session
|
|
sessions,
|
|
selectedSessionId,
|
|
selectSession,
|
|
// 方法
|
|
handleMessage,
|
|
handleCommand,
|
|
clearMessages,
|
|
handleServerMessage,
|
|
selectTopic,
|
|
createTopic,
|
|
switchTopic,
|
|
deleteTopic,
|
|
requestSessionList,
|
|
requestTopicList,
|
|
topicRefreshTrigger,
|
|
enterSubAgentView,
|
|
exitSubAgentView,
|
|
handleStop,
|
|
} = useChat()
|
|
|
|
const { status, sendMessage } = useWebSocket({
|
|
url: WS_URL,
|
|
onMessage: handleServerMessage,
|
|
})
|
|
|
|
// ---- 主题状态 ----
|
|
|
|
const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => {
|
|
try {
|
|
return localStorage.getItem('picobot-memory-panel-open') !== 'false'
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
const toggleMemoryPanel = useCallback((open: boolean) => {
|
|
setMemoryPanelOpen(open)
|
|
localStorage.setItem('picobot-memory-panel-open', String(open))
|
|
}, [])
|
|
|
|
const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory')
|
|
|
|
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
|
const saved = localStorage.getItem('picobot-theme')
|
|
return saved === 'light' ? 'light' : 'dark'
|
|
})
|
|
|
|
useEffect(() => {
|
|
const root = document.documentElement
|
|
if (theme === 'light') {
|
|
root.classList.add('light')
|
|
} else {
|
|
root.classList.remove('light')
|
|
}
|
|
localStorage.setItem('picobot-theme', theme)
|
|
|
|
// 切换时启用平滑过渡
|
|
root.classList.add('theme-transitioning')
|
|
const timer = setTimeout(() => {
|
|
root.classList.remove('theme-transitioning')
|
|
}, 350)
|
|
return () => clearTimeout(timer)
|
|
}, [theme])
|
|
|
|
const [showThinking, setShowThinking] = useState(() => {
|
|
return localStorage.getItem('picobot-show-thinking') !== 'false'
|
|
})
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('picobot-show-thinking', String(showThinking))
|
|
}, [showThinking])
|
|
|
|
// ---- WebSocket 初始化 ----
|
|
|
|
// Step 1: 连接建立后先请求通道列表
|
|
useEffect(() => {
|
|
if (isConnected && status === 'connected') {
|
|
const cmd = requestChannelList()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}
|
|
}, [isConnected, status, handleCommand, sendMessage, requestChannelList])
|
|
|
|
// Step 2: 通道列表加载后,请求选中通道的 Session 列表
|
|
useEffect(() => {
|
|
if (channels.length > 0 && status === 'connected') {
|
|
const sessionCmd = requestSessionList()
|
|
handleCommand(sessionCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
|
|
}
|
|
}, [channels.length, 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])
|
|
|
|
// 话题描述异步生成后自动刷新话题列表
|
|
useEffect(() => {
|
|
if (topicRefreshTrigger === 0) return
|
|
if (status !== 'connected') return
|
|
const topicCmd = requestTopicList()
|
|
if (!topicCmd) return
|
|
|
|
const timer = setTimeout(() => {
|
|
handleCommand(topicCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) })
|
|
}, 500)
|
|
|
|
return () => clearTimeout(timer)
|
|
}, [topicRefreshTrigger])
|
|
|
|
// Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时)
|
|
useEffect(() => {
|
|
if (topics.length === 0 || status !== 'connected') {
|
|
return
|
|
}
|
|
// 用户已经选中了某个 topic → 不要抢走
|
|
if (selectedTopic) {
|
|
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, selectedTopic, 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 = createTopic(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
|
|
case 'stop':
|
|
cmd = { type: 'stop_execution' }
|
|
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 handleStopExecution = useCallback(() => {
|
|
const cmd = handleStop()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [sendMessage, handleCommand, handleStop])
|
|
|
|
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 handleRefreshTopics = useCallback(() => {
|
|
if (!sessionId) return
|
|
const cmd = requestTopicList()
|
|
if (cmd) {
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}
|
|
}, [sessionId, requestTopicList, handleCommand, sendMessage])
|
|
|
|
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 handleDeleteTopic = useCallback(
|
|
(topicId: string) => {
|
|
const cmd = deleteTopic(topicId)
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
// 如果删除的是当前选中话题,清空选中状态和消息
|
|
if (topicId === selectedTopic) {
|
|
selectTopic('')
|
|
clearMessages()
|
|
}
|
|
},
|
|
[sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages]
|
|
)
|
|
|
|
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])
|
|
|
|
// 连接就绪时自动拉取记忆、技能和待办列表
|
|
useEffect(() => {
|
|
if (status === 'connected') {
|
|
const memCmd = requestMemoryList()
|
|
handleCommand(memCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(memCmd) })
|
|
const skillCmd = requestSkillList()
|
|
handleCommand(skillCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(skillCmd) })
|
|
const todoCmd = requestTodoList()
|
|
handleCommand(todoCmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) })
|
|
}
|
|
}, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList, requestTodoList])
|
|
|
|
const handleRefreshMemories = useCallback(() => {
|
|
const cmd = requestMemoryList()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [handleCommand, sendMessage, requestMemoryList])
|
|
|
|
const handleRefreshSkills = useCallback(() => {
|
|
const cmd = requestSkillList()
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [handleCommand, sendMessage, requestSkillList])
|
|
|
|
const sendMemoryCommand = useCallback((cmd: Command) => {
|
|
handleCommand(cmd)
|
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
|
}, [handleCommand, sendMessage])
|
|
|
|
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 handleSwitchChannel = useCallback(
|
|
(channelId: string) => {
|
|
if (channelId === selectedChannel) return
|
|
lastAutoSwitchedTopicRef.current = null
|
|
selectChannel(channelId)
|
|
},
|
|
[selectedChannel, selectChannel]
|
|
)
|
|
|
|
const handleSelectSession = useCallback(
|
|
(sessionId: string) => {
|
|
if (sessionId === selectedSessionId) return
|
|
lastAutoSwitchedTopicRef.current = null
|
|
selectSession(sessionId)
|
|
},
|
|
[selectedSessionId, selectSession]
|
|
)
|
|
|
|
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])
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/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-[var(--accent-cyan)]" />
|
|
<h1 className="text-xl font-bold tracking-tight">
|
|
<span className="text-[var(--text-primary)]">Pico</span>
|
|
<span className="text-[var(--accent-cyan)] glow-text">Bot</span>
|
|
</h1>
|
|
</div>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<ConnectionStatus status={status} />
|
|
{/* 主题切换按钮 */}
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<button
|
|
onClick={() => setTheme(prev => prev === 'dark' ? 'light' : 'dark')}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
|
|
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
|
|
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
>
|
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowThinking(prev => !prev)}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
|
showThinking
|
|
? 'text-purple-400 hover:text-purple-300 bg-purple-500/10 hover:bg-purple-500/20'
|
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
|
|
}`}
|
|
title={showThinking ? '隐藏思考过程' : '显示思考过程'}
|
|
aria-label="Toggle thinking display"
|
|
>
|
|
<Brain className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
|
<ChannelSelector
|
|
channels={channels}
|
|
selectedChannel={selectedChannel}
|
|
onSelectChannel={handleSwitchChannel}
|
|
/>
|
|
<SessionSelector
|
|
sessions={sessions}
|
|
selectedSessionId={selectedSessionId}
|
|
onSelectSession={handleSelectSession}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex flex-1 overflow-hidden relative">
|
|
{/* Left Sidebar */}
|
|
<div className={`w-72 shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
{/* Tab 栏 */}
|
|
<div className="flex border-b border-[var(--border-color)]">
|
|
<button
|
|
onClick={() => setSidebarTab('topics')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
sidebarTab === 'topics'
|
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
话题
|
|
</button>
|
|
<button
|
|
onClick={() => setSidebarTab('scheduler')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
sidebarTab === 'scheduler'
|
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
定时任务
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
{sidebarTab === 'topics' ? (
|
|
<TopicList
|
|
sessionId={sessionId}
|
|
topics={topics}
|
|
currentTopicId={selectedTopic}
|
|
isReadOnly={isReadOnly}
|
|
onCreateTopic={handleCreateTopic}
|
|
onRefresh={handleRefreshTopics}
|
|
onSwitchTopic={handleSwitchTopic}
|
|
onDeleteTopic={handleDeleteTopic}
|
|
/>
|
|
) : (
|
|
<SchedulerJobList
|
|
jobs={schedulerJobs}
|
|
onRefresh={handleRefreshSchedulerJobs}
|
|
onViewJob={handleViewSchedulerJob}
|
|
sessionId={sessionId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center - Chat */}
|
|
<div className="flex-1 min-w-0 bg-[var(--bg-primary)] flex flex-col">
|
|
{/* Scheduler job view back bar */}
|
|
{schedulerView && (
|
|
<div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4">
|
|
<button
|
|
onClick={handleExitSchedulerJobView}
|
|
className="flex items-center gap-1.5 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span>返回定时任务列表</span>
|
|
</button>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]">
|
|
<Clock className="h-4 w-4 text-amber-400" />
|
|
<span className="text-[var(--text-muted)]">定时任务:</span>
|
|
<span className="text-[var(--text-primary)] font-medium font-mono text-xs truncate max-w-[250px]">{schedulerView.description}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-[var(--text-muted)]">通道:</span>
|
|
<span className="text-[var(--text-secondary)]">{schedulerView.channel}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Sub-agent back bar */}
|
|
{subAgentView && (
|
|
<div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4">
|
|
<button
|
|
onClick={handleExitSubAgentView}
|
|
className="flex items-center gap-1.5 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span>返回主会话</span>
|
|
</button>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]">
|
|
<Bot className="h-4 w-4 text-violet-400" />
|
|
<span className="text-[var(--text-muted)]">子智能体:</span>
|
|
<span className="text-[var(--text-primary)] font-medium">{subAgentView.description}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-[var(--text-muted)]">类型:</span>
|
|
<span className="text-[var(--text-secondary)]">{subAgentView.subagentType || '...'}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-[var(--divider-color)]" />
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="text-[var(--text-muted)]">状态:</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-[var(--text-secondary)]'
|
|
}`}>
|
|
{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
|
|
key={selectedTopic ?? 'no-topic'}
|
|
messages={chatMessages}
|
|
isLoading={isLoading}
|
|
isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
|
|
channelName={
|
|
schedulerView ? `定时任务: ${schedulerView.description}` :
|
|
subAgentView ? `子智能体: ${subAgentView.description}` :
|
|
(session?.title ?? channels.find(c => c.id === selectedChannel)?.name ?? 'PicoBot')
|
|
}
|
|
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
|
onNavigateToSubAgent={handleNavigateToSubAgent}
|
|
onStop={handleStopExecution}
|
|
showThinking={showThinking}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */}
|
|
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}>
|
|
<div className={`w-80 h-full flex flex-col ${memoryPanelOpen ? '' : 'invisible'}`}>
|
|
{/* Tab 栏 */}
|
|
<div className="shrink-0 flex border-b border-[var(--border-color)]">
|
|
<button
|
|
onClick={() => setRightPanelTab('memory')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
rightPanelTab === 'memory'
|
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
记忆
|
|
</button>
|
|
<button
|
|
onClick={() => setRightPanelTab('skill')}
|
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
|
rightPanelTab === 'skill'
|
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
技能
|
|
</button>
|
|
<button onClick={() => toggleMemoryPanel(false)} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
{/* Panel content */}
|
|
<div className="flex-1 min-h-0">
|
|
{rightPanelTab === 'memory' ? (
|
|
<MemoryPanel
|
|
memories={memories}
|
|
onRefresh={handleRefreshMemories}
|
|
onCreateMemory={createMemory}
|
|
onUpdateMemory={updateMemory}
|
|
onDeleteMemory={deleteMemory}
|
|
sendCommand={sendMemoryCommand}
|
|
/>
|
|
) : (
|
|
<SkillList
|
|
skills={skills}
|
|
onRefresh={handleRefreshSkills}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reopen button — visible when panel is collapsed */}
|
|
{!memoryPanelOpen && (
|
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
|
|
<button
|
|
onClick={() => toggleMemoryPanel(true)}
|
|
className="flex items-center gap-1.5 px-2 py-4 rounded-l-xl bg-[var(--bg-secondary)]/80 backdrop-blur-sm border border-r-0 border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg"
|
|
title="展开记忆面板"
|
|
>
|
|
<PanelRightOpen className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 悬浮 Todo 面板 */}
|
|
<TodoPanel
|
|
todos={todos}
|
|
requestTodoList={requestTodoList}
|
|
sendCommand={sendMemoryCommand}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|