PicoBot/web/src/App.tsx
oudecheng 6962ea2eb1 feat(todo): 优化悬浮待办面板功能
- 将悬浮待办面板集成到聊天组件上方,避免重复渲染
- 调整状态样式配置,简化颜色和样式管理
- 实现待办面板位置拖拽功能,支持位置持久化保存
- 优化折叠与展开交互,改进分组标题和列表项显示样式
- 设计迷你和完整两种面板展现形态,提升界面灵活性
- 添加刷新按钮及自动展开待办新条目功能
- 精简和改进待办项展示,提高内容可读性和界面美观度
2026-06-18 14:25:41 +08:00

750 lines
28 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 } 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,
// 记忆
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,
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 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}
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