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
+165
View File
@@ -0,0 +1,165 @@
/**
* Agent 事件系统
* 用于在 Core 模块和 Server 模块之间传递子 Agent 执行事件
*/
/**
* 子 Agent 事件类型
*/
export type SubagentEventType =
| 'subagent:start'
| 'subagent:end'
| 'subagent:stream'
| 'subagent:tool_start'
| 'subagent:tool_end';
/**
* 子 Agent 开始事件
*/
export interface SubagentStartEvent {
type: 'subagent:start';
sessionId: string;
agentId: string;
agentName: string;
description: string;
parentToolCallId?: string;
}
/**
* 子 Agent 结束事件
*/
export interface SubagentEndEvent {
type: 'subagent:end';
sessionId: string;
agentId: string;
agentName: string;
success: boolean;
duration: number;
error?: string;
}
/**
* 子 Agent 流式输出事件
*/
export interface SubagentStreamEvent {
type: 'subagent:stream';
sessionId: string;
agentId: string;
content: string;
}
/**
* 子 Agent 工具调用开始事件
*/
export interface SubagentToolStartEvent {
type: 'subagent:tool_start';
sessionId: string;
agentId: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}
/**
* 子 Agent 工具调用结束事件
*/
export interface SubagentToolEndEvent {
type: 'subagent:tool_end';
sessionId: string;
agentId: string;
toolCallId: string;
status: 'completed' | 'error';
result?: unknown;
error?: string;
duration?: number;
}
/**
* 子 Agent 事件联合类型
*/
export type SubagentEvent =
| SubagentStartEvent
| SubagentEndEvent
| SubagentStreamEvent
| SubagentToolStartEvent
| SubagentToolEndEvent;
/**
* 事件监听器类型
*/
export type SubagentEventListener = (event: SubagentEvent) => void;
/**
* Agent 事件发射器
* 按 sessionId 隔离事件监听,支持跨模块事件传递
*/
export class AgentEventEmitter {
private listeners = new Map<string, Set<SubagentEventListener>>();
/**
* 订阅指定会话的事件
* @param sessionId 会话 ID
* @param listener 事件监听器
* @returns 取消订阅函数
*/
on(sessionId: string, listener: SubagentEventListener): () => void {
let sessionListeners = this.listeners.get(sessionId);
if (!sessionListeners) {
sessionListeners = new Set();
this.listeners.set(sessionId, sessionListeners);
}
sessionListeners.add(listener);
// 返回取消订阅函数
return () => this.off(sessionId, listener);
}
/**
* 取消订阅
*/
off(sessionId: string, listener: SubagentEventListener): void {
const sessionListeners = this.listeners.get(sessionId);
if (sessionListeners) {
sessionListeners.delete(listener);
if (sessionListeners.size === 0) {
this.listeners.delete(sessionId);
}
}
}
/**
* 发射事件
*/
emit(event: SubagentEvent): void {
const sessionListeners = this.listeners.get(event.sessionId);
if (sessionListeners) {
for (const listener of sessionListeners) {
try {
listener(event);
} catch (error) {
console.error('[AgentEventEmitter] Listener error:', error);
}
}
}
}
/**
* 清理指定会话的所有监听器
*/
clear(sessionId: string): void {
this.listeners.delete(sessionId);
}
/**
* 检查是否有监听器
*/
hasListeners(sessionId: string): boolean {
const listeners = this.listeners.get(sessionId);
return !!listeners && listeners.size > 0;
}
}
/**
* 全局事件发射器单例
*/
export const agentEventEmitter = new AgentEventEmitter();
+77 -11
View File
@@ -18,6 +18,7 @@ import type {
import { checkBashPermission, isPathInAllowedWritePaths } from './permission-merger.js';
import { getProviderRegistry } from '../provider/index.js';
import { renderPromptTemplate, createPlanContext } from '../template/index.js';
import { agentEventEmitter } from './events.js';
/**
* Agent 执行器
@@ -54,7 +55,10 @@ export class AgentExecutor {
prompt: string,
context: AgentExecutionContext
): Promise<AgentExecutionResult> {
const { onStream, onToolCall, onToolResult, images } = context;
const { onStream, onToolCall, onToolResult, images, sessionId, agentId, emitEvents } = context;
// 是否发射子 Agent 事件
const shouldEmitEvents = emitEvents && sessionId && agentId;
// 获取过滤后的工具
const tools = this.getFilteredTools();
@@ -82,9 +86,12 @@ export class AgentExecutor {
let fullResponse = '';
let steps = 0;
// 工具调用时间追踪(用于计算持续时间)
const toolStartTimes = new Map<string, number>();
try {
if (onStream) {
// 流式模式
if (onStream || shouldEmitEvents) {
// 流式模式(或需要发射事件时使用流式模式)
const result = streamText({
model: this.getModel(modelName),
system: systemPrompt,
@@ -96,23 +103,72 @@ export class AgentExecutor {
if (chunk.type === 'tool-call') {
steps++;
const toolArgs = 'input' in chunk ? chunk.input : {};
const toolCallId = `tool-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
// 记录工具开始时间
toolStartTimes.set(chunk.toolName, Date.now());
onToolCall?.(chunk.toolName, toolArgs as Record<string, unknown>);
onStream(`\n[调用工具: ${chunk.toolName}]\n`);
onStream?.(`\n[调用工具: ${chunk.toolName}]\n`);
// 发射子 Agent 工具开始事件
if (shouldEmitEvents) {
agentEventEmitter.emit({
type: 'subagent:tool_start',
sessionId: sessionId!,
agentId: agentId!,
toolCallId,
toolName: chunk.toolName,
args: toolArgs as Record<string, unknown>,
});
}
} else if (chunk.type === 'tool-result') {
const output = (chunk as { output?: ToolResult }).output;
onToolResult?.(
(chunk as { toolName?: string }).toolName ?? 'unknown',
output
);
const toolName = (chunk as { toolName?: string }).toolName ?? 'unknown';
const toolCallId = `tool-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
// 计算工具执行时间
const startTime = toolStartTimes.get(toolName);
const duration = startTime ? Date.now() - startTime : undefined;
toolStartTimes.delete(toolName);
onToolResult?.(toolName, output);
if (output && typeof output === 'object') {
if (output.success) {
const displayOutput =
output.output.length > 500
? output.output.substring(0, 500) + '...(截断)'
: output.output;
onStream(`[结果: ${displayOutput}]\n`);
onStream?.(`[结果: ${displayOutput}]\n`);
// 发射子 Agent 工具完成事件
if (shouldEmitEvents) {
agentEventEmitter.emit({
type: 'subagent:tool_end',
sessionId: sessionId!,
agentId: agentId!,
toolCallId,
status: 'completed',
result: output.output,
duration,
});
}
} else {
onStream(`[错误: ${output.error}]\n`);
onStream?.(`[错误: ${output.error}]\n`);
// 发射子 Agent 工具错误事件
if (shouldEmitEvents) {
agentEventEmitter.emit({
type: 'subagent:tool_end',
sessionId: sessionId!,
agentId: agentId!,
toolCallId,
status: 'error',
error: output.error,
duration,
});
}
}
}
}
@@ -121,7 +177,17 @@ export class AgentExecutor {
for await (const chunk of result.textStream) {
fullResponse += chunk;
onStream(chunk);
onStream?.(chunk);
// 发射子 Agent 流式输出事件
if (shouldEmitEvents && chunk) {
agentEventEmitter.emit({
type: 'subagent:stream',
sessionId: sessionId!,
agentId: agentId!,
content: chunk,
});
}
}
await result.response;
+14
View File
@@ -61,6 +61,20 @@ export {
// System Prompt
export { SystemPrompt } from './system-prompt.js';
// Events
export {
AgentEventEmitter,
agentEventEmitter,
type SubagentEventType,
type SubagentStartEvent,
type SubagentEndEvent,
type SubagentStreamEvent,
type SubagentToolStartEvent,
type SubagentToolEndEvent,
type SubagentEvent,
type SubagentEventListener,
} from './events.js';
// Prompt Template (re-export from ../template/)
export {
renderTemplate,
+42 -3
View File
@@ -3,6 +3,7 @@ import type { AgentConfig } from '../types/index.js';
import type { AgentExecutionContext, AgentInfo } from './types.js';
import { AgentExecutor } from './executor.js';
import { ToolRegistry } from '../tools/registry.js';
import { agentEventEmitter } from './events.js';
/**
* 后台 Agent 状态
@@ -67,7 +68,7 @@ export class AgentManager {
});
// 异步执行,不等待结果
this.executeAsync(agentId, agentInfo, prompt, baseConfig, toolRegistry, context);
this.executeAsync(agentId, agentInfo, description, prompt, baseConfig, toolRegistry, context);
return agentId;
}
@@ -78,16 +79,31 @@ export class AgentManager {
private async executeAsync(
agentId: string,
agentInfo: AgentInfo,
description: string,
prompt: string,
baseConfig: AgentConfig,
toolRegistry: ToolRegistry,
context: AgentExecutionContext
): Promise<void> {
const sessionId = context.parentSessionId || 'standalone';
const startTime = Date.now();
// 发射子 Agent 开始事件
agentEventEmitter.emit({
type: 'subagent:start',
sessionId,
agentId,
agentName: agentInfo.name,
description,
});
try {
const executor = new AgentExecutor(agentInfo, baseConfig, toolRegistry);
const result = await executor.execute(prompt, {
...context,
onStream: undefined, // 后台运行不使用流式输出
sessionId,
agentId,
emitEvents: true, // 启用子 Agent 事件发射
});
// 更新状态为完成
@@ -101,14 +117,37 @@ export class AgentManager {
agent.error = result.error;
}
}
// 发射子 Agent 结束事件
agentEventEmitter.emit({
type: 'subagent:end',
sessionId,
agentId,
agentName: agentInfo.name,
success: result.success,
duration: Date.now() - startTime,
error: result.error,
});
} catch (error) {
// 更新状态为失败
const agent = this.backgroundAgents.get(agentId);
const errorMessage = error instanceof Error ? error.message : String(error);
if (agent) {
agent.status = 'failed';
agent.completedAt = new Date();
agent.error = error instanceof Error ? error.message : String(error);
agent.error = errorMessage;
}
// 发射子 Agent 错误事件
agentEventEmitter.emit({
type: 'subagent:end',
sessionId,
agentId,
agentName: agentInfo.name,
success: false,
duration: Date.now() - startTime,
error: errorMessage,
});
}
// 触发等待回调
+6
View File
@@ -174,6 +174,12 @@ export interface AgentExecutionContext {
onToolCall?: (toolName: string, params: Record<string, unknown>) => void;
/** 回调:工具结果 */
onToolResult?: (toolName: string, result: unknown) => void;
/** 会话 ID(用于事件发射) */
sessionId?: string;
/** 子 Agent 实例 ID(用于事件发射) */
agentId?: string;
/** 是否发射子 Agent 事件(默认 false */
emitEvents?: boolean;
}
/**
+38 -3
View File
@@ -1,12 +1,19 @@
import type { ToolWithMetadata } from '../types.js';
import type { AgentConfig } from '../../types/index.js';
import type { ImageData } from '../../agent/types.js';
import { agentRegistry, AgentExecutor } from '../../agent/index.js';
import { agentRegistry, AgentExecutor, agentEventEmitter } from '../../agent/index.js';
import { toolRegistry } from '../registry.js';
import { SessionManager } from '../../session/index.js';
import { getAgentManager } from '../../agent/manager.js';
import { loadVisionConfig } from '../../utils/config.js';
/**
* 生成短 ID8 字符)
*/
function generateShortId(): string {
return Math.random().toString(36).slice(2, 10);
}
/**
* 模型预设映射
*/
@@ -231,15 +238,41 @@ export const taskTool: ToolWithMetadata = {
`${description} (@${agent.name})`
);
// 生成子 Agent 实例 ID
const agentId = generateShortId();
const startTime = Date.now();
// 发射子 Agent 开始事件
agentEventEmitter.emit({
type: 'subagent:start',
sessionId: parentSessionId,
agentId,
agentName: agent.name,
description,
});
// 创建执行器
const executor = new AgentExecutor(agent, effectiveConfig, toolRegistry);
// 执行任务
// 执行任务(启用事件发射)
const result = await executor.execute(prompt, {
parentSessionId,
workdir: process.cwd(),
images,
onStream: undefined, // 子任务不使用流式输出
sessionId: parentSessionId,
agentId,
emitEvents: true, // 启用子 Agent 事件发射
});
// 发射子 Agent 结束事件
agentEventEmitter.emit({
type: 'subagent:end',
sessionId: parentSessionId,
agentId,
agentName: agent.name,
success: result.success,
duration: Date.now() - startTime,
error: result.error,
});
// 保存子会话
@@ -255,6 +288,7 @@ export const taskTool: ToolWithMetadata = {
output: result.text,
metadata: {
agent: agent.name,
agentId,
sessionId: childSession.id,
steps: result.steps,
mode: 'sync',
@@ -267,6 +301,7 @@ export const taskTool: ToolWithMetadata = {
error: result.error || '子任务执行失败',
metadata: {
agent: agent.name,
agentId,
sessionId: childSession.id,
steps: result.steps,
mode: 'sync',
+40
View File
@@ -168,6 +168,25 @@ interface AgentRegistryInterface {
isInitialized(): boolean;
}
/**
* 子 Agent 事件类型
*/
interface SubagentEvent {
type: 'subagent:start' | 'subagent:end' | 'subagent:stream' | 'subagent:tool_start' | 'subagent:tool_end';
sessionId: string;
agentId: string;
[key: string]: unknown;
}
/**
* Agent Event Emitter 接口
*/
interface AgentEventEmitterInterface {
on(sessionId: string, listener: (event: SubagentEvent) => void): () => void;
off(sessionId: string, listener: (event: SubagentEvent) => void): void;
clear(sessionId: string): void;
}
/**
* Core 模块接口
*/
@@ -180,6 +199,7 @@ interface CoreModule {
getPermissionManager: (projectRoot?: string) => PermissionManager;
getProviderRegistry: () => ProviderRegistryInterface;
agentRegistry: AgentRegistryInterface;
agentEventEmitter?: AgentEventEmitterInterface;
}
// ============================================================================
@@ -446,6 +466,22 @@ export async function processMessage(
setSessionAutoApprove(sessionId, null);
}
// 订阅子 Agent 事件(如果可用)
let unsubscribeSubagentEvents: (() => void) | null = null;
if (coreModule?.agentEventEmitter) {
unsubscribeSubagentEvents = coreModule.agentEventEmitter.on(sessionId, (event: SubagentEvent) => {
// 检查是否已取消
if (abortController.signal.aborted) return;
// 转发子 Agent 事件到前端
broadcastToSession(sessionId, {
type: event.type,
sessionId,
payload: event,
});
});
}
try {
// 调用 Agent 的 chat 方法,使用流式回调和 AbortSignal
const result = await agent.chat(content, {
@@ -554,6 +590,10 @@ export async function processMessage(
emitLogEvent(sessionId, 'error', errorMessage);
} finally {
// 取消子 Agent 事件订阅
if (unsubscribeSubagentEvents) {
unsubscribeSubagentEvents();
}
// 清理 AbortController
abortControllerCache.delete(sessionId);
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
+68 -1
View File
@@ -123,7 +123,13 @@ export interface ServerMessage {
| 'error'
| 'session_updated'
| 'permission_request'
| 'mode_switched'; // 模式切换完成
| 'mode_switched' // 模式切换完成
// 子 Agent 事件
| 'subagent:start' // 子 Agent 开始执行
| 'subagent:end' // 子 Agent 执行结束
| 'subagent:stream' // 子 Agent 流式输出
| 'subagent:tool_start' // 子 Agent 工具调用开始
| 'subagent:tool_end'; // 子 Agent 工具调用结束
sessionId: string;
payload?: unknown;
}
@@ -144,6 +150,67 @@ 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;
// ============ Permission 相关 ============
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
+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';
+8 -1
View File
@@ -13,6 +13,7 @@ import {
ChatInput,
PermissionDialog,
ContextUsage,
SubagentProgress,
} from '@ai-assistant/ui';
interface ChatPageProps {
@@ -63,6 +64,7 @@ export function ChatPage({
setAgentMode,
setAutoApprove,
currentAgent,
currentSubagent,
} = useChat({
sessionId,
onError: (error) => {
@@ -298,7 +300,12 @@ export function ChatPage({
<ChatMessage message={streamingMessage} isStreaming />
)}
{isLoading && !streamingMessage && <TypingIndicator agentName={currentAgent} />}
{/* 子 Agent 进度显示 */}
{currentSubagent && (
<SubagentProgress subagent={currentSubagent} />
)}
{isLoading && !streamingMessage && !currentSubagent && <TypingIndicator agentName={currentAgent} />}
<div ref={messagesEndRef} />
</div>