PicoBot/web/src/App.tsx
oudecheng 0a8d21fe40 fix: 修复专家发现与前端选择多项问题
后端:
- split_frontmatter 兼容 CRLF/LF 行尾符,修复 Windows 下 EXPERT.md 解析失败
- Config::load_from 移除 fallback 到 cwd/config.json 的逻辑,避免静默加载项目目录配置导致 experts 字段缺失
- ExpertRuntime 新增 update_config 方法,save_config 时同步更新 ExpertRuntime 内部 config,sources 变更即时生效
- source_order 空数组不再兜底返回 [User, Project],尊重用户关闭所有源的意图
- from_config_with_cwd 增加诊断日志,输出 enabled/sources/cwd/discovered

前端:
- ExpertSelector 弹窗改为按钮上方居中展开
- 专家卡片 name 完整显示(break-all),路径显示在卡片底部
- "管理专家"入口仅在无专家时显示
- 每次打开下拉都重新拉取列表和选中状态
- 监听设置弹窗关闭事件刷新已选专家(处理已选专家被禁用的情况)
2026-07-08 14:53:30 +08:00

918 lines
36 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, PanelLeftClose, PanelLeftOpen, 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 [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => {
try {
return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'
} catch {
return false
}
})
const toggleRightSidebar = useCallback(() => {
setRightSidebarCollapsed(prev => {
const next = !prev
localStorage.setItem('picobot-right-sidebar-collapsed', String(next))
return next
})
}, [])
const [rightPanelTab, setRightPanelTab] = useState<'todo' | 'memory' | 'skill'>('todo')
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 [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
const [settingsClosedTick, setSettingsClosedTick] = useState(0)
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, status, handleCommand, sendMessage, requestTopicList])
// 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(() => {
const command = exitSubAgentView()
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [exitSubAgentView, sendMessage])
const handleNavigateToSubAgentLevel = useCallback((index: number) => {
const command = navigateToSubAgentLevel(index)
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [navigateToSubAgentLevel, sendMessage])
// 切换到定时任务 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) {
alert('该待办的完成记录无法定位,可能是历史数据')
return
}
// 仅定时任务视图需要退出(其消息源是另一个 chat_id
if (schedulerView) {
exitSchedulerJobView()
}
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
const msgId = todo.created_by_message_id
setHighlightedMessageId(null)
// 延迟一帧,等视图切换后消息列表渲染完成再滚动
setTimeout(() => {
setHighlightedMessageId(msgId)
}, 50)
}, [setHighlightedMessageId, schedulerView, exitSchedulerJobView])
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_toolresult 到达后才显示保留calling/pending 有 callContent 也保留
return result.filter(msg => {
if (msg.type !== 'merged_tool') return true
if (msg.status === 'calling' || 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={() => {
setConfigInitialTab('providers')
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={() => handleNavigateToSubAgentLevel(-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 === 'unknown' ? '未知' :
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={() => handleNavigateToSubAgentLevel(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}
sessionId={sessionId}
settingsClosedTick={settingsClosedTick}
onOpenSettings={() => {
setConfigInitialTab('experts')
setConfigPageOpen(true)
}}
/>
</div>
</div>
{/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
<div
className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
{rightSidebarCollapsed ? (
// 折叠态:窄条 + 展开按钮
<button
onClick={toggleRightSidebar}
className="flex items-center justify-center w-full py-4 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
>
<PanelLeftOpen className="h-4 w-4 rotate-180" />
</button>
) : (
<div className="w-80 h-full flex flex-col">
{/* Tab 栏 */}
<div className="shrink-0 flex border-b border-[var(--border-color)]">
<button
onClick={() => setRightPanelTab('todo')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'todo'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<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={toggleRightSidebar} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors shrink-0" title="收起">
<PanelLeftClose className="h-3.5 w-3.5 rotate-180" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 min-h-0">
{rightPanelTab === 'todo' ? (
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
) : rightPanelTab === 'memory' ? (
<MemoryPanel
memories={memories}
onRefresh={handleRefreshMemories}
onCreateMemory={createMemory}
onUpdateMemory={updateMemory}
onDeleteMemory={deleteMemory}
sendCommand={sendMemoryCommand}
/>
) : (
<SkillList
skills={skills}
onRefresh={handleRefreshSkills}
/>
)}
</div>
</div>
)}
</div>
</div>
{/* 系统配置页面 */}
{configPageOpen && (
<ConfigPage
onClose={() => {
setConfigPageOpen(false)
setSettingsClosedTick(t => t + 1)
}}
onSaveConnection={handleSaveConnection}
initialTab={configInitialTab}
/>
)}
</div>
)
}
export default App