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:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user