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',