PicoBot/web/src/App.tsx

868 lines
34 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, PanelLeftClose, PanelLeftOpen, 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, TodoItemSummary } 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,
// 高亮消息
highlightedMessageId,
setHighlightedMessageId,
// 定时任务
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 [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try {
return localStorage.getItem('picobot-sidebar-collapsed') === 'true'
} catch {
return false
}
})
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const next = !prev
localStorage.setItem('picobot-sidebar-collapsed', String(next))
return next
})
}, [])
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, subagentType?: string) => {
const cmd = enterSubAgentView(taskId, description, subagentType)
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 handleTodoClick = useCallback((todo: TodoItemSummary) => {
if (todo.created_by_message_id) {
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
setHighlightedMessageId(null)
const msgId = todo.created_by_message_id
setTimeout(() => setHighlightedMessageId(msgId), 0)
} else {
alert('该待办的完成记录无法定位,可能是历史数据')
}
}, [setHighlightedMessageId])
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)
}
}
// 过滤无实质内容的 merged_tool无结果且非等待中
return result.filter(msg => {
if (msg.type !== 'merged_tool') return true
if (msg.status === 'pending') return true
return !!(msg.resultContent && msg.resultContent.trim())
})
}, [messages])
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置
const viewKey = useMemo(() => {
if (schedulerView) return `scheduler:${schedulerView.jobId}`
if (subAgentView) return `subagent:${subAgentView.taskId}`
return 'main'
}, [schedulerView, subAgentView])
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 — smooth width animation via will-change + overflow-hidden */}
<div
className={`shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${sidebarCollapsed ? 'w-11' : 'w-72'} ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
{/* Tab 栏 + collapse toggle */}
<div className="flex border-b border-[var(--border-color)]" style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}>
{sidebarCollapsed ? (
/* 收起态:紧凑竖排 tab 按钮 */
<div className="flex flex-col w-full">
<button
onClick={() => { setSidebarTab('topics'); setSidebarCollapsed(false); localStorage.setItem('picobot-sidebar-collapsed', 'false'); }}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="话题"
>
<br/>
</button>
<button
onClick={() => { setSidebarTab('scheduler'); setSidebarCollapsed(false); localStorage.setItem('picobot-sidebar-collapsed', 'false'); }}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="定时任务"
>
<br/>
</button>
{/* 展开按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center py-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<button
onClick={() => setSidebarTab('topics')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors whitespace-nowrap ${
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 whitespace-nowrap ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
{/* 收起按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center px-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors shrink-0"
title="收起侧栏"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</>
)}
</div>
<div className="flex-1 overflow-hidden" style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}>
{sidebarCollapsed ? null : 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}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
todoPanel={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
}
/>
</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