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,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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
// 触发等待回调
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 生成短 ID(8 字符)
|
||||
*/
|
||||
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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user