PicoBot/web/src/App.tsx
oudecheng bf724b133c feat(chat): 支持子智能体栈和面包屑导航功能
- 引入子智能体栈结构以支持多层子智能体导航
- 替换原有单一子智能体视图为子智能体栈管理消息和状态
- 添加navigateToSubAgentLevel方法,支持通过面包屑快速跳转子智能体层级
- 调整子智能体面包屑条UI,显示完整层级的描述、类型和状态
- 优化子智能体消息流和状态更新逻辑,保持栈顶视图同步
- 更新退出子智能体视图逻辑以支持栈弹出操作
- 添加主会话入口和各层子智能体的切换按钮及状态颜色显示
2026-06-18 14:53:05 +08:00

773 lines
29 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon, ChevronRight } 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 { getGatewaySettings, buildWsUrl, type GatewaySettings } from './components/Settings/SettingsModal'
import { ConfigPage } from './components/Settings/ConfigPage'
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'
function getInitialSettings(): GatewaySettings {
return getGatewaySettings()
}
function App() {
const [gatewaySettings, setGatewaySettings] = useState<GatewaySettings>(getInitialSettings)
const wsUrl = buildWsUrl(gatewaySettings)
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
const {
// 连接状态
isConnected,
// Session 状态
session,
sessionId,
chatId,
// Topic 状态
topics,
selectedTopic,
// 消息状态
messages,
isLoading,
isReadOnly,
// 子智能体视图
subAgentView,
subAgentStack,
// 记忆
memories,
requestMemoryList,
createMemory,
updateMemory,
deleteMemory,
// 技能
skills,
requestSkillList,
todos,
setTodos,
requestTodoList,
requestSubAgentTodoList,
// 定时任务
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
// 通道
channels,
selectedChannel,
selectChannel,
requestChannelList,
// Session
sessions,
selectedSessionId,
selectSession,
// 方法
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
setSendMessage,
selectTopic,
createTopic,
switchTopic,
deleteTopic,
requestSessionList,
requestTopicList,
topicRefreshTrigger,
enterSubAgentView,
exitSubAgentView,
navigateToSubAgentLevel,
handleStop,
} = useChat()
const { status, sendMessage } = useWebSocket({
url: wsUrl,
onMessage: handleServerMessage,
})
// 将 sendMessage 注入到 useChat供 handleServerMessage 内部发送命令
useEffect(() => {
setSendMessage(sendMessage)
}, [setSendMessage, sendMessage])
// ---- 主题状态 ----
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'
})
const [configPageOpen, setConfigPageOpen] = useState(false)
const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port })
}, [])
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) })
}
}, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList])
// 连接就绪、切换 topic、或进出子代理视图时刷新 todo 列表
const prevTodoTriggerRef = useRef<string>('')
useEffect(() => {
if (status !== 'connected') return
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`
if (key === prevTodoTriggerRef.current) return
prevTodoTriggerRef.current = key
setTodos([]) // 先清空,防止切换时短暂显示旧 scope 的 todos
const todoCmd = subAgentView?.taskId
? requestSubAgentTodoList(subAgentView.taskId)
: requestTodoList()
handleCommand(todoCmd)
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) })
}, [status, selectedTopic, subAgentView, handleCommand, sendMessage, requestTodoList, requestSubAgentTodoList, setTodos])
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])
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
const refreshTodoList = useCallback((): Command => {
return subAgentView?.taskId
? requestSubAgentTodoList(subAgentView.taskId)
: requestTodoList()
}, [subAgentView, requestTodoList, requestSubAgentTodoList])
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>
<button
onClick={() => setConfigPageOpen(true)}
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="系统配置"
aria-label="System config"
>
<SettingsIcon 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 breadcrumb 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-2">
<button
onClick={handleExitSubAgentView}
className="flex items-center gap-1 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors shrink-0"
title="返回上一级"
>
<ArrowLeft className="h-4 w-4" />
</button>
{/* Breadcrumb: 主会话 */}
<button
onClick={() => navigateToSubAgentLevel(-1)}
className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
title="返回主会话"
>
<Bot className="h-3.5 w-3.5" />
<span></span>
</button>
{/* Breadcrumb: each sub-agent level */}
{subAgentStack.map((level, idx) => {
const isLast = idx === subAgentStack.length - 1
const statusText =
level.status === 'completed' ? '已完成' :
level.status === 'failed' ? '失败' :
level.status === 'timeout' ? '超时' :
level.status === 'running' ? '执行中' :
level.status === 'loading' ? '加载中...' :
level.status
const statusColor =
level.status === 'completed' ? 'text-emerald-400' :
level.status === 'failed' ? 'text-red-400' :
level.status === 'timeout' ? 'text-amber-400' :
level.status === 'running' ? 'text-amber-400' :
'text-[var(--text-secondary)]'
return (
<div key={level.taskId} className="flex items-center gap-2 min-w-0">
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)] shrink-0" />
{isLast ? (
<div className="flex items-center gap-2 text-sm min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate">{level.description}</span>
{level.subagentType && (
<span className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] px-1.5 py-0.5 rounded shrink-0">{level.subagentType}</span>
)}
<span className={`text-xs font-medium shrink-0 ${statusColor}`}>{statusText}</span>
</div>
) : (
<button
onClick={() => navigateToSubAgentLevel(idx)}
className="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors min-w-0"
>
<span className="truncate">{level.description}</span>
{level.subagentType && (
<span className="text-xs bg-[var(--overlay-dim)] px-1.5 py-0.5 rounded shrink-0">{level.subagentType}</span>
)}
</button>
)}
</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}
todoPanel={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
/>
}
/>
</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>
{/* 系统配置页面 */}
{configPageOpen && (
<ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />
)}
</div>
)
}
export default App