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 { 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 { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@ -40,6 +40,7 @@ function App() {
isReadOnly,
// 子智能体视图
subAgentView,
subAgentStack,
// 记忆
memories,
requestMemoryList,
@ -85,6 +86,7 @@ function App() {
topicRefreshTrigger,
enterSubAgentView,
exitSubAgentView,
navigateToSubAgentLevel,
handleStop,
} = useChat()
@ -607,45 +609,66 @@ function App() {
</div>
</div>
)}
{/* Sub-agent back bar */}
{/* 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-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
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" />
<span></span>
</button>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]">
<Bot className="h-4 w-4 text-violet-400" />
<span className="text-[var(--text-muted)]">:</span>
<span className="text-[var(--text-primary)] font-medium">{subAgentView.description}</span>
</div>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-[var(--text-muted)]">:</span>
<span className="text-[var(--text-secondary)]">{subAgentView.subagentType || '...'}</span>
</div>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-[var(--text-muted)]">:</span>
<span className={`font-medium ${
subAgentView.status === 'completed' ? 'text-emerald-400' :
subAgentView.status === 'failed' ? 'text-red-400' :
subAgentView.status === 'timeout' ? 'text-amber-400' :
subAgentView.status === 'running' ? 'text-amber-400' :
{/* 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)]'
}`}>
{subAgentView.status === 'completed' ? '已完成' :
subAgentView.status === 'failed' ? '失败' :
subAgentView.status === 'timeout' ? '超时' :
subAgentView.status === 'running' ? '执行中' :
subAgentView.status === 'loading' ? '加载中...' :
subAgentView.status}
</span>
</div>
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">

View File

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