feat(ui): 添加子 Agent 进度显示功能

当 build agent 调用 guide/explore 等子 agent 时,
用户可以在 web 页面实时看到子 agent 的执行进度

实现方案:
- Core: 使用 EventEmitter 模式发射子 agent 事件
- Server: 订阅事件并转发到 WebSocket
- UI: 处理事件并渲染 SubagentProgress 组件

新增文件:
- packages/core/src/agent/events.ts
- packages/ui/src/components/SubagentProgress.tsx

修改文件:
- core: executor.ts, manager.ts, types.ts, task.ts
- server: adapter.ts, types.ts
- ui: useChat.ts, types.ts
- web: Chat.tsx
This commit is contained in:
2025-12-16 19:38:36 +08:00
parent f0ff887129
commit 08d481483c
13 changed files with 921 additions and 19 deletions
+92
View File
@@ -912,3 +912,95 @@ export interface ToolEndPayload {
duration?: number;
}
// ============ 子 Agent 事件 Payload ============
/** 子 Agent 开始事件 Payload */
export interface SubagentStartPayload {
type: 'subagent:start';
sessionId: string;
agentId: string;
agentName: string;
description: string;
parentToolCallId?: string;
}
/** 子 Agent 结束事件 Payload */
export interface SubagentEndPayload {
type: 'subagent:end';
sessionId: string;
agentId: string;
agentName: string;
success: boolean;
duration: number;
error?: string;
}
/** 子 Agent 流式输出事件 Payload */
export interface SubagentStreamPayload {
type: 'subagent:stream';
sessionId: string;
agentId: string;
content: string;
}
/** 子 Agent 工具调用开始事件 Payload */
export interface SubagentToolStartPayload {
type: 'subagent:tool_start';
sessionId: string;
agentId: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}
/** 子 Agent 工具调用结束事件 Payload */
export interface SubagentToolEndPayload {
type: 'subagent:tool_end';
sessionId: string;
agentId: string;
toolCallId: string;
status: 'completed' | 'error';
result?: unknown;
error?: string;
duration?: number;
}
/** 子 Agent 事件 Payload 联合类型 */
export type SubagentEventPayload =
| SubagentStartPayload
| SubagentEndPayload
| SubagentStreamPayload
| SubagentToolStartPayload
| SubagentToolEndPayload;
/** 子 Agent 工具调用信息(用于 UI 展示) */
export interface SubagentToolInfo {
id: string;
toolName: string;
status: ToolCallStatus;
args: Record<string, unknown>;
result?: unknown;
error?: string;
duration?: number;
}
/** 子 Agent 状态(用于 UI 展示) */
export interface SubagentState {
/** 子 Agent 实例 ID */
id: string;
/** Agent 类型名称(如 guide, explore */
name: string;
/** 任务描述 */
description: string;
/** 执行状态 */
status: 'running' | 'completed' | 'error';
/** 嵌套的工具调用列表 */
tools: SubagentToolInfo[];
/** 流式输出内容 */
streamContent: string;
/** 执行时长 (ms) */
duration?: number;
/** 错误信息 */
error?: string;
}
@@ -0,0 +1,243 @@
/**
* Subagent Progress Component
*
* 展示子 Agent 执行进度的可折叠组件
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bot,
ChevronDown,
ChevronRight,
Wrench,
Clock,
Loader2,
CheckCircle2,
AlertCircle,
} from 'lucide-react';
import { cn } from '../utils/cn';
import { getAgentDisplayName } from '../utils/agent';
import type { SubagentState, SubagentToolInfo } from '../api/types';
interface SubagentProgressProps {
subagent: SubagentState;
}
/**
* 子 Agent 进度显示组件
*/
export function SubagentProgress({ subagent }: SubagentProgressProps) {
const [expanded, setExpanded] = useState(true);
const isComplete = subagent.status === 'completed' || subagent.status === 'error';
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="border border-line rounded-lg overflow-hidden bg-surface-subtle/50 mb-3"
>
{/* 头部:Agent 名称、描述、状态 */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-muted/50 cursor-pointer"
>
<Bot size={14} className="text-primary-400 flex-shrink-0" />
<span className="font-medium text-fg-secondary">
{getAgentDisplayName(subagent.name)}
</span>
<span className="text-fg-subtle truncate flex-1 text-left">{subagent.description}</span>
{getSubagentStatusIcon(subagent.status)}
{subagent.duration && (
<span className="text-xs text-fg-subtle">{formatDuration(subagent.duration)}</span>
)}
<span className="text-fg-subtle">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</button>
{/* 展开的内容 */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-line overflow-hidden"
>
<div className="px-3 py-2 space-y-2">
{/* 工具调用列表 */}
{subagent.tools.length > 0 && (
<div className="space-y-1">
{subagent.tools.map((tool) => (
<SubagentToolItem key={tool.id} tool={tool} />
))}
</div>
)}
{/* 流式文本输出(如果有) */}
{subagent.streamContent && (
<div className="text-xs text-fg-muted bg-surface-base rounded p-2 max-h-32 overflow-y-auto">
<pre className="whitespace-pre-wrap font-mono">{subagent.streamContent}</pre>
{!isComplete && (
<motion.span
animate={{ opacity: [1, 0] }}
transition={{ duration: 0.8, repeat: Infinity, repeatType: 'reverse' }}
className="inline-block w-1.5 h-3 bg-primary-400 ml-0.5 rounded-sm align-middle"
/>
)}
</div>
)}
{/* 错误信息 */}
{subagent.error && (
<div className="text-xs text-red-400 bg-red-950/30 rounded p-2">
<span className="font-medium">Error:</span> {subagent.error}
</div>
)}
{/* 如果没有工具调用和流式内容,显示等待状态 */}
{subagent.tools.length === 0 && !subagent.streamContent && !isComplete && (
<div className="flex items-center gap-2 text-xs text-fg-subtle py-1">
<Loader2 size={12} className="animate-spin" />
<span>Processing...</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
/**
* 折叠版子 Agent 进度(用于完成后的简洁展示)
*/
export function SubagentProgressCompact({ subagent }: SubagentProgressProps) {
const toolCount = subagent.tools.length;
const completedCount = subagent.tools.filter(
(t) => t.status === 'completed' || t.status === 'error'
).length;
return (
<div className="inline-flex items-center gap-2 px-2 py-1 rounded bg-surface-subtle/50 text-xs text-fg-muted">
<Bot size={12} className="text-primary-400" />
<span className="font-medium">{getAgentDisplayName(subagent.name)}</span>
{toolCount > 0 && (
<span className="text-fg-subtle">
({completedCount}/{toolCount} tools)
</span>
)}
{subagent.duration && <span className="text-fg-subtle">{formatDuration(subagent.duration)}</span>}
{getSubagentStatusIcon(subagent.status, 12)}
</div>
);
}
/**
* 子 Agent 工具调用项
*/
interface SubagentToolItemProps {
tool: SubagentToolInfo;
}
function SubagentToolItem({ tool }: SubagentToolItemProps) {
const [expanded, setExpanded] = useState(false);
const hasDetails = tool.result !== undefined || tool.error !== undefined;
return (
<div className="text-xs">
<button
onClick={() => hasDetails && setExpanded(!expanded)}
className={cn(
'flex items-center gap-1.5 w-full py-0.5',
hasDetails && 'hover:bg-surface-muted/30 cursor-pointer',
!hasDetails && 'cursor-default'
)}
>
<span className="text-fg-subtle"></span>
<Wrench size={10} className="text-fg-subtle flex-shrink-0" />
<span className="font-mono text-fg-muted truncate">{tool.toolName}</span>
{tool.duration && (
<span className="text-fg-subtle text-[10px]">({formatDuration(tool.duration)})</span>
)}
{getToolStatusIcon(tool.status, 10)}
{hasDetails && (
<span className="text-fg-subtle ml-auto">
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</span>
)}
</button>
{/* 展开的详情 */}
<AnimatePresence>
{expanded && hasDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="ml-6 overflow-hidden"
>
<div className="py-1 space-y-1">
{tool.result !== undefined && (
<pre className="bg-surface-base rounded p-1.5 text-[10px] text-green-400 max-h-24 overflow-y-auto overflow-x-auto">
{typeof tool.result === 'string'
? tool.result.slice(0, 500) + (tool.result.length > 500 ? '...' : '')
: JSON.stringify(tool.result, null, 2).slice(0, 500)}
</pre>
)}
{tool.error && (
<pre className="bg-red-950/30 rounded p-1.5 text-[10px] text-red-300 max-h-24 overflow-y-auto">
{tool.error}
</pre>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/**
* 获取子 Agent 状态图标
*/
function getSubagentStatusIcon(status: SubagentState['status'], size: number = 14) {
switch (status) {
case 'running':
return <Loader2 size={size} className="text-blue-500 animate-spin" />;
case 'completed':
return <CheckCircle2 size={size} className="text-green-500" />;
case 'error':
return <AlertCircle size={size} className="text-red-500" />;
}
}
/**
* 获取工具状态图标
*/
function getToolStatusIcon(status: SubagentToolInfo['status'], size: number = 14) {
switch (status) {
case 'pending':
return <Clock size={size} className="text-yellow-500" />;
case 'running':
return <Loader2 size={size} className="text-blue-500 animate-spin" />;
case 'completed':
return <CheckCircle2 size={size} className="text-green-500" />;
case 'error':
return <AlertCircle size={size} className="text-red-500" />;
}
}
/**
* 格式化执行时长
*/
function formatDuration(ms?: number): string {
if (!ms) return '';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
+127
View File
@@ -14,6 +14,13 @@ import type {
MessagePart,
ToolMessagePart,
AgentModeType,
SubagentStartPayload,
SubagentEndPayload,
SubagentStreamPayload,
SubagentToolStartPayload,
SubagentToolEndPayload,
SubagentState,
SubagentToolInfo,
} from '../api/types.js';
interface UseChatOptions {
@@ -38,6 +45,8 @@ interface ChatState {
autoApprove: boolean;
/** 当前正在执行的 Agent 名称 */
currentAgent: string;
/** 当前正在执行的子 Agent 状态 */
currentSubagent: SubagentState | null;
}
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError }: UseChatOptions) {
@@ -50,6 +59,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
agentMode: 'build',
autoApprove: false,
currentAgent: 'build',
currentSubagent: null,
});
const wsRef = useRef<WebSocket | null>(null);
@@ -334,6 +344,122 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
}));
}
break;
// ============ 子 Agent 事件处理 ============
case 'subagent:start': {
const payload = message.payload as SubagentStartPayload;
setState((prev) => ({
...prev,
currentAgent: payload.agentName,
currentSubagent: {
id: payload.agentId,
name: payload.agentName,
description: payload.description,
status: 'running',
tools: [],
streamContent: '',
},
}));
break;
}
case 'subagent:tool_start': {
const payload = message.payload as SubagentToolStartPayload;
setState((prev) => {
if (!prev.currentSubagent || prev.currentSubagent.id !== payload.agentId) {
return prev;
}
const newTool: SubagentToolInfo = {
id: payload.toolCallId,
toolName: payload.toolName,
status: 'running',
args: payload.args,
};
return {
...prev,
currentSubagent: {
...prev.currentSubagent,
tools: [...prev.currentSubagent.tools, newTool],
},
};
});
break;
}
case 'subagent:stream': {
const payload = message.payload as SubagentStreamPayload;
setState((prev) => {
if (!prev.currentSubagent || prev.currentSubagent.id !== payload.agentId) {
return prev;
}
return {
...prev,
currentSubagent: {
...prev.currentSubagent,
streamContent: prev.currentSubagent.streamContent + payload.content,
},
};
});
break;
}
case 'subagent:tool_end': {
const payload = message.payload as SubagentToolEndPayload;
setState((prev) => {
if (!prev.currentSubagent || prev.currentSubagent.id !== payload.agentId) {
return prev;
}
const updatedTools = prev.currentSubagent.tools.map((tool) => {
if (tool.id === payload.toolCallId) {
return {
...tool,
status: payload.status === 'completed' ? 'completed' : 'error',
result: payload.result,
error: payload.error,
duration: payload.duration,
} as SubagentToolInfo;
}
return tool;
});
return {
...prev,
currentSubagent: {
...prev.currentSubagent,
tools: updatedTools,
},
};
});
break;
}
case 'subagent:end': {
const payload = message.payload as SubagentEndPayload;
setState((prev) => {
// 只有当 agentId 匹配时才处理
if (!prev.currentSubagent || prev.currentSubagent.id !== payload.agentId) {
return prev;
}
return {
...prev,
currentAgent: prev.agentMode, // 恢复为主 Agent
currentSubagent: {
...prev.currentSubagent,
status: payload.success ? 'completed' : 'error',
duration: payload.duration,
error: payload.error,
},
};
});
// 完成后短暂延迟再清除,让 UI 能显示最终状态
setTimeout(() => {
setState((prev) => ({
...prev,
currentSubagent: null,
}));
}, 1000);
break;
}
}
} catch {
// 忽略解析错误
@@ -476,6 +602,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
agentMode: 'build',
autoApprove: false,
currentAgent: 'build',
currentSubagent: null,
});
reconnectAttemptsRef.current = 0;
+1
View File
@@ -221,6 +221,7 @@ export { Toaster } from './components/Toaster.js';
export { Skeleton, MessageSkeleton, SessionSkeleton, FileSkeleton } from './components/Skeleton.js';
export { Markdown } from './components/Markdown.js';
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js';
// Toast function (re-export from sonner)
export { toast } from 'sonner';