feat(chat): 支持子智能体栈和面包屑导航功能

- 引入子智能体栈结构以支持多层子智能体导航
- 替换原有单一子智能体视图为子智能体栈管理消息和状态
- 添加navigateToSubAgentLevel方法,支持通过面包屑快速跳转子智能体层级
- 调整子智能体面包屑条UI,显示完整层级的描述、类型和状态
- 优化子智能体消息流和状态更新逻辑,保持栈顶视图同步
- 更新退出子智能体视图逻辑以支持栈弹出操作
- 添加主会话入口和各层子智能体的切换按钮及状态颜色显示
This commit is contained in:
oudecheng 2026-06-18 14:53:05 +08:00
parent 175e7fc01b
commit bf724b133c
2 changed files with 130 additions and 65 deletions

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@ -40,6 +40,7 @@ function App() {
isReadOnly, isReadOnly,
// 子智能体视图 // 子智能体视图
subAgentView, subAgentView,
subAgentStack,
// 记忆 // 记忆
memories, memories,
requestMemoryList, requestMemoryList,
@ -85,6 +86,7 @@ function App() {
topicRefreshTrigger, topicRefreshTrigger,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
navigateToSubAgentLevel,
handleStop, handleStop,
} = useChat() } = useChat()
@ -607,45 +609,66 @@ function App() {
</div> </div>
</div> </div>
)} )}
{/* Sub-agent back bar */} {/* Sub-agent breadcrumb bar */}
{subAgentView && ( {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"> <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 <button
onClick={handleExitSubAgentView} onClick={handleExitSubAgentView}
className="flex items-center gap-1.5 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors" 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" /> <ArrowLeft className="h-4 w-4" />
<span></span>
</button> </button>
<div className="h-4 w-px bg-[var(--divider-color)]" /> {/* Breadcrumb: 主会话 */}
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]"> <button
<Bot className="h-4 w-4 text-violet-400" /> onClick={() => navigateToSubAgentLevel(-1)}
<span className="text-[var(--text-muted)]">:</span> className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
<span className="text-[var(--text-primary)] font-medium">{subAgentView.description}</span> title="返回主会话"
</div> >
<div className="h-4 w-px bg-[var(--divider-color)]" /> <Bot className="h-3.5 w-3.5" />
<div className="flex items-center gap-1.5 text-sm"> <span></span>
<span className="text-[var(--text-muted)]">:</span> </button>
<span className="text-[var(--text-secondary)]">{subAgentView.subagentType || '...'}</span> {/* Breadcrumb: each sub-agent level */}
</div> {subAgentStack.map((level, idx) => {
<div className="h-4 w-px bg-[var(--divider-color)]" /> const isLast = idx === subAgentStack.length - 1
<div className="flex items-center gap-1.5 text-sm"> const statusText =
<span className="text-[var(--text-muted)]">:</span> level.status === 'completed' ? '已完成' :
<span className={`font-medium ${ level.status === 'failed' ? '失败' :
subAgentView.status === 'completed' ? 'text-emerald-400' : level.status === 'timeout' ? '超时' :
subAgentView.status === 'failed' ? 'text-red-400' : level.status === 'running' ? '执行中' :
subAgentView.status === 'timeout' ? 'text-amber-400' : level.status === 'loading' ? '加载中...' :
subAgentView.status === 'running' ? 'text-amber-400' : 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)]' 'text-[var(--text-secondary)]'
}`}> return (
{subAgentView.status === 'completed' ? '已完成' : <div key={level.taskId} className="flex items-center gap-2 min-w-0">
subAgentView.status === 'failed' ? '失败' : <ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)] shrink-0" />
subAgentView.status === 'timeout' ? '超时' : {isLast ? (
subAgentView.status === 'running' ? '执行中' : <div className="flex items-center gap-2 text-sm min-w-0">
subAgentView.status === 'loading' ? '加载中...' : <span className="text-[var(--text-primary)] font-medium truncate">{level.description}</span>
subAgentView.status} {level.subagentType && (
</span> <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> </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>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">

View File

@ -59,8 +59,9 @@ interface UseChatReturn {
// 是否只读 // 是否只读
isReadOnly: boolean isReadOnly: boolean
// 子智能体视图 // 子智能体视图(栈结构,支持面包屑导航)
subAgentView: SubAgentView | null subAgentView: SubAgentView | null
subAgentStack: SubAgentView[]
// 方法 // 方法
handleMessage: (content: string, attachments?: Attachment[]) => void handleMessage: (content: string, attachments?: Attachment[]) => void
@ -86,6 +87,7 @@ interface UseChatReturn {
// 子智能体导航方法 // 子智能体导航方法
enterSubAgentView: (taskId: string, description: string) => Command enterSubAgentView: (taskId: string, description: string) => Command
exitSubAgentView: () => void exitSubAgentView: () => void
navigateToSubAgentLevel: (index: number) => void
// 记忆状态 // 记忆状态
memories: MemorySummary[] memories: MemorySummary[]
@ -149,7 +151,8 @@ export function useChat(): UseChatReturn {
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0) const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
const [sessions, setSessions] = useState<SessionSummary[]>([]) const [sessions, setSessions] = useState<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null) const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null) const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
const [memories, setMemories] = useState<MemorySummary[]>([]) const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([]) const [todos, setTodos] = useState<TodoItemSummary[]>([])
@ -302,11 +305,12 @@ export function useChat(): UseChatReturn {
// stream_delta: accumulate into existing message by ID, or create new // stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta const msg = message as StreamDelta
setSubAgentView((prev) => { setSubAgentStack((prev) => {
if (!prev) return prev if (prev.length === 0) return prev
const existingIdx = prev.messages.findIndex(m => m.id === msg.id && m.type === 'message') const top = prev[prev.length - 1]
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...prev.messages] const updated = [...top.messages]
const existing = updated[existingIdx] const existing = updated[existingIdx]
updated[existingIdx] = { updated[existingIdx] = {
...existing, ...existing,
@ -315,10 +319,15 @@ export function useChat(): UseChatReturn {
? (existing.reasoningContent || '') + msg.reasoning_delta ? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent, : existing.reasoningContent,
} }
return { ...prev, messages: updated } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
} }
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message)
return chatMsg ? { ...prev, messages: [...prev.messages, chatMsg] } : prev if (!chatMsg) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
}) })
return return
} }
@ -327,17 +336,22 @@ export function useChat(): UseChatReturn {
// Other messages: assistant_response replaces streamed message by ID // Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) { if (chatMsg) {
setSubAgentView((prev) => { setSubAgentStack((prev) => {
if (!prev) return prev if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
const existingIdx = prev.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...prev.messages] const updated = [...top.messages]
updated[existingIdx] = chatMsg updated[existingIdx] = chatMsg
return { ...prev, messages: updated } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
} }
} }
return { ...prev, messages: [...prev.messages, chatMsg] } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
}) })
} }
} }
@ -366,16 +380,18 @@ export function useChat(): UseChatReturn {
if (currentSubAgentView) { if (currentSubAgentView) {
if (message.type === 'task_messages_loaded') { if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded const msg = message as TaskMessagesLoaded
setSubAgentView((prev) => setSubAgentStack((prev) => {
prev if (prev.length === 0) return prev
? { const top = prev[prev.length - 1]
...prev, const newStack = [...prev]
newStack[newStack.length - 1] = {
...top,
subagentType: msg.subagent_type, subagentType: msg.subagent_type,
status: msg.status, status: msg.status,
summary: msg.summary, summary: msg.summary,
} }
: prev return newStack
) })
return return
} }
@ -756,7 +772,7 @@ export function useChat(): UseChatReturn {
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId) setSelectedTopic(topicId)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
}, []) }, [])
const createTopic = useCallback((title?: string): Command => { const createTopic = useCallback((title?: string): Command => {
@ -802,7 +818,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
setIsLoading(true) setIsLoading(true)
}, [selectedChannel]) }, [selectedChannel])
@ -812,7 +828,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
setIsLoading(true) setIsLoading(true)
}, [selectedSessionId]) }, [selectedSessionId])
@ -849,9 +865,12 @@ export function useChat(): UseChatReturn {
status: 'loading', status: 'loading',
messages: [], messages: [],
} }
setSubAgentStack((prev) => {
const newStack = [...prev, newView]
// Sync ref immediately so WebSocket response routing works correctly // Sync ref immediately so WebSocket response routing works correctly
subAgentViewRef.current = newView subAgentViewRef.current = newView
setSubAgentView(newView) return newStack
})
return { return {
type: 'load_task_messages', type: 'load_task_messages',
task_id: taskId, task_id: taskId,
@ -859,8 +878,29 @@ export function useChat(): UseChatReturn {
}, []) }, [])
const exitSubAgentView = useCallback(() => { const exitSubAgentView = useCallback(() => {
setSubAgentStack((prev) => {
if (prev.length <= 1) {
subAgentViewRef.current = null subAgentViewRef.current = null
setSubAgentView(null) return []
}
const newStack = prev.slice(0, -1)
subAgentViewRef.current = newStack[newStack.length - 1]
return newStack
})
}, [])
const navigateToSubAgentLevel = useCallback((index: number) => {
setSubAgentStack((prev) => {
if (index < 0) {
// -1 means go back to main session (clear all)
subAgentViewRef.current = null
return []
}
if (index >= prev.length) return prev
const newStack = prev.slice(0, index + 1)
subAgentViewRef.current = newStack.length > 0 ? newStack[newStack.length - 1] : null
return newStack
})
}, []) }, [])
// 记忆方法 // 记忆方法
@ -957,6 +997,7 @@ export function useChat(): UseChatReturn {
channels, channels,
selectedChannel, selectedChannel,
subAgentView, subAgentView,
subAgentStack,
handleMessage, handleMessage,
handleCommand, handleCommand,
clearMessages, clearMessages,
@ -974,6 +1015,7 @@ export function useChat(): UseChatReturn {
selectSession, selectSession,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
navigateToSubAgentLevel,
memories, memories,
requestMemoryList, requestMemoryList,
createMemory, createMemory,