- 引入子智能体栈结构以支持多层子智能体导航 - 替换原有单一子智能体视图为子智能体栈管理消息和状态 - 添加navigateToSubAgentLevel方法,支持通过面包屑快速跳转子智能体层级 - 调整子智能体面包屑条UI,显示完整层级的描述、类型和状态 - 优化子智能体消息流和状态更新逻辑,保持栈顶视图同步 - 更新退出子智能体视图逻辑以支持栈弹出操作 - 添加主会话入口和各层子智能体的切换按钮及状态颜色显示
773 lines
29 KiB
TypeScript
773 lines
29 KiB
TypeScript
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
|