125 KiB
Multi-Agent Crew 编排与运行监控 — 实施规划
基于设计文档:
2026-04-14-multi-agent-crew-orchestration-design.md日期:2026-04-14 状态:待审核 For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
目录
- 总览
- Phase 0:准备工作
- Phase 1:后端数据层(Entity + Migration)
- Phase 2:后端核心引擎(编排器 + 委派工具)
- Phase 3:后端通信层(WebSocket + 触发 + 定时)
- Phase 4:前端编排页
- Phase 5:前端监控页
- Phase 6:权限集成 + 端到端测试
- 风险应对与降级方案
- 验收标准
总览
实施策略
采用 纵向切片、逐层推进 策略。每个 Phase 完成后都有可验证的产出,允许中途停下来评审。
Phase 0 ─ 准备(依赖验证、技术验证、代码骨架)
│
Phase 1 ─ 后端数据层(4 个 Entity + agent 扩展字段)
│
Phase 2 ─ 后端核心引擎(CrewOrchestrator + CrewDelegate + 3 个工具)
│
Phase 3 ─ 后端通信层(CrewGateway + CrewTrigger + CrewScheduler)
│
Phase 4 ─ 前端编排页(画布 + 侧栏 + 属性面板)
│
Phase 5 ─ 前端监控页(运行列表 + 实时画布 + 时间线 + 日志)
│
Phase 6 ─ 权限集成 + 端到端测试
关键约定
| 约定 | 说明 |
|---|---|
| Entity 继承 | 全部继承 BaseEntity(自带 id/createTime/updateTime/tenantId) |
| Controller 风格 | 使用 @CoolController 自动 CRUD,复杂接口手写 |
| 文件命名 | 下划线法(如 crew_agent.ts) |
| 字段命名 | 驼峰法(如 masterAgentId) |
| WebSocket | 独立 /crew 命名空间,不侵入现有 /netaclaw |
| 子 Agent 执行 | 直接调用 runAgent() 纯函数,零 runtime 改动 |
| 注释语言 | 中文 |
文件影响矩阵
packages/backend/src/modules/netaclaw/
├── entity/ ← Phase 1: 新增 4 个文件
│ ├── crew.ts [新增]
│ ├── crew_agent.ts [新增]
│ ├── crew_run.ts [新增]
│ ├── crew_task.ts [新增]
│ └── agent.ts [修改: +isCrewMaster 字段]
├── controller/
│ └── admin/
│ ├── crew.ts [新增] Phase 3
│ ├── crew_run.ts [新增] Phase 3
│ └── crew_trigger.ts [新增] Phase 3
├── gateway/
│ └── crew_server.ts [新增] Phase 3
├── service/
│ ├── crew.ts [新增] Phase 2
│ ├── crew_orchestrator.ts [新增] Phase 2
│ ├── crew_delegate.ts [新增] Phase 2
│ └── crew_scheduler.ts [新增] Phase 3
├── tools/
│ ├── builtin/
│ │ ├── delegate_task.ts [新增] Phase 2
│ │ ├── delegate_parallel.ts [新增] Phase 2
│ │ └── escalate.ts [新增] Phase 2
└── config.ts [修改: 注册新 Entity] Phase 1
packages/frontend/src/modules/agent/
├── config.ts [修改: +2 路由] Phase 4
├── views/
│ ├── crew-editor.vue [新增] Phase 4
│ └── crew-monitor.vue [新增] Phase 5
├── components/crew/
│ ├── crew-canvas.vue [新增] Phase 4
│ ├── crew-agent-node.vue [新增] Phase 4
│ ├── crew-edge-label.vue [新增] Phase 4
│ ├── crew-sidebar.vue [新增] Phase 4
│ ├── crew-property-panel.vue [新增] Phase 4
│ ├── crew-trigger-config.vue [新增] Phase 4
│ ├── crew-context-menu.vue [新增] Phase 4
│ ├── crew-run-table.vue [新增] Phase 5
│ ├── crew-run-detail.vue [新增] Phase 5
│ ├── crew-timeline.vue [新增] Phase 5
│ └── crew-log-panel.vue [新增] Phase 5
├── hooks/
│ ├── crew-canvas.ts [新增] Phase 4
│ ├── crew-orchestration.ts [新增] Phase 4
│ └── crew-monitor.ts [新增] Phase 5
└── store/
└── crew.ts [新增] Phase 4
Phase 0:准备工作
目标:搭建开发环境,确认依赖可用,创建代码骨架 预估:0.5 天
Step 0.1:确认前端依赖可用
项目已安装但未实际使用的关键依赖,需验证版本兼容:
cd packages/frontend
# 检查 @vue-flow 系列
pnpm list @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background
# 检查 elkjs(自动布局)
pnpm list elkjs
预期结果:
@vue-flow/core≥ 1.42.1 ✓@vue-flow/minimap、@vue-flow/controls、@vue-flow/background已安装 ✓elkjs≥ 0.9.3 ✓
如果缺失:
pnpm add @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background elkjs
Step 0.2:确认后端依赖
cd packages/backend
# cron 包(定时调度用)
pnpm list cron
如果缺失:
pnpm add cron
pnpm add -D @types/cron
Step 0.3:Vue Flow 技术验证(⚠️ 关键风险点)
这是本项目首次实际使用 Vue Flow。在正式开发前,先用最小 demo 验证核心功能。
创建临时验证文件 packages/frontend/src/modules/agent/views/__test-flow.vue:
<template>
<div style="width: 100%; height: 600px">
<VueFlow :nodes="nodes" :edges="edges" @node-drag-stop="onNodeDragStop">
<MiniMap />
<Controls />
<Background />
</VueFlow>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { Controls } from '@vue-flow/controls';
import { Background } from '@vue-flow/background';
const nodes = ref([
{ id: '1', position: { x: 100, y: 100 }, data: { label: '主 Agent' } },
{ id: '2', position: { x: 300, y: 200 }, data: { label: '子 Agent' } },
]);
const edges = ref([
{ id: 'e1-2', source: '1', target: '2' },
]);
function onNodeDragStop(event: any) {
console.log('节点拖拽完成', event);
}
</script>
验证清单:
- 画布正常渲染,节点可见
- 节点可自由拖拽,连线跟随
- MiniMap、Controls、Background 正常显示
- 自定义节点(slot 方式)可渲染
- 连线可通过 Handle 创建
通过后删除测试文件,正式进入 Phase 1。
Step 0.4:创建后端模块骨架目录
cd packages/backend/src/modules/netaclaw
# 确认以下目录存在
ls entity/
ls controller/admin/
ls service/
ls gateway/
ls tools/builtin/
Phase 0 验收
- 前端 Vue Flow 依赖可用且 demo 验证通过
- 后端 cron 依赖可用
- 目录结构就绪
Phase 1:后端数据层(Entity + Migration)
目标:建立 4 个新表 + 扩展 agent 表,TypeORM synchronize 自动建表 预估:1 天 依赖:Phase 0 完成
Step 1.1:创建 crew.ts Entity
文件:packages/backend/src/modules/netaclaw/entity/crew.ts
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* Agent 集群(Crew)
*/
@Entity('netaclaw_crew')
export class NetaClawCrewEntity extends BaseEntity {
@Column({ comment: '集群唯一标识', length: 100, unique: true })
name: string;
@Column({ comment: '显示名称', length: 200 })
label: string;
@Column({ comment: '集群描述', type: 'text', nullable: true })
description: string;
@Column({ comment: '图标', length: 500, nullable: true })
icon: string;
@Index()
@Column({ comment: '主 Agent ID', nullable: true })
masterAgentId: number;
@Column({ type: 'json', comment: '画布布局数据', nullable: true })
canvasData: Record<string, unknown>;
@Column({ type: 'json', comment: '触发配置', nullable: true })
triggerConfig: {
manual?: boolean;
cron?: { enabled: boolean; expression: string; timezone: string };
webhook?: { enabled: boolean; secret: string };
api?: { enabled: boolean; apiKey: string };
};
@Column({ type: 'json', comment: '委派提示(从连线生成)', nullable: true })
delegateHints: {
hints: string;
edges: Array<{ from: string; to: string; type: string; note?: string }>;
};
@Index()
@Column({ comment: '状态: 0=草稿 1=已发布', default: 0 })
status: number;
@Column({ comment: '最大并发子 Agent 数', default: 3 })
maxConcurrent: number;
@Column({ comment: '单个子任务默认超时(秒)', default: 300 })
taskTimeout: number;
@Column({ type: 'json', comment: '全局重试策略', nullable: true })
retryPolicy: { maxRetries: number; retryDelay: number };
}
设计要点:
triggerConfig/delegateHints/retryPolicy使用 JSON 列,灵活扩展masterAgentId不使用外键约束(遵循项目规范),通过应用层关联status索引优化发布状态查询
Step 1.2:创建 crew_agent.ts Entity
文件:packages/backend/src/modules/netaclaw/entity/crew_agent.ts
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* 集群-Agent 关联(成员关系权威来源)
*/
@Entity('netaclaw_crew_agent')
export class NetaClawCrewAgentEntity extends BaseEntity {
@Index()
@Column({ comment: '集群 ID' })
crewId: number;
@Index()
@Column({ comment: 'Agent ID' })
agentId: number;
@Column({ comment: '该 Agent 在集群中的角色描述', type: 'text', nullable: true })
role: string;
@Column({ type: 'json', comment: '画布位置', nullable: true })
canvasPosition: { x: number; y: number };
@Column({ comment: '分组名', length: 100, nullable: true })
groupName: string;
}
设计要点:
crewId + agentId双索引,支持高频关联查询canvasPosition用 JSON 存画布坐标,前端序列化/反序列化- 此表是成员关系的权威来源,
canvasData仅存渲染信息
Step 1.3:创建 crew_run.ts Entity
文件:packages/backend/src/modules/netaclaw/entity/crew_run.ts
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* 集群运行记录
*/
@Entity('netaclaw_crew_run')
export class NetaClawCrewRunEntity extends BaseEntity {
@Index()
@Column({ comment: '集群 ID' })
crewId: number;
@Column({ comment: '触发方式', length: 20 })
triggerType: string; // manual / cron / webhook / api
@Column({ comment: '触发输入参数', type: 'text', nullable: true })
triggerInput: string;
@Index()
@Column({ comment: '运行状态', length: 20, default: 'pending' })
status: string; // pending / running / paused / completed / failed
@Column({ comment: '主 Agent 会话 ID', nullable: true })
masterSessionId: number;
@Column({ comment: '开始时间', type: 'datetime', nullable: true })
startTime: Date;
@Column({ comment: '结束时间', type: 'datetime', nullable: true })
endTime: Date;
@Column({ type: 'json', comment: '运行结果摘要', nullable: true })
result: Record<string, unknown>;
@Column({ comment: '错误信息', type: 'text', nullable: true })
error: string;
@Column({ type: 'json', comment: '升级暂停时保存的对话上下文', nullable: true })
pausedState: unknown;
@Column({ type: 'json', comment: '累计 token 消耗', nullable: true })
tokenUsage: { inputTokens: number; outputTokens: number };
}
Step 1.4:创建 crew_task.ts Entity
文件:packages/backend/src/modules/netaclaw/entity/crew_task.ts
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* 子 Agent 任务记录
*/
@Entity('netaclaw_crew_task')
export class NetaClawCrewTaskEntity extends BaseEntity {
@Index()
@Column({ comment: '运行记录 ID' })
runId: number;
@Index()
@Column({ comment: '子 Agent ID' })
agentId: number;
@Column({ comment: '任务描述', type: 'text' })
taskDescription: string;
@Index()
@Column({ comment: '任务状态', length: 20, default: 'pending' })
status: string; // pending / running / completed / failed / retrying
@Column({ comment: '子 Agent 会话 ID', nullable: true })
sessionId: number;
@Column({ comment: '开始时间', type: 'datetime', nullable: true })
startTime: Date;
@Column({ comment: '结束时间', type: 'datetime', nullable: true })
endTime: Date;
@Column({ type: 'json', comment: '执行结果', nullable: true })
result: Record<string, unknown>;
@Column({ comment: '错误信息', type: 'text', nullable: true })
error: string;
@Column({ comment: '已重试次数', default: 0 })
retryCount: number;
@Column({ comment: '该任务超时(秒),空则使用集群默认', nullable: true })
timeout: number;
@Column({ type: 'json', comment: '该任务 token 消耗', nullable: true })
tokenUsage: { inputTokens: number; outputTokens: number };
@Column({ comment: '父任务 ID(支持嵌套委派)', nullable: true })
parentTaskId: number;
}
Step 1.5:扩展 agent.ts Entity
文件:packages/backend/src/modules/netaclaw/entity/agent.ts
操作:在现有 status 字段后新增一个字段
// 在 status 字段之后添加:
@Column({ comment: '是否可作为集群主 Agent', default: 0 })
isCrewMaster: number; // 0=否 1=是(UI 筛选用)
这是唯一一处修改现有文件的地方,风险极低(仅新增列,不改现有字段)。
Step 1.6:注册 Entity 到模块配置
文件:packages/backend/src/modules/netaclaw/config.ts
在现有 Entity 导入列表中追加:
import { NetaClawCrewEntity } from './entity/crew.js';
import { NetaClawCrewAgentEntity } from './entity/crew_agent.js';
import { NetaClawCrewRunEntity } from './entity/crew_run.js';
import { NetaClawCrewTaskEntity } from './entity/crew_task.js';
// 在 typeorm.entity 数组中追加:
NetaClawCrewEntity,
NetaClawCrewAgentEntity,
NetaClawCrewRunEntity,
NetaClawCrewTaskEntity,
Step 1.7:验证建表
cd packages/backend
pnpm dev # 启动开发服务器,TypeORM synchronize=true 自动建表
验证清单:
- 服务启动无报错
- MySQL 中新增 4 张表:
netaclaw_crew、netaclaw_crew_agent、netaclaw_crew_run、netaclaw_crew_task netaclaw_agent表新增isCrewMaster字段- 所有索引正确创建
- JSON 列可正常读写(手动插入测试数据验证)
Step 1.8:提交代码
git add packages/backend/src/modules/netaclaw/entity/crew*.ts
git add packages/backend/src/modules/netaclaw/entity/agent.ts
git add packages/backend/src/modules/netaclaw/config.ts
git commit -m "feat(crew): 新增 Crew 编排数据模型 - 4 个 Entity + agent 扩展字段"
Phase 1 验收
- 4 个新 Entity 文件创建完成
- agent.ts 新增 isCrewMaster 字段
- config.ts 注册新 Entity
- 自动建表成功,字段/索引/JSON 列均正确
- 代码已提交
Phase 2:后端核心引擎(编排器 + 委派工具)
目标:实现 CrewOrchestrator 编排调度器 + CrewDelegate 委派执行器 + 3 个委派工具 预估:2.5 天 依赖:Phase 1 完成 本阶段是整个系统的核心,代码量最大,逻辑最复杂
Step 2.1:定义类型接口
文件:packages/backend/src/modules/netaclaw/service/crew_types.ts
先定义所有跨模块共享的类型,避免循环引用。
/**
* Crew 编排系统共享类型定义
*/
/** 委派执行结果 */
export interface DelegateResult {
agent: string;
status: 'completed' | 'failed';
result: string;
error?: string;
duration: string;
tokenUsage: { inputTokens: number; outputTokens: number };
}
/** 委派回调 */
export interface CrewCallbacks {
/** 日志推送 */
onLog: (runId: number, taskId: number, agentName: string, level: string, message: string) => void;
/** 任务状态变更推送 */
onTaskStatus: (runId: number, taskId: number, agentName: string, status: string, result?: any, error?: string) => void;
/** 运行状态变更推送 */
onRunStatus: (runId: number, status: string, progress?: string) => void;
/** 升级推送 */
onEscalation: (runId: number, taskId: number | undefined, reason: string, error?: string) => void;
}
/** 并行委派任务项 */
export interface ParallelTaskItem {
agent_name: string;
task_description: string;
context?: string;
}
/** 集群运行上下文(编排器运行期间的内存状态) */
export interface CrewRunContext {
runId: number;
crewId: number;
masterAgentId: number;
memberAgents: Array<{
id: number;
name: string;
label: string;
role: string;
agent: any; // NetaClawAgentEntity
}>;
maxConcurrent: number;
taskTimeout: number;
callbacks: CrewCallbacks;
/** 获取当前主 Agent 对话历史(escalate 持久化用) */
getConversation?: () => any[];
}
Step 2.2:实现 CrewDelegate(委派执行器)
文件:packages/backend/src/modules/netaclaw/service/crew_delegate.ts
职责:执行子 Agent 任务的核心函数,直接调用 runAgent()。
import { Provide, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
import { runAgent, AgentConfig } from '../runtime/agent.js';
import { AnyAgentTool } from '../tools/common.js';
import { DelegateResult, CrewCallbacks } from './crew_types.js';
import { SkillLoaderService } from '../service/skill_loader.js';
import { buildSkillContext } from '../service/skill_context.js';
// 导入内置工具
import { bashTool } from '../tools/builtin/bash.js';
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';
/** 委派相关工具名,子 Agent 不可使用(防止无限嵌套) */
const CREW_TOOLS = ['delegate_task', 'delegate_parallel', 'escalate'];
@Provide()
export class CrewDelegateService {
@Logger()
logger: ILogger;
@InjectEntityModel(NetaClawCrewTaskEntity)
crewTaskRepo: Repository<NetaClawCrewTaskEntity>;
@Inject()
skillLoader: SkillLoaderService;
/**
* 执行单个子 Agent 任务
*/
async executeSubAgent(
agent: NetaClawAgentEntity,
taskDescription: string,
context: string | undefined,
runId: number,
taskTimeout: number,
callbacks: CrewCallbacks
): Promise<DelegateResult> {
const startTime = Date.now();
// 1. 创建 crew_task 记录
const task = await this.crewTaskRepo.save({
runId,
agentId: agent.id,
taskDescription,
status: 'running',
startTime: new Date(),
} as any);
callbacks.onTaskStatus(runId, task.id, agent.name, 'running');
try {
// 2. 加载子 Agent 的 Skill 上下文(继承 Agent 自身配置的 skills)
let skillSystemPrompt = '';
if (agent.skills?.length) {
const skillContext = await buildSkillContext(this.skillLoader, agent.skills);
skillSystemPrompt = skillContext ? `\n\n${skillContext}` : '';
}
// 3. 构建 Agent 配置
const agentConfig: AgentConfig = {
name: agent.name,
systemPrompt: `${agent.systemPrompt || ''}${skillSystemPrompt}\n\n## 当前任务\n${taskDescription}`,
model: agent.modelConfig?.modelId || 'anthropic:claude-sonnet-4-20250514',
apiKey: agent.modelConfig?.apiKey || '',
baseUrl: agent.modelConfig?.apiUrl,
skills: agent.skills || [],
};
// 4. 加载子 Agent 工具集(内置工具 + Skill 工具,移除委派工具)
const builtinTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool];
const skillTools = await this.skillLoader.loadToolsForAgent(agent);
const tools = [...builtinTools, ...skillTools].filter(t => !CREW_TOOLS.includes(t.name));
// 5. 构建用户消息
const userMessage = context
? `任务:${taskDescription}\n\n上下文:${context}`
: taskDescription;
// 6. 带超时调用 runAgent()
const timeout = taskTimeout * 1000;
const result = await Promise.race([
runAgent({
agentConfig,
tools,
userMessage,
history: [], // 空历史 = 独立会话
onToken: (text) => callbacks.onLog(runId, task.id, agent.name, 'info', text),
onToolCall: (name) => callbacks.onLog(runId, task.id, agent.name, 'tool', `调用工具: ${name}`),
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`子 Agent "${agent.name}" 执行超时 (${taskTimeout}s)`)), timeout)
),
]) as any;
// 7. 更新 crew_task 为完成
const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
await this.crewTaskRepo.update(task.id, {
status: 'completed',
result: { text: result.finalContent },
tokenUsage: result.usage,
endTime: new Date(),
} as any);
const delegateResult: DelegateResult = {
agent: agent.name,
status: 'completed',
result: result.finalContent,
duration,
tokenUsage: result.usage,
};
callbacks.onTaskStatus(runId, task.id, agent.name, 'completed', delegateResult);
return delegateResult;
} catch (err: any) {
// 8. 失败处理
const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
await this.crewTaskRepo.update(task.id, {
status: 'failed',
error: err.message,
endTime: new Date(),
} as any);
const delegateResult: DelegateResult = {
agent: agent.name,
status: 'failed',
result: '',
error: err.message,
duration,
tokenUsage: { inputTokens: 0, outputTokens: 0 },
};
callbacks.onTaskStatus(runId, task.id, agent.name, 'failed', undefined, err.message);
return delegateResult;
}
}
/**
* 并行执行多个子 Agent 任务(受 maxConcurrent 限制)
*/
async executeParallel(
tasks: Array<{ agent: NetaClawAgentEntity; taskDescription: string; context?: string }>,
runId: number,
maxConcurrent: number,
taskTimeout: number,
callbacks: CrewCallbacks
): Promise<DelegateResult[]> {
// 简单的并发限制:分批执行
const results: DelegateResult[] = [];
for (let i = 0; i < tasks.length; i += maxConcurrent) {
const batch = tasks.slice(i, i + maxConcurrent);
const batchResults = await Promise.all(
batch.map(t => this.executeSubAgent(t.agent, t.taskDescription, t.context, runId, taskTimeout, callbacks))
);
results.push(...batchResults);
}
return results;
}
}
设计要点:
runAgent()是纯函数调用,history: []保证独立会话Promise.race实现超时控制- 动态加载子 Agent 的 skills/tools(通过 SkillLoaderService),而非硬编码
- 移除 delegate/escalate 工具防止子 Agent 嵌套委派
- 并行执行通过分批 +
Promise.all实现,受maxConcurrent限制
Step 2.3:实现 3 个委派工具
2.3.1 delegate_task.ts(串行委派)
文件:packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts
import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext, DelegateResult } from '../../service/crew_types.js';
/**
* 创建 delegate_task 工具实例
* 闭包绑定当前 crew 运行上下文,使主 Agent 可通过工具调用委派子 Agent
*/
export function createDelegateTaskTool(ctx: CrewRunContext): AnyAgentTool {
return {
name: 'delegate_task',
label: '委派任务',
description: '将任务委派给团队中的一个子 Agent 执行,等待执行完成后返回结果。',
visibility: 'tool',
parameters: Type.Object({
agent_name: Type.String({ description: '子 Agent 名称' }),
task_description: Type.String({ description: '任务描述' }),
context: Type.Optional(Type.String({ description: '上下文(前序任务结果等)' })),
}),
execute: async (_id: string, params: any): Promise<string> => {
const { agent_name, task_description, context } = params;
// 查找目标子 Agent
const member = ctx.memberAgents.find(m => m.name === agent_name);
if (!member) {
return JSON.stringify({
agent: agent_name,
status: 'failed',
error: `未找到名为 "${agent_name}" 的子 Agent。可用成员: ${ctx.memberAgents.map(m => m.name).join(', ')}`,
});
}
// 调用 CrewDelegate 执行
// 注意:ctx._delegate 在 CrewOrchestrator 构建时注入
const result: DelegateResult = await (ctx as any)._delegate.executeSubAgent(
member.agent,
task_description,
context,
ctx.runId,
ctx.taskTimeout,
ctx.callbacks,
);
return JSON.stringify(result);
},
};
}
2.3.2 delegate_parallel.ts(并行委派)
文件:packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts
import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext, DelegateResult } from '../../service/crew_types.js';
/**
* 创建 delegate_parallel 工具实例
*/
export function createDelegateParallelTool(ctx: CrewRunContext): AnyAgentTool {
return {
name: 'delegate_parallel',
label: '并行委派',
description: '将多个任务同时委派给不同的子 Agent 并行执行,全部完成后返回所有结果。',
visibility: 'tool',
parameters: Type.Object({
tasks: Type.Array(
Type.Object({
agent_name: Type.String({ description: '子 Agent 名称' }),
task_description: Type.String({ description: '任务描述' }),
context: Type.Optional(Type.String({ description: '上下文' })),
}),
{ description: '要并行执行的任务列表' }
),
}),
execute: async (_id: string, params: any): Promise<string> => {
const { tasks } = params;
// 解析并验证所有目标 Agent
const resolvedTasks = [];
for (const t of tasks) {
const member = ctx.memberAgents.find(m => m.name === t.agent_name);
if (!member) {
return JSON.stringify([{
agent: t.agent_name,
status: 'failed',
error: `未找到名为 "${t.agent_name}" 的子 Agent`,
}]);
}
resolvedTasks.push({
agent: member.agent,
taskDescription: t.task_description,
context: t.context,
});
}
// 并行执行
const results: DelegateResult[] = await (ctx as any)._delegate.executeParallel(
resolvedTasks,
ctx.runId,
ctx.maxConcurrent,
ctx.taskTimeout,
ctx.callbacks,
);
return JSON.stringify(results);
},
};
}
2.3.3 escalate.ts(升级人工)
文件:packages/backend/src/modules/netaclaw/tools/builtin/escalate.ts
import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext } from '../../service/crew_types.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';
/**
* 内存中保存 escalate 的 resolve 回调(用于暂停/恢复)
* key: runId, value: resolve 函数
*/
export const escalateResolvers = new Map<number, (userMessage: string) => void>();
/**
* 创建 escalate 工具实例
* 需要传入 crewRunRepo 以持久化 pausedState
*/
export function createEscalateTool(
ctx: CrewRunContext,
crewRunRepo: Repository<NetaClawCrewRunEntity>
): AnyAgentTool {
return {
name: 'escalate',
label: '升级人工',
description: '当你无法处理某个问题时,升级给人工处理。集群将暂停等待人工介入。',
visibility: 'tool',
parameters: Type.Object({
reason: Type.String({ description: '升级原因' }),
failed_task: Type.Optional(Type.String({ description: '失败的任务描述' })),
}),
execute: async (_id: string, params: any): Promise<string> => {
const { reason, failed_task } = params;
// 1. 持久化当前对话上下文到 pausedState(进程重启恢复用)
const conversation = ctx.getConversation?.() || [];
await crewRunRepo.update(ctx.runId, {
status: 'paused',
pausedState: JSON.stringify(conversation),
} as any);
// 2. 推送升级事件 + 运行状态变更
ctx.callbacks.onEscalation(ctx.runId, undefined, reason, failed_task);
ctx.callbacks.onRunStatus(ctx.runId, 'paused');
// 3. 返回不 resolve 的 Promise,阻塞 runAttempt 循环
// 用户在监控页点击"恢复"时,通过 escalateResolvers 回调 resolve
return new Promise<string>((resolve) => {
escalateResolvers.set(ctx.runId, (userMessage: string) => {
resolve(JSON.stringify({
status: 'resumed',
userMessage,
instruction: '用户已提供处理意见,请根据用户意见继续执行。',
}));
});
});
},
};
}
设计要点:
escalateResolvers内存 Map 是暂停/恢复的核心机制execute()返回的 Promise 不立即 resolve →runAttempt阻塞在该 tool_call- 恢复时通过 Map 取出 resolve 回调,注入用户意见作为 tool_result
- 进程重启恢复在 Phase 3 的 CrewScheduler 中处理
Step 2.4:实现 CrewOrchestrator(核心编排器)
文件:packages/backend/src/modules/netaclaw/service/crew_orchestrator.ts
这是整个系统最核心的文件,负责:
- 加载集群配置
- 构建主 Agent 增强 system prompt
- 注入委派工具
- 启动主 Agent ReAct 循环
- 管理运行生命周期
import { Provide, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewAgentEntity } from '../entity/crew_agent.js';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
import { runAgent, AgentConfig } from '../runtime/agent.js';
import { AnyAgentTool } from '../tools/common.js';
import { CrewDelegateService } from './crew_delegate.js';
import { CrewRunContext, CrewCallbacks } from './crew_types.js';
import { createDelegateTaskTool } from '../tools/builtin/delegate_task.js';
import { createDelegateParallelTool } from '../tools/builtin/delegate_parallel.js';
import { createEscalateTool } from '../tools/builtin/escalate.js';
import { bashTool } from '../tools/builtin/bash.js';
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';
@Provide()
export class CrewOrchestratorService {
@Logger()
logger: ILogger;
@InjectEntityModel(NetaClawCrewEntity)
crewRepo: Repository<NetaClawCrewEntity>;
@InjectEntityModel(NetaClawCrewAgentEntity)
crewAgentRepo: Repository<NetaClawCrewAgentEntity>;
@InjectEntityModel(NetaClawCrewRunEntity)
crewRunRepo: Repository<NetaClawCrewRunEntity>;
@InjectEntityModel(NetaClawCrewTaskEntity)
crewTaskRepo: Repository<NetaClawCrewTaskEntity>;
@InjectEntityModel(NetaClawAgentEntity)
agentRepo: Repository<NetaClawAgentEntity>;
@Inject()
crewDelegate: CrewDelegateService;
/**
* 启动一次集群运行
*/
async start(
crewId: number,
triggerType: string,
triggerInput: string,
callbacks: CrewCallbacks
): Promise<number> {
// 1. 加载集群配置
const crew = await this.crewRepo.findOne({ where: { id: crewId } });
if (!crew) throw new Error(`集群 ${crewId} 不存在`);
if (crew.status !== 1) throw new Error(`集群 ${crewId} 未发布`);
// 2. 加载主 Agent
const masterAgent = await this.agentRepo.findOne({ where: { id: crew.masterAgentId } });
if (!masterAgent) throw new Error(`主 Agent ${crew.masterAgentId} 不存在`);
// 3. 加载集群成员
const crewAgents = await this.crewAgentRepo.find({ where: { crewId } });
const memberAgents = [];
for (const ca of crewAgents) {
if (ca.agentId === crew.masterAgentId) continue; // 排除主 Agent 自身
const agent = await this.agentRepo.findOne({ where: { id: ca.agentId } });
if (agent) {
memberAgents.push({
id: agent.id,
name: agent.name,
label: agent.label,
role: ca.role || agent.description || '',
agent,
});
}
}
// 4. 创建运行记录
const run = await this.crewRunRepo.save({
crewId,
triggerType,
triggerInput,
status: 'running',
startTime: new Date(),
tokenUsage: { inputTokens: 0, outputTokens: 0 },
} as any);
callbacks.onRunStatus(run.id, 'running', `0/${memberAgents.length}`);
// 5. 构建运行上下文
const ctx: CrewRunContext = {
runId: run.id,
crewId,
masterAgentId: crew.masterAgentId,
memberAgents,
maxConcurrent: crew.maxConcurrent || 3,
taskTimeout: crew.taskTimeout || 300,
callbacks,
// getConversation 在 runOrchestration 中注入(需要闭包引用 runAgent 的内部状态)
};
// 注入 delegate 服务引用
(ctx as any)._delegate = this.crewDelegate;
// 6. 异步执行编排(不阻塞调用方)
this.runOrchestration(crew, masterAgent, ctx, triggerInput, callbacks)
.catch(err => {
this.logger.error(`集群 ${crewId} 运行 ${run.id} 异常:`, err);
});
return run.id;
}
/**
* 核心编排执行逻辑
*/
private async runOrchestration(
crew: NetaClawCrewEntity,
masterAgent: NetaClawAgentEntity,
ctx: CrewRunContext,
triggerInput: string,
callbacks: CrewCallbacks
): Promise<void> {
try {
// 1. 构建增强 system prompt
const enhancedPrompt = this.buildEnhancedPrompt(masterAgent, ctx, crew);
// 2. 构建主 Agent 配置
const agentConfig: AgentConfig = {
name: masterAgent.name,
systemPrompt: enhancedPrompt,
model: masterAgent.modelConfig?.modelId || 'anthropic:claude-sonnet-4-20250514',
apiKey: masterAgent.modelConfig?.apiKey || '',
baseUrl: masterAgent.modelConfig?.apiUrl,
maxToolRounds: 50, // 主 Agent 可能需要多轮委派
};
// 3. 用闭包捕获对话历史引用(供 escalate 持久化用)
// runAgent 内部会构建 messages 数组,我们通过 history 参数的引用来追踪
const conversationTracker: any[] = [];
ctx.getConversation = () => conversationTracker;
// 4. 构建主 Agent 工具集(基础工具 + 委派工具)
const tools: AnyAgentTool[] = [
bashTool, readFileTool, writeFileTool, listDirTool,
createDelegateTaskTool(ctx),
createDelegateParallelTool(ctx),
createEscalateTool(ctx, this.crewRunRepo), // 传入 repo 以持久化 pausedState
];
// 5. 执行主 Agent ReAct 循环
const result = await runAgent({
agentConfig,
tools,
userMessage: triggerInput || '请根据团队成员和调度建议,开始执行任务。',
history: [],
onToken: (text) => {
callbacks.onLog(ctx.runId, 0, masterAgent.name, 'info', text);
// 追踪对话内容(简化版,完整版需在 runAttempt 层获取)
conversationTracker.push({ role: 'assistant_token', content: text });
},
onToolCall: (name, args) => callbacks.onLog(ctx.runId, 0, masterAgent.name, 'tool', `调用 ${name}`),
});
// 6. 计算累计 Token(主 Agent + 所有子 Agent 任务)
const tasks = await this.crewTaskRepo.find({ where: { runId: ctx.runId } });
const totalTokens = tasks.reduce((acc, t) => ({
inputTokens: acc.inputTokens + (t.tokenUsage?.inputTokens || 0),
outputTokens: acc.outputTokens + (t.tokenUsage?.outputTokens || 0),
}), {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
});
// 7. 更新运行记录为完成
await this.crewRunRepo.update(ctx.runId, {
status: 'completed',
result: { summary: result.finalContent },
tokenUsage: totalTokens,
endTime: new Date(),
} as any);
callbacks.onRunStatus(ctx.runId, 'completed');
} catch (err: any) {
// 失败处理
await this.crewRunRepo.update(ctx.runId, {
status: 'failed',
error: err.message,
endTime: new Date(),
} as any);
callbacks.onRunStatus(ctx.runId, 'failed');
this.logger.error(`集群运行 ${ctx.runId} 失败:`, err);
}
}
/**
* 构建主 Agent 增强 system prompt
*/
private buildEnhancedPrompt(
masterAgent: NetaClawAgentEntity,
ctx: CrewRunContext,
crew: NetaClawCrewEntity
): string {
let prompt = masterAgent.systemPrompt || '';
// 追加团队成员信息
prompt += '\n\n## 团队成员\n';
prompt += '你是团队的主管 Agent,负责将任务分配给以下子 Agent 执行:\n\n';
for (const member of ctx.memberAgents) {
prompt += `- **${member.name}**(${member.label}): ${member.role}\n`;
}
// 追加调度建议(如有连线提示)
if (crew.delegateHints?.hints) {
prompt += `\n## 调度建议\n${crew.delegateHints.hints}\n`;
}
// 追加工具使用说明
prompt += `\n## 工具说明\n`;
prompt += `- 使用 \`delegate_task\` 将任务委派给一个子 Agent 串行执行\n`;
prompt += `- 使用 \`delegate_parallel\` 将多个独立任务同时委派给不同子 Agent 并行执行\n`;
prompt += `- 当遇到无法解决的问题时,使用 \`escalate\` 升级给人工处理\n`;
prompt += `- 根据任务之间的依赖关系决定串行还是并行执行\n`;
prompt += `- 收到子 Agent 的执行结果后,分析结果并决定下一步行动\n`;
return prompt;
}
/**
* 终止运行
*/
async stop(runId: number): Promise<void> {
await this.crewRunRepo.update(runId, {
status: 'failed',
error: '用户手动终止',
endTime: new Date(),
} as any);
}
/**
* 暂停运行(由 escalate 工具自动触发,此方法用于手动暂停)
*/
async pause(runId: number): Promise<void> {
await this.crewRunRepo.update(runId, {
status: 'paused',
} as any);
}
/**
* 从 pausedState 恢复运行(进程重启后使用)
*/
async resumeFromPausedState(
runId: number,
pausedHistory: any[],
userMessage: string,
callbacks: CrewCallbacks
): Promise<void> {
const run = await this.crewRunRepo.findOne({ where: { id: runId } });
if (!run) throw new Error(`运行 ${runId} 不存在`);
const crew = await this.crewRepo.findOne({ where: { id: run.crewId } });
if (!crew) throw new Error(`集群 ${run.crewId} 不存在`);
const masterAgent = await this.agentRepo.findOne({ where: { id: crew.masterAgentId } });
if (!masterAgent) throw new Error(`主 Agent 不存在`);
// 更新状态为 running
await this.crewRunRepo.update(runId, { status: 'running' } as any);
// 重新构建上下文并继续执行(将用户恢复消息追加到历史中)
// 简化实现:以用户消息重新触发,历史已持久化
const crewAgents = await this.crewAgentRepo.find({ where: { crewId: crew.id } });
const memberAgents = [];
for (const ca of crewAgents) {
if (ca.agentId === crew.masterAgentId) continue;
const agent = await this.agentRepo.findOne({ where: { id: ca.agentId } });
if (agent) {
memberAgents.push({ id: agent.id, name: agent.name, label: agent.label, role: ca.role || '', agent });
}
}
const ctx: CrewRunContext = {
runId, crewId: crew.id, masterAgentId: crew.masterAgentId,
memberAgents, maxConcurrent: crew.maxConcurrent || 3,
taskTimeout: crew.taskTimeout || 300, callbacks,
};
(ctx as any)._delegate = this.crewDelegate;
// 用恢复消息作为新一轮 triggerInput
this.runOrchestration(crew, masterAgent, ctx, `用户恢复指令: ${userMessage}`, callbacks)
.catch(err => this.logger.error(`恢复运行 ${runId} 失败:`, err));
}
}
Step 2.5:实现 CrewService(集群 CRUD 业务层)
文件:packages/backend/src/modules/netaclaw/service/crew.ts
import { Provide, Inject } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, In } from 'typeorm';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewAgentEntity } from '../entity/crew_agent.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
@Provide()
export class CrewService {
@InjectEntityModel(NetaClawCrewEntity)
crewRepo: Repository<NetaClawCrewEntity>;
@InjectEntityModel(NetaClawCrewAgentEntity)
crewAgentRepo: Repository<NetaClawCrewAgentEntity>;
@InjectEntityModel(NetaClawAgentEntity)
agentRepo: Repository<NetaClawAgentEntity>;
/**
* 保存画布数据 + 同步成员关系
*/
async saveCanvas(crewId: number, data: {
canvasData: any;
members: Array<{ agentId: number; role?: string; canvasPosition?: any; groupName?: string }>;
delegateHints?: any;
}): Promise<void> {
// 1. 更新画布数据
await this.crewRepo.update(crewId, {
canvasData: data.canvasData,
delegateHints: data.delegateHints,
} as any);
// 2. 同步成员关系(先删后插,简单可靠)
await this.crewAgentRepo.delete({ crewId });
if (data.members?.length) {
const entities = data.members.map(m => ({
crewId,
agentId: m.agentId,
role: m.role || '',
canvasPosition: m.canvasPosition,
groupName: m.groupName || '',
}));
await this.crewAgentRepo.save(entities as any[]);
}
}
/**
* 获取集群详情(含成员信息)
*/
async getDetail(crewId: number): Promise<any> {
const crew = await this.crewRepo.findOne({ where: { id: crewId } });
if (!crew) return null;
const crewAgents = await this.crewAgentRepo.find({ where: { crewId } });
const agentIds = crewAgents.map(ca => ca.agentId);
const agents = agentIds.length
? await this.agentRepo.find({ where: { id: In(agentIds) } })
: [];
const members = crewAgents.map(ca => {
const agent = agents.find(a => a.id === ca.agentId);
return {
...ca,
agentName: agent?.name,
agentLabel: agent?.label,
agentIcon: agent?.icon,
agentDescription: agent?.description,
};
});
return { ...crew, members };
}
/**
* 发布集群
*/
async publish(crewId: number): Promise<void> {
const crew = await this.crewRepo.findOne({ where: { id: crewId } });
if (!crew) throw new Error('集群不存在');
if (!crew.masterAgentId) throw new Error('请先设置主 Agent');
const members = await this.crewAgentRepo.find({ where: { crewId } });
if (members.length < 2) throw new Error('集群至少需要 2 个成员(含主 Agent)');
await this.crewRepo.update(crewId, { status: 1 } as any);
}
/**
* 取消发布
*/
async unpublish(crewId: number): Promise<void> {
await this.crewRepo.update(crewId, { status: 0 } as any);
}
}
Step 2.6:验证核心引擎
此阶段暂不实现 Controller 和 WebSocket(Phase 3),但可通过单元测试或临时脚本验证:
验证方式:编写一个临时测试服务,在后端启动后通过 API 手动调用
// 临时测试(可在 service 中加一个 test 方法)
// 1. 创建一个 crew 记录
// 2. 创建 crew_agent 记录
// 3. 调用 orchestrator.start()
// 4. 检查 crew_run 和 crew_task 表是否正确记录
验证清单:
- CrewDelegateService 可正确调用 runAgent() 执行子 Agent
- delegate_task 工具可找到目标 Agent 并返回结果
- delegate_parallel 工具可并行执行多个子 Agent
- escalate 工具可阻塞 ReAct 循环
- CrewOrchestrator 可完成完整的编排流程
- crew_run / crew_task 记录正确写入数据库
- token 消耗正确统计
Step 2.7:提交代码
git add packages/backend/src/modules/netaclaw/service/crew_types.ts
git add packages/backend/src/modules/netaclaw/service/crew_delegate.ts
git add packages/backend/src/modules/netaclaw/service/crew_orchestrator.ts
git add packages/backend/src/modules/netaclaw/service/crew.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/escalate.ts
git commit -m "feat(crew): 实现核心编排引擎 - Orchestrator + Delegate + 3个委派工具"
Phase 2 验收
crew_types.ts共享类型定义完成crew_delegate.ts子 Agent 执行器完成(含超时控制、并发限制)delegate_task.ts串行委派工具完成delegate_parallel.ts并行委派工具完成escalate.ts升级人工工具完成(含暂停/恢复机制)crew_orchestrator.ts核心编排器完成(含增强 prompt 构建)crew.ts集群 CRUD 业务层完成- 核心流程验证通过
- 代码已提交
Phase 3:后端通信层(WebSocket + 触发 + 定时)
目标:实现 Controller API + CrewGateway WebSocket + 定时调度器 预估:2 天 依赖:Phase 2 完成
Step 3.1:实现 Crew CRUD Controller
文件:packages/backend/src/modules/netaclaw/controller/admin/crew.ts
import { Body, Inject, Post, Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { NetaClawCrewEntity } from '../../entity/crew.js';
import { CrewService } from '../../service/crew.js';
/**
* 集群管理 CRUD
* 自动生成: add / delete / update / info / list / page
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'update', 'info', 'list', 'page'],
entity: NetaClawCrewEntity,
pageQueryOp: {
fieldEq: ['status'],
keyWordLikeFields: ['name', 'label'],
},
})
export class AdminCrewController extends BaseController {
@Inject()
crewService: CrewService;
/**
* 保存画布 + 同步成员关系
*/
@Post('/saveCanvas')
async saveCanvas(@Body() body: any) {
const { crewId, canvasData, members, delegateHints } = body;
await this.crewService.saveCanvas(crewId, { canvasData, members, delegateHints });
return this.ok();
}
/**
* 获取集群详情(含成员 Agent 信息)
*/
@Post('/detail')
async detail(@Body() body: any) {
const result = await this.crewService.getDetail(body.id);
return this.ok(result);
}
/**
* 发布集群
*/
@Post('/publish')
async publish(@Body() body: any) {
await this.crewService.publish(body.id);
return this.ok();
}
/**
* 取消发布
*/
@Post('/unpublish')
async unpublish(@Body() body: any) {
await this.crewService.unpublish(body.id);
return this.ok();
}
}
自动生成的 API 端点(Cool Admin 约定,路径基于 controller/admin/ 目录结构):
POST /admin/netaclaw/crew/add— 创建集群POST /admin/netaclaw/crew/delete— 删除集群POST /admin/netaclaw/crew/update— 更新集群POST /admin/netaclaw/crew/info— 获取集群信息POST /admin/netaclaw/crew/page— 分页查询POST /admin/netaclaw/crew/saveCanvas— 保存画布POST /admin/netaclaw/crew/detail— 集群详情(含成员)POST /admin/netaclaw/crew/publish— 发布POST /admin/netaclaw/crew/unpublish— 取消发布
注意:设计文档中写的
POST /admin/crew/trigger/start是简写。实际 Cool Admin 路由映射为POST /admin/netaclaw/crewTrigger/start(基于文件路径controller/admin/crew_trigger.ts,驼峰转换)。前端通过 service 代理调用,无需关心具体路径。
前端 service 代理路径:service.netaclaw.crew.saveCanvas() 等
Step 3.2:实现 Crew Run Controller
文件:packages/backend/src/modules/netaclaw/controller/admin/crew_run.ts
import { Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';
/**
* 集群运行记录查询
*/
@Provide()
@CoolController({
api: ['info', 'list', 'page'],
entity: NetaClawCrewRunEntity,
pageQueryOp: {
fieldEq: ['crewId', 'status', 'triggerType'],
where: async (ctx) => {
// 可根据需求添加筛选逻辑
return [];
},
},
})
export class AdminCrewRunController extends BaseController {}
Step 3.3:实现 Crew Trigger Controller
文件:packages/backend/src/modules/netaclaw/controller/admin/crew_trigger.ts
import { Body, Inject, Post, Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { CrewOrchestratorService } from '../../service/crew_orchestrator.js';
import { escalateResolvers } from '../../tools/builtin/escalate.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';
/**
* 集群触发控制
*/
@Provide()
@CoolController()
export class AdminCrewTriggerController extends BaseController {
@Inject()
orchestrator: CrewOrchestratorService;
@InjectEntityModel(NetaClawCrewRunEntity)
crewRunRepo: Repository<NetaClawCrewRunEntity>;
/**
* 手动触发运行
*/
@Post('/start')
async start(@Body() body: { crewId: number; triggerInput?: string }) {
const { crewId, triggerInput = '' } = body;
// 创建 noop 回调(WebSocket 推送在 Gateway 中处理)
// 这里提供基础回调,实际的 WebSocket 推送在 Phase 3.4 集成
const callbacks = this.createNoopCallbacks();
const runId = await this.orchestrator.start(crewId, 'manual', triggerInput, callbacks);
return this.ok({ runId });
}
/**
* 终止运行
*/
@Post('/stop')
async stop(@Body() body: { runId: number }) {
await this.orchestrator.stop(body.runId);
return this.ok();
}
/**
* 恢复暂停的运行(处理 escalate)
*/
@Post('/resume')
async resume(@Body() body: { runId: number; userMessage: string }) {
const { runId, userMessage } = body;
// 优先从内存 Map 中取出 resolve 回调
const resolver = escalateResolvers.get(runId);
if (resolver) {
// 内存中有 → 直接恢复(进程未重启)
resolver(userMessage);
escalateResolvers.delete(runId);
await this.crewRunRepo.update(runId, { status: 'running' } as any);
return this.ok();
}
// 内存中没有(进程重启了)→ 从 pausedState 恢复
const run = await this.crewRunRepo.findOne({ where: { id: runId } });
if (run?.pausedState) {
const history = JSON.parse(run.pausedState as string);
const callbacks = this.createNoopCallbacks();
await this.orchestrator.resumeFromPausedState(runId, history, userMessage, callbacks);
return this.ok();
}
return this.fail('运行已过期或无暂停状态,请重新触发');
}
/**
* 获取运行详情(含子任务列表)
*/
@Post('/runDetail')
async runDetail(@Body() body: { runId: number }) {
const run = await this.crewRunRepo.findOne({ where: { id: body.runId } });
// 查询关联的 crew_task
// ... 补充查询逻辑
return this.ok(run);
}
private createNoopCallbacks() {
return {
onLog: () => {},
onTaskStatus: () => {},
onRunStatus: () => {},
onEscalation: () => {},
};
}
}
Step 3.4:实现 CrewGateway(WebSocket 网关)
文件:packages/backend/src/modules/netaclaw/gateway/crew_server.ts
独立
/crew命名空间,与现有/netaclaw隔离。
import { Inject, Logger, WSController, OnWSConnection, OnWSMessage } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { Context } from '@midwayjs/socketio';
import { CrewOrchestratorService } from '../service/crew_orchestrator.js';
import { escalateResolvers } from '../tools/builtin/escalate.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { CrewCallbacks } from '../service/crew_types.js';
/**
* Crew WebSocket 网关
* 命名空间: /crew
*
* 服务端 → 客户端事件:
* crew:run:status - 运行状态变更
* crew:task:status - 子任务状态变更
* crew:escalation - 升级人工通知
* crew:log - 实时日志
*
* 客户端 → 服务端事件:
* crew:trigger - 触发运行
* crew:control - 控制运行(暂停/恢复/终止)
*/
@WSController('/crew')
export class CrewGateway {
@Inject()
ctx: Context;
@Logger()
logger: ILogger;
@Inject()
orchestrator: CrewOrchestratorService;
@InjectEntityModel(NetaClawCrewRunEntity)
crewRunRepo: Repository<NetaClawCrewRunEntity>;
@OnWSConnection()
async onConnection() {
this.logger.info('[Crew WS] 客户端连接:', this.ctx.id);
}
@OnWSMessage('crew:trigger')
async onTrigger(data: { crewId: number; triggerInput?: string }) {
const { crewId, triggerInput = '' } = data;
// 构建 WebSocket 推送回调
const callbacks = this.createCallbacks();
try {
const runId = await this.orchestrator.start(crewId, 'manual', triggerInput, callbacks);
this.ctx.emit('crew:run:status', { runId, status: 'running' });
} catch (err: any) {
this.ctx.emit('crew:run:status', { runId: 0, status: 'failed', error: err.message });
}
}
@OnWSMessage('crew:control')
async onControl(data: { runId: number; action: string; userMessage?: string }) {
const { runId, action, userMessage } = data;
switch (action) {
case 'stop':
await this.orchestrator.stop(runId);
this.ctx.emit('crew:run:status', { runId, status: 'failed' });
break;
case 'resume':
const resolver = escalateResolvers.get(runId);
if (resolver) {
resolver(userMessage || '继续执行');
escalateResolvers.delete(runId);
await this.crewRunRepo.update(runId, { status: 'running' } as any);
this.ctx.emit('crew:run:status', { runId, status: 'running' });
}
break;
case 'pause':
await this.orchestrator.pause(runId);
this.ctx.emit('crew:run:status', { runId, status: 'paused' });
break;
case 'retry': {
// 重新触发一次运行(复制原 run 的 crewId 和 triggerInput)
const run = await this.crewRunRepo.findOne({ where: { id: runId } });
if (run) {
const callbacks = this.createCallbacks();
const newRunId = await this.orchestrator.start(run.crewId, 'manual', run.triggerInput || '', callbacks);
this.ctx.emit('crew:run:status', { runId: newRunId, status: 'running' });
}
break;
}
}
}
/**
* 构建 WebSocket 推送回调
*/
private createCallbacks(): CrewCallbacks {
const ctx = this.ctx;
return {
onLog: (runId, taskId, agentName, level, message) => {
ctx.emit('crew:log', {
runId, taskId, agentName, level, message,
timestamp: new Date().toISOString(),
});
},
onTaskStatus: (runId, taskId, agentName, status, result, error) => {
ctx.emit('crew:task:status', { runId, taskId, agentName, status, result, error });
},
onRunStatus: (runId, status, progress) => {
ctx.emit('crew:run:status', { runId, status, progress });
},
onEscalation: (runId, taskId, reason, error) => {
ctx.emit('crew:escalation', { runId, taskId, reason, error });
},
};
}
}
Step 3.5:实现 CrewScheduler(定时调度器)
文件:packages/backend/src/modules/netaclaw/service/crew_scheduler.ts
import { Provide, Inject, Logger, Init } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CronJob } from 'cron';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { CrewOrchestratorService } from './crew_orchestrator.js';
import { escalateResolvers } from '../tools/builtin/escalate.js';
/**
* 集群定时调度器
* - 管理 CronJob 实例的动态创建/销毁
* - 服务启动时恢复已发布集群的定时任务
* - 服务启动时恢复 paused 状态的运行
*/
@Provide()
export class CrewSchedulerService {
@Logger()
logger: ILogger;
@InjectEntityModel(NetaClawCrewEntity)
crewRepo: Repository<NetaClawCrewEntity>;
@InjectEntityModel(NetaClawCrewRunEntity)
crewRunRepo: Repository<NetaClawCrewRunEntity>;
@Inject()
orchestrator: CrewOrchestratorService;
/** 活跃的 CronJob 实例 */
private activeJobs = new Map<number, CronJob>();
/**
* 服务启动时初始化
*/
@Init()
async init() {
// 延迟初始化,等待其他服务就绪
setTimeout(() => {
this.restoreSchedules().catch(err =>
this.logger.error('[CrewScheduler] 恢复定时任务失败:', err)
);
this.restorePausedRuns().catch(err =>
this.logger.error('[CrewScheduler] 恢复暂停运行失败:', err)
);
}, 5000);
}
/**
* 注册定时任务
*/
registerCron(crew: NetaClawCrewEntity): void {
if (!crew.triggerConfig?.cron?.enabled) return;
// 先取消已有的
this.unregisterCron(crew.id);
try {
const job = new CronJob(
crew.triggerConfig.cron.expression,
() => {
this.logger.info(`[CrewScheduler] 定时触发集群 ${crew.id}: ${crew.label}`);
const callbacks = {
onLog: () => {},
onTaskStatus: () => {},
onRunStatus: () => {},
onEscalation: () => {},
};
this.orchestrator.start(crew.id, 'cron', '', callbacks)
.catch(err => this.logger.error(`[CrewScheduler] 定时运行失败:`, err));
},
null,
true,
crew.triggerConfig.cron.timezone || 'Asia/Shanghai'
);
this.activeJobs.set(crew.id, job);
this.logger.info(`[CrewScheduler] 注册定时任务: 集群 ${crew.id}, cron=${crew.triggerConfig.cron.expression}`);
} catch (err) {
this.logger.error(`[CrewScheduler] 注册定时任务失败:`, err);
}
}
/**
* 取消定时任务
*/
unregisterCron(crewId: number): void {
const job = this.activeJobs.get(crewId);
if (job) {
job.stop();
this.activeJobs.delete(crewId);
this.logger.info(`[CrewScheduler] 取消定时任务: 集群 ${crewId}`);
}
}
/**
* 恢复所有已发布集群的定时任务
*/
private async restoreSchedules(): Promise<void> {
const publishedCrews = await this.crewRepo.find({ where: { status: 1 } });
const cronCrews = publishedCrews.filter(c => c.triggerConfig?.cron?.enabled);
this.logger.info(`[CrewScheduler] 恢复 ${cronCrews.length} 个定时任务`);
cronCrews.forEach(crew => this.registerCron(crew));
}
/**
* 恢复 paused 状态的运行(进程重启后)
*/
private async restorePausedRuns(): Promise<void> {
const pausedRuns = await this.crewRunRepo.find({ where: { status: 'paused' } });
if (pausedRuns.length === 0) return;
this.logger.info(`[CrewScheduler] 发现 ${pausedRuns.length} 个暂停的运行,等待用户恢复`);
// 暂停的运行不自动恢复,而是等待用户手动处理
// 如果 pausedState 存在,可以在用户恢复时使用
}
/**
* 获取活跃定时任务数
*/
getActiveJobCount(): number {
return this.activeJobs.size;
}
}
Step 3.6:集成定时调度到发布/取消发布流程
修改文件:packages/backend/src/modules/netaclaw/service/crew.ts
在 publish() 和 unpublish() 方法中注入调度器:
// 在 CrewService 中注入
@Inject()
scheduler: CrewSchedulerService;
// publish() 中追加:
async publish(crewId: number): Promise<void> {
// ... 现有校验逻辑 ...
await this.crewRepo.update(crewId, { status: 1 } as any);
// 注册定时任务
const crew = await this.crewRepo.findOne({ where: { id: crewId } });
if (crew) this.scheduler.registerCron(crew);
}
// unpublish() 中追加:
async unpublish(crewId: number): Promise<void> {
await this.crewRepo.update(crewId, { status: 0 } as any);
// 取消定时任务
this.scheduler.unregisterCron(crewId);
}
Step 3.7:验证通信层
验证清单:
- CRUD API:通过 Postman/curl 测试集群的创建、查询、更新、删除
- 画布保存:
saveCanvasAPI 正确保存 canvasData + 同步成员关系 - 发布流程:发布时校验主 Agent 和成员数量
- 手动触发:通过 REST API 和 WebSocket 均可触发运行
- WebSocket 事件:连接
/crew命名空间,触发运行后能收到crew:run:status、crew:task:status、crew:log事件 - 终止运行:
crew:control { action: 'stop' }正确终止 - 升级恢复:escalate 后通过
crew:control { action: 'resume' }正确恢复 - 定时调度:发布带 cron 配置的集群后,定时任务注册成功
- 前端 service 代理路径可访问:
service.netaclaw.crew.*
Step 3.8:提交代码
git add packages/backend/src/modules/netaclaw/controller/admin/crew*.ts
git add packages/backend/src/modules/netaclaw/gateway/crew_server.ts
git add packages/backend/src/modules/netaclaw/service/crew_scheduler.ts
# + crew.ts 的修改
git commit -m "feat(crew): 实现通信层 - Controller + WebSocket Gateway + 定时调度器"
Phase 3 验收
- 3 个 Controller 创建完成(crew / crew_run / crew_trigger)
- CrewGateway WebSocket 网关工作正常
- CrewScheduler 定时调度器工作正常
- 发布/取消发布联动定时调度
- REST API + WebSocket 双通道触发验证通过
- 代码已提交
Phase 4:前端编排页
目标:实现完整的集群编排画布页面(Vue Flow 画布 + Agent 侧栏 + 属性面板) 预估:3 天(Vue Flow 首次使用,额外预留缓冲) 依赖:Phase 3 完成(后端 API 可用)
Step 4.1:注册路由与菜单
4.1.1 修改前端路由配置
文件:packages/frontend/src/modules/agent/config.ts
在 views 数组中追加:
{
path: '/agent/crew-editor',
meta: { label: 'Agent 编排' },
component: () => import('./views/crew-editor.vue')
},
{
path: '/agent/crew-monitor',
meta: { label: '运行监控' },
component: () => import('./views/crew-monitor.vue')
},
4.1.2 插入菜单记录
通过数据库插入 base_sys_menu 记录(Cool Admin 菜单管理):
-- 在 Agent 管理父菜单下新增
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms)
VALUES
('Agent 编排', '/agent/crew-editor', <agent父菜单ID>, 5, 1, 'icon-crew', 'agent:crew:editor'),
('运行监控', '/agent/crew-monitor', <agent父菜单ID>, 6, 1, 'icon-monitor', 'agent:crew:monitor');
具体 parentId 需查询当前数据库中 Agent 管理的菜单 ID。
Step 4.2:实现 Pinia Store
文件:packages/frontend/src/modules/agent/store/crew.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useCool } from '/@/cool';
/**
* Crew 编排状态管理
*/
export const useCrewStore = defineStore('agent-crew', () => {
const { service } = useCool();
// 集群列表
const crewList = ref<any[]>([]);
// 当前编辑的集群
const currentCrew = ref<any>(null);
// 当前集群的成员列表
const currentMembers = ref<any[]>([]);
// 可用 Agent 列表(已发布的 Agent)
const availableAgents = ref<any[]>([]);
// 加载集群列表
async function loadCrewList() {
const res = await service.netaclaw.crew.list();
crewList.value = res || [];
}
// 加载集群详情
async function loadCrewDetail(crewId: number) {
const res = await service.netaclaw.crew.detail({ id: crewId });
currentCrew.value = res;
currentMembers.value = res?.members || [];
return res;
}
// 加载可用 Agent
async function loadAvailableAgents() {
const res = await service.netaclaw.agent.list({ status: 1 });
availableAgents.value = res || [];
}
// 保存画布
async function saveCanvas(crewId: number, canvasData: any, members: any[], delegateHints: any) {
await service.netaclaw.crew.saveCanvas({
crewId, canvasData, members, delegateHints,
});
}
// 创建集群
async function createCrew(data: any) {
const res = await service.netaclaw.crew.add(data);
await loadCrewList();
return res;
}
// 发布/取消发布
async function publish(crewId: number) {
await service.netaclaw.crew.publish({ id: crewId });
await loadCrewDetail(crewId);
}
async function unpublish(crewId: number) {
await service.netaclaw.crew.unpublish({ id: crewId });
await loadCrewDetail(crewId);
}
// 判断某 Agent 是否已在当前集群中
const isMember = computed(() => {
const ids = new Set(currentMembers.value.map((m: any) => m.agentId));
return (agentId: number) => ids.has(agentId);
});
return {
crewList, currentCrew, currentMembers, availableAgents,
loadCrewList, loadCrewDetail, loadAvailableAgents,
saveCanvas, createCrew, publish, unpublish,
isMember,
};
});
Step 4.3:实现画布核心 Hook
文件:packages/frontend/src/modules/agent/hooks/crew-canvas.ts
import { ref, watch } from 'vue';
import { useVueFlow, type Node, type Edge } from '@vue-flow/core';
/**
* 画布操作 Hook
* 封装 Vue Flow 的节点/连线增删、序列化/反序列化
*/
export function useCrewCanvas() {
const {
nodes, edges, addNodes, removeNodes,
addEdges, removeEdges, fitView,
onConnect, onNodeDragStop,
} = useVueFlow();
// 主 Agent ID
const masterAgentId = ref<number | null>(null);
/**
* 添加 Agent 节点到画布
*/
function addAgentNode(agent: any, position?: { x: number; y: number }) {
const id = `agent-${agent.id}`;
// 避免重复添加
if (nodes.value.find(n => n.id === id)) return;
const node: Node = {
id,
type: 'crew-agent', // 自定义节点类型
position: position || { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
data: {
agentId: agent.id,
name: agent.name,
label: agent.label,
icon: agent.icon,
description: agent.description,
role: '',
groupName: '',
isMaster: agent.id === masterAgentId.value,
},
};
addNodes([node]);
}
/**
* 移除 Agent 节点
*/
function removeAgentNode(agentId: number) {
const nodeId = `agent-${agentId}`;
const node = nodes.value.find(n => n.id === nodeId);
if (node) removeNodes([node]);
}
/**
* 设置主 Agent
*/
function setMasterAgent(agentId: number) {
masterAgentId.value = agentId;
// 更新所有节点的 isMaster 标记
nodes.value.forEach(n => {
if (n.data) {
n.data.isMaster = n.data.agentId === agentId;
}
});
}
/**
* 序列化画布数据(保存用)
*/
function serializeCanvas() {
return {
nodes: nodes.value.map(n => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})),
edges: edges.value.map(e => ({
id: e.id,
source: e.source,
target: e.target,
data: e.data,
})),
};
}
/**
* 反序列化画布数据(加载用)
*/
function deserializeCanvas(canvasData: any) {
if (!canvasData?.nodes) return;
// 清空当前画布
removeNodes(nodes.value);
removeEdges(edges.value);
// 恢复节点
addNodes(canvasData.nodes.map((n: any) => ({
...n,
type: n.type || 'crew-agent',
})));
// 恢复连线
if (canvasData.edges?.length) {
addEdges(canvasData.edges);
}
// 延迟 fitView
setTimeout(() => fitView({ padding: 0.2 }), 100);
}
/**
* 从画布状态提取成员列表(保存到 crew_agent 表)
*/
function extractMembers() {
return nodes.value
.filter(n => n.data?.agentId)
.map(n => ({
agentId: n.data.agentId,
role: n.data.role || '',
canvasPosition: n.position,
groupName: n.data.groupName || '',
}));
}
return {
nodes, edges, masterAgentId,
addAgentNode, removeAgentNode, setMasterAgent,
serializeCanvas, deserializeCanvas, extractMembers,
onConnect, onNodeDragStop, fitView,
};
}
Step 4.4:实现画布 → delegateHints 转换 Hook
文件:packages/frontend/src/modules/agent/hooks/crew-orchestration.ts
import type { Edge, Node } from '@vue-flow/core';
/**
* 从画布连线生成 delegateHints
* 供主 Agent system prompt 使用
*/
export function canvasToHints(
nodes: Node[],
edges: Edge[]
): { hints: string; edges: Array<{ from: string; to: string; type: string; note?: string }> } {
if (!edges.length) {
return { hints: '无特定调度建议,请根据任务需求自行安排。', edges: [] };
}
const nodeMap = new Map<string, string>();
nodes.forEach(n => {
if (n.data?.label) nodeMap.set(n.id, n.data.label);
});
// 解析连线
const edgeInfos = edges.map(e => ({
from: nodeMap.get(e.source) || e.source,
to: nodeMap.get(e.target) || e.target,
type: e.data?.edgeType || 'serial',
note: e.data?.note || '',
}));
// 生成自然语言提示
const serialEdges = edgeInfos.filter(e => e.type === 'serial');
const parallelEdges = edgeInfos.filter(e => e.type === 'parallel');
let hints = '建议执行顺序:\n';
if (serialEdges.length) {
// 构建串行链
const chains: string[] = [];
serialEdges.forEach(e => {
const note = e.note ? `(${e.note})` : '';
chains.push(`${e.from} → ${e.to}${note}`);
});
hints += `串行依赖:${chains.join(';')}\n`;
}
if (parallelEdges.length) {
const parallel = parallelEdges.map(e => `${e.from} 与 ${e.to}`);
hints += `可并行执行:${parallel.join(';')}\n`;
}
return { hints, edges: edgeInfos };
}
Step 4.5:实现自定义节点组件
文件:packages/frontend/src/modules/agent/components/crew/crew-agent-node.vue
<template>
<div
class="crew-agent-node"
:class="{
'is-master': data.isMaster,
'is-running': liveStatus === 'running',
'is-completed': liveStatus === 'completed',
'is-failed': liveStatus === 'failed',
}"
>
<!-- 主 Agent 星标 -->
<div v-if="data.isMaster" class="master-badge">★</div>
<!-- 分组色带 -->
<div v-if="data.groupName" class="group-ribbon" :style="{ background: groupColor }">
{{ data.groupName }}
</div>
<!-- Agent 图标 -->
<div class="node-icon">
<el-icon v-if="data.icon" :size="28"><component :is="data.icon" /></el-icon>
<el-icon v-else :size="28"><UserFilled /></el-icon>
</div>
<!-- Agent 名称 -->
<div class="node-label">{{ data.label || data.name }}</div>
<!-- 角色标签 -->
<div v-if="data.role" class="node-role">{{ data.role }}</div>
<!-- 连线锚点 (四边) -->
<Handle type="source" :position="Position.Right" />
<Handle type="source" :position="Position.Bottom" />
<Handle type="target" :position="Position.Left" />
<Handle type="target" :position="Position.Top" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Handle, Position } from '@vue-flow/core';
import { UserFilled } from '@element-plus/icons-vue';
const props = defineProps<{
data: {
agentId: number;
name: string;
label: string;
icon?: string;
description?: string;
role?: string;
groupName?: string;
isMaster?: boolean;
};
liveStatus?: string; // 监控页使用
}>();
// 分组颜色(基于名称 hash)
const groupColor = computed(() => {
if (!props.data.groupName) return '';
const hash = [...props.data.groupName].reduce((acc, c) => acc + c.charCodeAt(0), 0);
const hue = hash % 360;
return `hsl(${hue}, 60%, 85%)`;
});
</script>
<style scoped>
.crew-agent-node {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 12px;
background: white;
min-width: 140px;
text-align: center;
position: relative;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.crew-agent-node:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.crew-agent-node.is-master {
border-color: #e6a23c;
background: linear-gradient(135deg, #fffbf0, #fff8e6);
}
.crew-agent-node.is-running {
border-color: #409eff;
animation: breathe 2s ease-in-out infinite;
}
.crew-agent-node.is-completed {
border-color: #67c23a;
}
.crew-agent-node.is-failed {
border-color: #f56c6c;
}
.master-badge {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #e6a23c;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.group-ribbon {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 2px 8px;
font-size: 10px;
border-radius: 10px 10px 0 0;
text-align: center;
}
.node-icon { margin-bottom: 6px; }
.node-label { font-weight: 600; font-size: 14px; }
.node-role { font-size: 11px; color: #909399; margin-top: 4px; }
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(64, 158, 255, 0); }
}
</style>
Step 4.6:实现自定义连线标签
文件:packages/frontend/src/modules/agent/components/crew/crew-edge-label.vue
<template>
<div class="crew-edge-label" :class="{ 'is-parallel': isParallel }">
<el-icon :size="14">
<component :is="isParallel ? 'Refresh' : 'Right'" />
</el-icon>
<span v-if="label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
data?: { edgeType?: string; note?: string };
}>();
const isParallel = computed(() => props.data?.edgeType === 'parallel');
const label = computed(() => props.data?.note || '');
</script>
<style scoped>
.crew-edge-label {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: white;
border: 1px solid #ddd;
border-radius: 10px;
font-size: 11px;
white-space: nowrap;
}
.crew-edge-label.is-parallel {
border-color: #409eff;
color: #409eff;
}
</style>
Step 4.7:实现左侧 Agent 列表
文件:packages/frontend/src/modules/agent/components/crew/crew-sidebar.vue
<template>
<div class="crew-sidebar">
<el-input v-model="search" placeholder="搜索 Agent..." clearable prefix-icon="Search" />
<div class="agent-list">
<div
v-for="agent in filteredAgents"
:key="agent.id"
class="agent-item"
:class="{ 'is-member': isMember(agent.id) }"
draggable="true"
@dragstart="onDragStart($event, agent)"
>
<div class="agent-info">
<span class="agent-name">{{ agent.label || agent.name }}</span>
<span class="agent-desc">{{ agent.description }}</span>
</div>
<el-icon v-if="isMember(agent.id)" color="#67c23a"><Check /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Check } from '@element-plus/icons-vue';
const props = defineProps<{
agents: any[];
isMember: (id: number) => boolean;
}>();
const emit = defineEmits<{
(e: 'drop-agent', agent: any, position: { x: number; y: number }): void;
}>();
const search = ref('');
const filteredAgents = computed(() => {
if (!search.value) return props.agents;
const q = search.value.toLowerCase();
return props.agents.filter(a =>
a.name?.toLowerCase().includes(q) ||
a.label?.toLowerCase().includes(q) ||
a.description?.toLowerCase().includes(q)
);
});
function onDragStart(event: DragEvent, agent: any) {
event.dataTransfer?.setData('application/crew-agent', JSON.stringify(agent));
}
</script>
<style scoped>
.crew-sidebar {
width: 240px;
border-right: 1px solid #eee;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-list { flex: 1; overflow-y: auto; }
.agent-item {
padding: 10px 12px;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 8px;
cursor: grab;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.2s;
}
.agent-item:hover { background: #f5f7fa; }
.agent-item.is-member { background: #f0f9eb; }
.agent-info { flex: 1; }
.agent-name { font-weight: 500; display: block; }
.agent-desc { font-size: 12px; color: #909399; }
</style>
Step 4.8:实现属性面板
文件:packages/frontend/src/modules/agent/components/crew/crew-property-panel.vue
<template>
<div class="crew-property-panel">
<!-- 选中节点时 -->
<template v-if="selectedNode">
<h4>Agent 属性</h4>
<el-form label-position="top" size="small">
<el-form-item label="名称">
<span>{{ selectedNode.data.label }}</span>
</el-form-item>
<el-form-item label="角色描述">
<el-input
v-model="selectedNode.data.role"
type="textarea"
:rows="3"
placeholder="描述该 Agent 在集群中的角色..."
/>
</el-form-item>
<el-form-item label="分组">
<el-input v-model="selectedNode.data.groupName" placeholder="如:淘宝组" />
</el-form-item>
<el-form-item label="重试次数">
<el-input-number v-model="selectedNode.data.retryCount" :min="0" :max="5" />
</el-form-item>
<el-button type="primary" text @click="$emit('set-master', selectedNode.data.agentId)">
设为主 Agent
</el-button>
</el-form>
</template>
<!-- 选中连线时 -->
<template v-else-if="selectedEdge">
<h4>连线属性</h4>
<el-form label-position="top" size="small">
<el-form-item label="类型">
<el-radio-group v-model="selectedEdge.data.edgeType">
<el-radio value="serial">串行依赖</el-radio>
<el-radio value="parallel">并行建议</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标注">
<el-input v-model="selectedEdge.data.note" placeholder="如:需要先登录获取cookie" />
</el-form-item>
</el-form>
</template>
<!-- 未选中时:全局配置 -->
<template v-else>
<h4>集群配置</h4>
<el-form v-if="crew" label-position="top" size="small">
<el-form-item label="集群名称">
<el-input v-model="crew.label" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="crew.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="最大并发数">
<el-input-number v-model="crew.maxConcurrent" :min="1" :max="10" />
</el-form-item>
<el-form-item label="默认超时(秒)">
<el-input-number v-model="crew.taskTimeout" :min="60" :max="3600" :step="60" />
</el-form-item>
<!-- 触发配置子组件 -->
<crew-trigger-config v-model="crew.triggerConfig" />
</el-form>
</template>
</div>
</template>
<script setup lang="ts">
import CrewTriggerConfig from './crew-trigger-config.vue';
defineProps<{
selectedNode?: any;
selectedEdge?: any;
crew?: any;
}>();
defineEmits<{
(e: 'set-master', agentId: number): void;
}>();
</script>
<style scoped>
.crew-property-panel {
border-top: 1px solid #eee;
padding: 12px 16px;
max-height: 280px;
overflow-y: auto;
}
h4 { margin: 0 0 12px; font-size: 14px; }
</style>
Step 4.9:实现触发配置子组件
文件:packages/frontend/src/modules/agent/components/crew/crew-trigger-config.vue
<template>
<div class="trigger-config">
<el-divider content-position="left">触发方式</el-divider>
<el-form-item label="手动触发">
<el-switch v-model="config.manual" />
</el-form-item>
<el-form-item label="定时触发">
<el-switch v-model="config.cron.enabled" />
</el-form-item>
<template v-if="config.cron?.enabled">
<el-form-item label="Cron 表达式">
<el-input v-model="config.cron.expression" placeholder="0 9 * * *" />
</el-form-item>
<el-form-item label="时区">
<el-select v-model="config.cron.timezone">
<el-option value="Asia/Shanghai" label="Asia/Shanghai" />
<el-option value="UTC" label="UTC" />
</el-select>
</el-form-item>
</template>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue';
const props = defineProps<{ modelValue?: any }>();
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>();
const config = reactive({
manual: true,
cron: { enabled: false, expression: '0 9 * * *', timezone: 'Asia/Shanghai' },
...props.modelValue,
});
watch(config, (v) => emit('update:modelValue', { ...v }), { deep: true });
</script>
Step 4.10:实现编排页主视图
文件:packages/frontend/src/modules/agent/views/crew-editor.vue
<template>
<div class="crew-editor">
<!-- 顶部工具栏 -->
<div class="toolbar">
<el-select v-model="selectedCrewId" placeholder="选择集群" @change="onCrewChange">
<el-option
v-for="c in crewStore.crewList"
:key="c.id" :label="c.label" :value="c.id"
/>
</el-select>
<el-button @click="onNewCrew">+ 新建集群</el-button>
<el-divider direction="vertical" />
<el-button type="primary" @click="onSave" :loading="saving">保存</el-button>
<el-button
v-if="crewStore.currentCrew?.status === 0"
type="success" @click="onPublish"
>发布</el-button>
<el-button
v-else-if="crewStore.currentCrew?.status === 1"
type="warning" @click="onUnpublish"
>取消发布</el-button>
<el-button @click="onTestRun" :disabled="crewStore.currentCrew?.status !== 1">
试运行
</el-button>
</div>
<!-- 主体:左侧栏 + 画布 -->
<div class="main-area">
<crew-sidebar
:agents="crewStore.availableAgents"
:is-member="crewStore.isMember"
/>
<div class="canvas-container" @drop="onDrop" @dragover.prevent>
<VueFlow
:nodes="canvasHook.nodes.value"
:edges="canvasHook.edges.value"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@connect="onConnect"
>
<template #node-crew-agent="{ data }">
<crew-agent-node :data="data" />
</template>
<MiniMap />
<Controls />
<Background />
</VueFlow>
</div>
</div>
<!-- 底部属性面板 -->
<crew-property-panel
:selected-node="selectedNode"
:selected-edge="selectedEdge"
:crew="crewStore.currentCrew"
@set-master="onSetMaster"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { Controls } from '@vue-flow/controls';
import { Background } from '@vue-flow/background';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/controls/dist/style.css';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useCrewStore } from '../store/crew';
import { useCrewCanvas } from '../hooks/crew-canvas';
import { canvasToHints } from '../hooks/crew-orchestration';
import CrewSidebar from '../components/crew/crew-sidebar.vue';
import CrewAgentNode from '../components/crew/crew-agent-node.vue';
import CrewPropertyPanel from '../components/crew/crew-property-panel.vue';
const router = useRouter();
const crewStore = useCrewStore();
const canvasHook = useCrewCanvas();
const selectedCrewId = ref<number | null>(null);
const selectedNode = ref<any>(null);
const selectedEdge = ref<any>(null);
const saving = ref(false);
onMounted(async () => {
await crewStore.loadCrewList();
await crewStore.loadAvailableAgents();
});
async function onCrewChange(crewId: number) {
const detail = await crewStore.loadCrewDetail(crewId);
if (detail?.canvasData) {
canvasHook.deserializeCanvas(detail.canvasData);
}
if (detail?.masterAgentId) {
canvasHook.setMasterAgent(detail.masterAgentId);
}
}
function onNodeClick({ node }: any) {
selectedNode.value = node;
selectedEdge.value = null;
}
function onEdgeClick({ edge }: any) {
selectedEdge.value = edge;
selectedNode.value = null;
// 确保 edge.data 存在
if (!edge.data) edge.data = { edgeType: 'serial', note: '' };
}
function onPaneClick() {
selectedNode.value = null;
selectedEdge.value = null;
}
function onConnect(params: any) {
canvasHook.edges.value.push({
...params,
id: `e-${params.source}-${params.target}`,
data: { edgeType: 'serial', note: '' },
});
}
function onDrop(event: DragEvent) {
const data = event.dataTransfer?.getData('application/crew-agent');
if (!data) return;
const agent = JSON.parse(data);
const rect = (event.target as HTMLElement).getBoundingClientRect();
canvasHook.addAgentNode(agent, {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
}
function onSetMaster(agentId: number) {
canvasHook.setMasterAgent(agentId);
if (crewStore.currentCrew) {
crewStore.currentCrew.masterAgentId = agentId;
}
}
async function onSave() {
if (!selectedCrewId.value) return;
saving.value = true;
try {
const canvasData = canvasHook.serializeCanvas();
const members = canvasHook.extractMembers();
const hints = canvasToHints(canvasHook.nodes.value, canvasHook.edges.value);
await crewStore.saveCanvas(selectedCrewId.value, canvasData, members, hints);
ElMessage.success('保存成功');
} finally {
saving.value = false;
}
}
async function onPublish() {
if (!selectedCrewId.value) return;
await crewStore.publish(selectedCrewId.value);
ElMessage.success('发布成功');
}
async function onUnpublish() {
if (!selectedCrewId.value) return;
await crewStore.unpublish(selectedCrewId.value);
ElMessage.success('已取消发布');
}
async function onNewCrew() {
// 弹出创建对话框(简化版直接创建)
const crew = await crewStore.createCrew({
name: `crew-${Date.now()}`,
label: '新建集群',
status: 0,
triggerConfig: { manual: true, cron: { enabled: false, expression: '', timezone: '' } },
});
selectedCrewId.value = crew.id;
}
function onTestRun() {
router.push(`/agent/crew-monitor?crewId=${selectedCrewId.value}&autoRun=1`);
}
</script>
<style scoped>
.crew-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid #eee;
}
.main-area {
flex: 1;
display: flex;
overflow: hidden;
}
.canvas-container {
flex: 1;
position: relative;
}
</style>
Step 4.11:实现右键菜单组件
文件:packages/frontend/src/modules/agent/components/crew/crew-context-menu.vue
<template>
<div
v-if="visible"
class="crew-context-menu"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
>
<template v-if="targetType === 'node'">
<div class="menu-item" @click="$emit('set-master')">设为主 Agent</div>
<div class="menu-item" @click="$emit('edit-agent')">编辑 Agent</div>
<div class="menu-item danger" @click="$emit('remove-node')">移出集群</div>
</template>
<template v-else-if="targetType === 'edge'">
<div class="menu-item danger" @click="$emit('remove-edge')">删除连线</div>
</template>
<template v-else>
<div class="menu-item" @click="$emit('auto-layout')">自动布局</div>
<div class="menu-item" v-if="hasSelection" @click="$emit('group-selection')">设为分组</div>
</template>
</div>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean;
position: { x: number; y: number };
targetType: 'node' | 'edge' | 'pane';
hasSelection?: boolean;
}>();
defineEmits<{
(e: 'set-master'): void;
(e: 'edit-agent'): void;
(e: 'remove-node'): void;
(e: 'remove-edge'): void;
(e: 'auto-layout'): void;
(e: 'group-selection'): void;
}>();
</script>
<style scoped>
.crew-context-menu {
position: fixed;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 140px;
z-index: 1000;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
}
.menu-item:hover { background: #f5f7fa; }
.menu-item.danger { color: #f56c6c; }
</style>
在 crew-editor.vue 中集成右键菜单:在 <VueFlow> 上监听 @node-context-menu、@edge-context-menu、@pane-context-menu 事件,控制菜单显示/隐藏和位置。
Step 4.12:实现自动布局(elkjs)
在 hooks/crew-canvas.ts 中新增 autoLayout 方法:
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
/**
* 使用 elkjs 自动布局
*/
async function autoLayout() {
const graph = {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'RIGHT',
'elk.spacing.nodeNode': '80',
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
},
children: nodes.value.map(n => ({
id: n.id,
width: 160,
height: 80,
})),
edges: edges.value.map(e => ({
id: e.id,
sources: [e.source],
targets: [e.target],
})),
};
const layout = await elk.layout(graph);
// 应用布局结果到节点位置
layout.children?.forEach(child => {
const node = nodes.value.find(n => n.id === child.id);
if (node && child.x !== undefined && child.y !== undefined) {
node.position = { x: child.x, y: child.y };
}
});
setTimeout(() => fitView({ padding: 0.2 }), 100);
}
在 useCrewCanvas 的返回值中导出 autoLayout,右键菜单的"自动布局"调用此方法。
Step 4.13:实现框选分组
在 crew-editor.vue 中利用 Vue Flow 的 selectionMode 和 @selection-end 事件实现框选:
// 在 crew-editor.vue 的 <VueFlow> 上添加:
// selection-mode="box"
// @selection-end="onSelectionEnd"
function onSelectionEnd({ nodes: selectedNodes }: any) {
if (selectedNodes.length < 2) return;
// 弹出分组名输入框
ElMessageBox.prompt('请输入分组名', '设为分组', {
confirmButtonText: '确定',
cancelButtonText: '取消',
}).then(({ value }) => {
selectedNodes.forEach((n: any) => {
if (n.data) n.data.groupName = value;
});
}).catch(() => {});
}
Step 4.14:验证编排页
验证清单:
- 路由
/agent/crew-editor正常访问 - 左侧 Agent 列表正确加载
- 拖拽 Agent 到画布,节点正常渲染
- 节点间可拖拽连线
- 右键节点:设为主 Agent / 移出集群 / 编辑 Agent
- 右键连线:删除连线
- 右键空白:自动布局(elkjs)
- 框选多节点 → 右键"设为分组"
- 主 Agent 显示金色边框 + 星标
- 点击节点/连线/空白,底部属性面板正确切换
- 属性面板:角色描述、分组名、重试次数均可编辑
- 连线类型(串行/并行)可切换
- 保存功能正常(canvasData + members + delegateHints 持久化)
- 加载已保存的集群,画布正确恢复
- 发布/取消发布功能正常
- MiniMap / Controls / Background 正常显示
Step 4.15:提交代码
git add packages/frontend/src/modules/agent/config.ts
git add packages/frontend/src/modules/agent/store/crew.ts
git add packages/frontend/src/modules/agent/hooks/crew-canvas.ts
git add packages/frontend/src/modules/agent/hooks/crew-orchestration.ts
git add packages/frontend/src/modules/agent/views/crew-editor.vue
git add packages/frontend/src/modules/agent/components/crew/
git commit -m "feat(crew): 实现编排页 - Vue Flow 画布 + Agent 侧栏 + 属性面板"
Phase 4 验收
- 前端路由注册完成
- Pinia Store 完成
- 画布操作 Hook 完成(含 elkjs 自动布局)
- delegateHints 转换 Hook 完成
- 自定义节点/连线组件完成
- 右键菜单组件完成(节点/连线/画布三种模式)
- 侧栏/属性面板(含重试次数)/触发配置组件完成
- 框选分组功能完成
- 编排页主视图完成
- 拖拽添加 Agent → 连线 → 配置角色 → 保存 全流程验证通过
- 代码已提交
Phase 5:前端监控页
目标:实现运行列表 + 运行详情(实时画布 + 任务时间线 + 日志面板) 预估:2.5 天 依赖:Phase 4 完成(画布组件可复用)
Step 5.1:实现 WebSocket 监控 Hook
文件:packages/frontend/src/modules/agent/hooks/crew-monitor.ts
import { ref, onUnmounted } from 'vue';
import { io, Socket } from 'socket.io-client';
import { useStore } from '../../../store';
import config from '/@/config';
/**
* Crew 监控 WebSocket Hook
* 连接 /crew 命名空间,订阅实时事件
*/
export function useCrewMonitor() {
const socket = ref<Socket | null>(null);
const { user } = useStore();
// 实时状态
const runStatusMap = ref<Map<number, any>>(new Map());
const taskStatusMap = ref<Map<number, any>>(new Map());
const logs = ref<any[]>([]);
const escalations = ref<any[]>([]);
/** 连接 WebSocket */
function connect() {
const wsUrl = (config as any).host || 'http://127.0.0.1:8001';
socket.value = io(`${wsUrl}/crew`, {
auth: { token: user.token, isAdmin: true },
transports: ['websocket'],
});
socket.value.on('crew:run:status', (data: any) => {
runStatusMap.value.set(data.runId, data);
});
socket.value.on('crew:task:status', (data: any) => {
taskStatusMap.value.set(data.taskId, data);
});
socket.value.on('crew:log', (data: any) => {
logs.value.push(data);
// 限制日志条数,防止内存溢出
if (logs.value.length > 5000) logs.value.splice(0, 1000);
});
socket.value.on('crew:escalation', (data: any) => {
escalations.value.push(data);
});
}
/** 触发运行 */
function triggerRun(crewId: number, triggerInput?: string) {
socket.value?.emit('crew:trigger', { crewId, triggerInput });
}
/** 控制运行 */
function controlRun(runId: number, action: string, userMessage?: string) {
socket.value?.emit('crew:control', { runId, action, userMessage });
}
/** 获取某次运行的日志 */
function getRunLogs(runId: number, agentName?: string) {
return logs.value.filter(l => {
if (agentName) return l.agentName === agentName;
return true; // 返回所有日志
});
}
/** 获取某次运行的任务状态列表 */
function getRunTasks(runId: number) {
return [...taskStatusMap.value.values()].filter(t => t.runId === runId);
}
/** 清理日志 */
function clearLogs() {
logs.value = [];
}
/** 断开连接 */
function disconnect() {
socket.value?.disconnect();
socket.value = null;
}
onUnmounted(() => disconnect());
return {
socket, runStatusMap, taskStatusMap, logs, escalations,
connect, disconnect, triggerRun, controlRun,
getRunLogs, getRunTasks, clearLogs,
};
}
Step 5.2:实现运行记录表格
文件:packages/frontend/src/modules/agent/components/crew/crew-run-table.vue
<template>
<div class="crew-run-table">
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="filters.crewId" placeholder="集群" clearable>
<el-option v-for="c in crewList" :key="c.id" :label="c.label" :value="c.id" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" clearable>
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-date-picker
v-model="filters.timeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<el-button @click="loadData">刷新</el-button>
</div>
<!-- 表格 -->
<el-table :data="tableData" @row-click="onRowClick" highlight-current-row>
<el-table-column prop="crewId" label="集群" width="120">
<template #default="{ row }">
{{ getCrewLabel(row.crewId) }}
</template>
</el-table-column>
<el-table-column prop="triggerType" label="触发方式" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<span class="status-dot" :class="'status-' + row.status" />
{{ statusLabel(row.status) }}
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="180" />
<el-table-column label="耗时" width="100">
<template #default="{ row }">
{{ calcDuration(row.startTime, row.endTime) }}
</template>
</el-table-column>
<el-table-column label="进度" width="80">
<template #default="{ row }">
{{ row.progress || '-' }}
</template>
</el-table-column>
<el-table-column label="Token" width="120">
<template #default="{ row }">
{{ row.tokenUsage ? `${row.tokenUsage.inputTokens + row.tokenUsage.outputTokens}` : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button
v-if="row.status === 'running'"
size="small" type="warning" text
@click.stop="$emit('control', row.id, 'stop')"
>终止</el-button>
<el-button
v-if="row.status === 'paused'"
size="small" type="success" text
@click.stop="$emit('resume', row.id)"
>恢复</el-button>
<el-button
size="small" text
@click.stop="$emit('detail', row.id)"
>详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useCool } from '/@/cool';
const props = defineProps<{ crewList: any[] }>();
const emit = defineEmits<{
(e: 'detail', runId: number): void;
(e: 'control', runId: number, action: string): void;
(e: 'resume', runId: number): void;
(e: 'row-click', row: any): void;
}>();
const { service } = useCool();
const tableData = ref<any[]>([]);
const filters = reactive({ crewId: null as number | null, status: '', timeRange: null as string[] | null });
const statusOptions = [
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '运行中' },
{ value: 'paused', label: '已暂停' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '已失败' },
];
function statusLabel(s: string) {
return statusOptions.find(o => o.value === s)?.label || s;
}
function getCrewLabel(crewId: number) {
return props.crewList.find(c => c.id === crewId)?.label || crewId;
}
function calcDuration(start: string, end: string) {
if (!start) return '-';
const s = new Date(start).getTime();
const e = end ? new Date(end).getTime() : Date.now();
const sec = Math.round((e - s) / 1000);
return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${sec % 60}s`;
}
async function loadData() {
const params: any = {};
if (filters.crewId) params.crewId = filters.crewId;
if (filters.status) params.status = filters.status;
const res = await service.netaclaw.crewRun.page({ ...params, size: 50, page: 1 });
tableData.value = res?.list || [];
}
function onRowClick(row: any) {
emit('row-click', row);
}
/** 外部调用:实时更新某行状态 */
function updateRowStatus(runId: number, data: any) {
const row = tableData.value.find(r => r.id === runId);
if (row) Object.assign(row, data);
}
onMounted(() => loadData());
defineExpose({ loadData, updateRowStatus });
</script>
<style scoped>
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.status-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.status-running { background: #67c23a; }
.status-completed { background: #409eff; }
.status-paused { background: #e6a23c; }
.status-failed { background: #f56c6c; }
.status-pending { background: #c0c4cc; }
</style>
Step 5.3:实现任务时间线
文件:packages/frontend/src/modules/agent/components/crew/crew-timeline.vue
<template>
<div class="crew-timeline">
<h4>任务时间线</h4>
<el-timeline>
<el-timeline-item
v-for="task in sortedTasks"
:key="task.taskId"
:type="timelineType(task.status)"
:timestamp="task.startTime || task.timestamp"
placement="top"
>
<div class="task-item" @click="$emit('select-task', task)">
<span class="task-agent">{{ task.agentName }}</span>
<span class="task-status" :class="'status-' + task.status">
{{ statusIcon(task.status) }} {{ task.status }}
</span>
<p class="task-desc">{{ task.taskDescription || task.result?.agent }}</p>
<span v-if="task.duration" class="task-duration">{{ task.duration }}</span>
</div>
</el-timeline-item>
</el-timeline>
<div v-if="!sortedTasks.length" class="empty">暂无任务记录</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{ tasks: any[] }>();
defineEmits<{ (e: 'select-task', task: any): void }>();
const sortedTasks = computed(() =>
[...props.tasks].sort((a, b) => {
const ta = a.startTime || a.timestamp || '';
const tb = b.startTime || b.timestamp || '';
return ta.localeCompare(tb);
})
);
function timelineType(status: string) {
const map: Record<string, string> = {
running: 'primary', completed: 'success',
failed: 'danger', pending: 'info', retrying: 'warning',
};
return map[status] || 'info';
}
function statusIcon(status: string) {
const map: Record<string, string> = {
running: '⏳', completed: '✅', failed: '❌', pending: '⏸', retrying: '🔄',
};
return map[status] || '•';
}
</script>
<style scoped>
.crew-timeline { padding: 12px; overflow-y: auto; }
h4 { margin: 0 0 12px; font-size: 14px; }
.task-item { cursor: pointer; }
.task-agent { font-weight: 600; margin-right: 8px; }
.task-status { font-size: 12px; }
.task-desc { font-size: 12px; color: #606266; margin: 4px 0 0; }
.task-duration { font-size: 11px; color: #909399; }
.empty { text-align: center; color: #c0c4cc; padding: 40px; }
</style>
Step 5.4:实现日志面板
文件:packages/frontend/src/modules/agent/components/crew/crew-log-panel.vue
<template>
<div class="crew-log-panel">
<!-- Tab 切换:主 Agent / 各子 Agent -->
<el-tabs v-model="activeTab">
<el-tab-pane label="全部日志" name="all" />
<el-tab-pane
v-for="agent in agentNames"
:key="agent" :label="agent" :name="agent"
/>
</el-tabs>
<!-- 搜索过滤 -->
<el-input
v-model="searchText" placeholder="搜索日志..."
clearable size="small" style="margin-bottom: 8px"
/>
<!-- 日志流 -->
<div ref="logContainer" class="log-container">
<div
v-for="(log, i) in filteredLogs"
:key="i"
class="log-line"
:class="'level-' + log.level"
>
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<span class="log-agent">[{{ log.agentName }}]</span>
<span class="log-message">{{ log.message }}</span>
</div>
<div v-if="!filteredLogs.length" class="empty">等待日志...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
const props = defineProps<{ logs: any[] }>();
const activeTab = ref('all');
const searchText = ref('');
const logContainer = ref<HTMLElement>();
// 提取所有出现过的 Agent 名称
const agentNames = computed(() => {
const names = new Set<string>();
props.logs.forEach(l => { if (l.agentName) names.add(l.agentName); });
return [...names];
});
const filteredLogs = computed(() => {
let result = props.logs;
if (activeTab.value !== 'all') {
result = result.filter(l => l.agentName === activeTab.value);
}
if (searchText.value) {
const q = searchText.value.toLowerCase();
result = result.filter(l => l.message?.toLowerCase().includes(q));
}
return result;
});
function formatTime(ts: string) {
if (!ts) return '';
return new Date(ts).toLocaleTimeString();
}
// 自动滚动到底部
watch(() => props.logs.length, async () => {
await nextTick();
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight;
}
});
</script>
<style scoped>
.crew-log-panel { display: flex; flex-direction: column; height: 100%; }
.log-container {
flex: 1; overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px; line-height: 1.6;
background: #1e1e1e; color: #d4d4d4;
padding: 8px 12px; border-radius: 6px;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-time { color: #6a9955; margin-right: 8px; }
.log-agent { color: #569cd6; margin-right: 8px; }
.level-error .log-message { color: #f44747; }
.level-tool .log-message { color: #dcdcaa; }
.empty { text-align: center; color: #666; padding: 40px; }
</style>
Step 5.5:实现运行详情容器
文件:packages/frontend/src/modules/agent/components/crew/crew-run-detail.vue
<template>
<div class="crew-run-detail">
<!-- 三栏布局 -->
<div class="detail-columns">
<!-- 左:实时画布 -->
<div class="col-canvas">
<VueFlow
:nodes="canvasNodes"
:edges="canvasEdges"
:nodes-draggable="false"
:nodes-connectable="false"
:edges-updatable="false"
fit-view-on-init
:class-func="edgeClassFunc"
>
<template #node-crew-agent="{ data }">
<crew-agent-node :data="data" :live-status="getNodeStatus(data.agentId)" />
</template>
<MiniMap />
</VueFlow>
</div>
<!-- 中:任务时间线 -->
<div class="col-timeline">
<crew-timeline
:tasks="tasks"
@select-task="onSelectTask"
/>
</div>
<!-- 右:日志面板 -->
<div class="col-logs">
<crew-log-panel :logs="logs" />
</div>
</div>
<!-- 升级处理对话框 -->
<el-dialog v-model="showEscalation" title="需要人工处理" width="500px">
<p>{{ currentEscalation?.reason }}</p>
<el-input
v-model="resumeMessage"
type="textarea" :rows="4"
placeholder="请输入处理意见..."
/>
<template #footer>
<el-button @click="showEscalation = false">取消</el-button>
<el-button type="primary" @click="onResume">恢复执行</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/minimap/dist/style.css';
import CrewAgentNode from './crew-agent-node.vue';
import CrewTimeline from './crew-timeline.vue';
import CrewLogPanel from './crew-log-panel.vue';
const props = defineProps<{
run: any;
canvasData: any;
tasks: any[];
logs: any[];
escalations: any[];
}>();
const emit = defineEmits<{
(e: 'resume', runId: number, message: string): void;
}>();
// 画布数据(只读模式)
const canvasNodes = computed(() => props.canvasData?.nodes || []);
const canvasEdges = computed(() => props.canvasData?.edges || []);
// 节点实时状态映射
function getNodeStatus(agentId: number): string {
const task = props.tasks.find(t => t.agentId === agentId);
return task?.status || 'idle';
}
// 活跃连线动画:当 source 节点正在运行时,连线显示流动虚线
function edgeClassFunc(edge: any) {
const sourceNode = canvasNodes.value.find((n: any) => n.id === edge.source);
if (sourceNode?.data?.agentId) {
const status = getNodeStatus(sourceNode.data.agentId);
if (status === 'running') return 'is-active';
}
return '';
}
// 升级处理
const showEscalation = ref(false);
const currentEscalation = ref<any>(null);
const resumeMessage = ref('');
watch(() => props.escalations.length, () => {
const latest = props.escalations[props.escalations.length - 1];
if (latest && latest.runId === props.run?.id) {
currentEscalation.value = latest;
showEscalation.value = true;
}
});
function onResume() {
if (!props.run?.id) return;
emit('resume', props.run.id, resumeMessage.value);
showEscalation.value = false;
resumeMessage.value = '';
}
function onSelectTask(task: any) {
// 可以滚动日志到对应位置
}
</script>
<style scoped>
.crew-run-detail { height: 100%; }
.detail-columns {
display: flex; height: 100%; gap: 1px;
background: #eee;
}
.col-canvas { flex: 2; background: white; min-height: 300px; }
.col-timeline { flex: 1; background: white; overflow-y: auto; }
.col-logs { flex: 1.5; background: white; }
/* 活跃连线流动虚线动画 */
:deep(.vue-flow__edge.is-active path) {
stroke-dasharray: 8 4;
animation: flow-dash 0.6s linear infinite;
}
@keyframes flow-dash {
to { stroke-dashoffset: -12; }
}
</style>
Step 5.6:实现监控页主视图
文件:packages/frontend/src/modules/agent/views/crew-monitor.vue
<template>
<div class="crew-monitor">
<!-- 上半部:运行列表 -->
<div class="run-list-section">
<crew-run-table
ref="runTableRef"
:crew-list="crewStore.crewList"
@row-click="onRowClick"
@control="onControl"
@resume="onResumeClick"
@detail="onDetailClick"
/>
</div>
<!-- 下半部:运行详情 -->
<div v-if="selectedRun" class="run-detail-section">
<crew-run-detail
:run="selectedRun"
:canvas-data="selectedCanvasData"
:tasks="monitorHook.getRunTasks(selectedRun.id)"
:logs="monitorHook.logs.value"
:escalations="monitorHook.escalations.value"
@resume="onResume"
/>
</div>
<div v-else class="empty-detail">
<p>点击运行记录查看详情</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useCrewStore } from '../store/crew';
import { useCrewMonitor } from '../hooks/crew-monitor';
import CrewRunTable from '../components/crew/crew-run-table.vue';
import CrewRunDetail from '../components/crew/crew-run-detail.vue';
const route = useRoute();
const crewStore = useCrewStore();
const monitorHook = useCrewMonitor();
const runTableRef = ref<InstanceType<typeof CrewRunTable>>();
const selectedRun = ref<any>(null);
const selectedCanvasData = ref<any>(null);
onMounted(async () => {
await crewStore.loadCrewList();
monitorHook.connect();
// 监听实时状态更新
monitorHook.runStatusMap.value.forEach((data, runId) => {
runTableRef.value?.updateRowStatus(runId, data);
});
// 如果从编排页跳转过来,自动触发运行
const autoRunCrewId = route.query.crewId;
if (route.query.autoRun === '1' && autoRunCrewId) {
monitorHook.triggerRun(Number(autoRunCrewId));
}
});
async function onRowClick(row: any) {
selectedRun.value = row;
// 加载该集群的画布数据
const detail = await crewStore.loadCrewDetail(row.crewId);
selectedCanvasData.value = detail?.canvasData;
monitorHook.clearLogs();
}
function onDetailClick(runId: number) {
// 同 onRowClick,通过 runId 查找行数据
}
function onControl(runId: number, action: string) {
monitorHook.controlRun(runId, action);
}
function onResumeClick(runId: number) {
// 打开恢复对话框(由 crew-run-detail 内部处理)
selectedRun.value = { ...selectedRun.value, id: runId };
}
function onResume(runId: number, message: string) {
monitorHook.controlRun(runId, 'resume', message);
}
</script>
<style scoped>
.crew-monitor {
display: flex; flex-direction: column; height: 100%;
}
.run-list-section {
padding: 16px;
border-bottom: 1px solid #eee;
max-height: 40%;
overflow-y: auto;
}
.run-detail-section { flex: 1; overflow: hidden; }
.empty-detail {
flex: 1; display: flex; align-items: center;
justify-content: center; color: #c0c4cc;
}
</style>
Step 5.7:验证监控页
验证清单:
- 路由
/agent/crew-monitor正常访问 - 运行记录表格正确加载,筛选功能正常
- 状态颜色圆点正确显示
- WebSocket 连接
/crew命名空间成功 - 触发运行后,表格实时更新状态
- 点击行展开详情,三栏布局正确渲染
- 实时画布:节点状态随任务进度变化(idle→running→completed/failed)
- 任务时间线:按时间排序,状态图标正确
- 日志面板:实时滚动,Tab 切换过滤,搜索功能正常
- 升级处理:escalate 触发后弹出对话框,输入意见后恢复执行
- 终止运行功能正常
Step 5.8:提交代码
git add packages/frontend/src/modules/agent/hooks/crew-monitor.ts
git add packages/frontend/src/modules/agent/views/crew-monitor.vue
git add packages/frontend/src/modules/agent/components/crew/crew-run-table.vue
git add packages/frontend/src/modules/agent/components/crew/crew-run-detail.vue
git add packages/frontend/src/modules/agent/components/crew/crew-timeline.vue
git add packages/frontend/src/modules/agent/components/crew/crew-log-panel.vue
git commit -m "feat(crew): 实现监控页 - 运行列表 + 实时画布 + 时间线 + 日志面板"
Phase 5 验收
- WebSocket 监控 Hook 完成
- 运行记录表格完成(含实时更新)
- 任务时间线完成
- 日志面板完成(Tab 切换 + 搜索 + 自动滚动)
- 运行详情三栏布局完成(画布 + 时间线 + 日志)
- 升级处理对话框完成
- 监控页主视图完成
- 端到端实时监控验证通过
- 代码已提交
Phase 6:权限集成 + 端到端测试
目标:配置菜单权限、数据权限,并完成端到端全流程测试 预估:1 天 依赖:Phase 5 完成
Step 6.1:配置菜单权限
通过数据库或 Cool Admin 管理后台插入菜单和权限记录:
-- 1. 查询 Agent 管理父菜单 ID
SELECT id FROM base_sys_menu WHERE router = '/agent' OR name = 'Agent 管理';
-- 2. 插入 Agent 编排页菜单
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms, isShow)
VALUES ('Agent 编排', '/agent/crew-editor', @parentId, 5, 1, 'icon-crew', NULL, 1);
-- 3. 插入运行监控页菜单
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms, isShow)
VALUES ('运行监控', '/agent/crew-monitor', @parentId, 6, 1, 'icon-monitor', NULL, 1);
-- 4. 插入操作权限按钮(挂在编排页菜单下)
SET @crewMenuId = LAST_INSERT_ID(); -- 需要调整
INSERT INTO base_sys_menu (name, parentId, type, perms) VALUES
('创建集群', @crewMenuId, 2, 'agent:crew:create'),
('编辑集群', @crewMenuId, 2, 'agent:crew:edit'),
('发布集群', @crewMenuId, 2, 'agent:crew:publish'),
('触发运行', @crewMenuId, 2, 'agent:crew:trigger'),
('控制运行', @crewMenuId, 2, 'agent:crew:control'),
('查看监控', @crewMenuId, 2, 'agent:crew:view');
实际操作时建议通过 Cool Admin 的菜单管理页面 UI 操作,更安全。
Step 6.2:数据权限(租户隔离)
netaclaw_crew 继承 BaseEntity 已自带 tenantId 字段。Cool Admin 的 @CoolController 自动在查询时注入租户过滤条件,无需额外代码。
验证点:
- 不同租户创建的集群互不可见
- 管理员可查看所有集群
Step 6.3:Controller 权限装饰
如需更细粒度的权限控制,在 Controller 方法上添加装饰器:
// 示例:crew_trigger.ts 中的 start 方法
@Post('/start')
// Cool Admin 权限检查由框架自动处理,基于菜单 perms 配置
async start(@Body() body: { crewId: number; triggerInput?: string }) {
// ...
}
Cool Admin 的权限机制是:前端请求携带 token → 后端中间件解析用户角色 → 校验用户是否有该路由对应的菜单权限。菜单权限在 Step 6.1 已配置。
Step 6.4:端到端全流程测试
测试场景 1:基本编排 + 运行
前置条件:已有 3 个已发布 Agent(如:登录Agent、数据获取Agent、上架Agent)
1. 进入编排页 → 新建集群"淘宝运营"
2. 从左侧拖入 3 个 Agent
3. 设置"登录Agent"为主 Agent
4. 连线:登录Agent → 数据获取Agent → 上架Agent(串行)
5. 给每个 Agent 配置角色描述
6. 保存 → 发布
7. 点击"试运行" → 跳转监控页
8. 观察:
- 运行列表新增一行(status=running)
- 实时画布节点状态变化
- 时间线记录各任务执行
- 日志面板实时输出
9. 运行完成 → status=completed
10. 检查数据库:crew_run / crew_task 记录正确
测试场景 2:并行委派
1. 编排:A(主) → B(串行), A → C(并行), A → D(并行)
2. 触发运行
3. 验证:B 先执行完,然后 C 和 D 并行执行
4. 检查 crew_task 表:C 和 D 的 startTime 接近
测试场景 3:错误处理 + 升级
1. 编排包含一个"必然失败"的 Agent(如 API Key 无效)
2. 触发运行
3. 验证:主 Agent 收到错误,尝试处理
4. 如果主 Agent 判断无法处理 → 调用 escalate
5. 监控页弹出升级对话框
6. 输入处理意见 → 点击恢复
7. 验证主 Agent 继续执行
测试场景 4:定时调度
1. 编辑集群触发配置:启用 cron,表达式 = "*/2 * * * *"(每2分钟)
2. 发布集群
3. 等待 2 分钟,验证自动触发运行
4. 取消发布 → 验证定时任务停止
Step 6.5:性能与稳定性检查
- 并发安全:同时触发 3 个集群运行,无死锁或数据混乱
- WebSocket 断线重连:刷新页面后重新连接
/crew,状态恢复 - 超时控制:子 Agent 执行超过 taskTimeout 后正确标记 failed
- 内存泄漏:长时间运行后检查 Node.js 内存使用
- 日志量:大量日志时前端不卡顿(已设置 5000 条上限)
Step 6.6:提交代码
git add -A
git commit -m "feat(crew): 完成权限集成 + 端到端测试验证"
Phase 6 验收
- 菜单权限配置完成
- 租户数据隔离验证通过
- 端到端测试场景 1-4 全部通过
- 性能与稳定性检查通过
- 代码已提交
风险应对与降级方案
风险 1:Vue Flow 兼容问题
| 级别 | 场景 | 应对 |
|---|---|---|
| 🟢 低 | 样式冲突 | 调整 CSS 隔离,使用 scoped + deep 选择器 |
| 🟡 中 | 自定义节点渲染异常 | 检查 Vue Flow 版本 API 变更,参考官方 examples |
| 🔴 高 | Vue Flow 与项目 Vue 版本不兼容 | 降级方案:使用纯 SVG + CSS 实现简化版画布(节点拖拽用 interact.js,连线用 SVG <path>) |
预防措施:Phase 0 的技术验证(Step 0.4)可在正式开发前发现此类问题。
风险 2:主 Agent 编排质量差
| 场景 | 应对 |
|---|---|
| 主 Agent 不遵循 delegateHints | 强化 system prompt 中的指令措辞,增加 few-shot 示例 |
| 主 Agent 无限循环委派 | maxToolRounds = 50 硬性限制 + 检测连续失败次数 |
| 主 Agent 忽略错误 | 在 tool_result 中显式标记 status: "failed",提示需要处理 |
风险 3:长时间运行稳定性
| 场景 | 应对 |
|---|---|
| 服务重启丢失运行状态 | pausedState 持久化对话上下文,启动时恢复 |
| 子 Agent 执行卡死 | Promise.race 超时控制,默认 300s |
| WebSocket 断线 | 前端 Socket.IO 自动重连 + 重新拉取当前状态 |
风险 4:并发 Token 消耗过高
| 场景 | 应对 |
|---|---|
| 并行执行大量子 Agent | maxConcurrent 限制(默认 3),分批执行 |
| Token 成本超预期 | 前端展示累计 token 消耗,集群配置 token 预算告警(后续迭代) |
验收标准
功能验收
| # | 功能点 | 验收标准 |
|---|---|---|
| 1 | 集群 CRUD | 创建/编辑/删除集群,数据持久化正确 |
| 2 | 画布编排 | 拖拽添加 Agent、自由连线、设置主 Agent、保存/加载画布 |
| 3 | 角色配置 | 每个 Agent 可配置角色描述和分组 |
| 4 | 连线提示 | 连线自动生成 delegateHints,注入主 Agent system prompt |
| 5 | 发布控制 | 发布校验(需主 Agent + ≥2 成员),取消发布 |
| 6 | 手动触发 | 通过 REST API 和 WebSocket 触发运行 |
| 7 | 定时调度 | Cron 表达式配置,发布时注册,取消发布时销毁 |
| 8 | 串行委派 | 主 Agent 通过 delegate_task 串行执行子 Agent |
| 9 | 并行委派 | 主 Agent 通过 delegate_parallel 并行执行子 Agent |
| 10 | 错误处理 | 子 Agent 失败时,主 Agent 收到错误信息并自主决策 |
| 11 | 升级人工 | escalate 暂停运行 → 用户处理 → 恢复执行 |
| 12 | 运行列表 | 分页查询、状态筛选、实时更新 |
| 13 | 实时画布 | 节点状态随任务进度实时变化(呼吸动画/颜色) |
| 14 | 任务时间线 | 按时间排序,并行任务并排显示 |
| 15 | 日志面板 | 实时流式日志,Agent 维度 Tab 切换,搜索过滤 |
| 16 | Token 统计 | 每个任务和整次运行的 token 消耗正确记录 |
| 17 | 权限控制 | 菜单权限 + 租户数据隔离 |
非功能验收
| # | 指标 | 标准 |
|---|---|---|
| 1 | 零 runtime 改动 | runtime/agent.ts 和 runtime/attempt.ts 无修改 |
| 2 | 现有功能不受影响 | Agent 对话、Skill 管理等原有功能正常 |
| 3 | 代码规范 | Entity 继承 BaseEntity、Controller 使用 @CoolController、文件下划线命名 |
| 4 | 并发安全 | 3 个集群同时运行无异常 |
| 5 | 超时控制 | 子 Agent 超时后正确标记 failed |
交付物清单
| 类别 | 数量 | 文件 |
|---|---|---|
| 后端 Entity | 4 新增 + 1 修改 | crew.ts, crew_agent.ts, crew_run.ts, crew_task.ts, agent.ts |
| 后端 Service | 5 新增 | crew_types.ts, crew.ts, crew_orchestrator.ts, crew_delegate.ts, crew_scheduler.ts |
| 后端 Tool | 3 新增 | delegate_task.ts, delegate_parallel.ts, escalate.ts |
| 后端 Controller | 3 新增 | crew.ts, crew_run.ts, crew_trigger.ts |
| 后端 Gateway | 1 新增 | crew_server.ts |
| 前端 View | 2 新增 | crew-editor.vue, crew-monitor.vue |
| 前端 Component | 11 新增 | crew-agent-node.vue, crew-edge-label.vue, crew-sidebar.vue, crew-property-panel.vue, crew-trigger-config.vue, crew-context-menu.vue, crew-run-table.vue, crew-run-detail.vue, crew-timeline.vue, crew-log-panel.vue, crew-canvas.vue(可选抽取) |
| 前端 Hook | 3 新增 | crew-canvas.ts, crew-orchestration.ts, crew-monitor.ts |
| 前端 Store | 1 新增 | crew.ts |
| 合计 | 34 个文件 | 31 新增 + 3 修改 |
时间线总览
Phase 0 准备工作 ██ 0.5 天
Phase 1 后端数据层 ████ 1 天
Phase 2 后端核心引擎 ██████████ 2.5 天
Phase 3 后端通信层 ████████ 2 天
Phase 4 前端编排页 ████████████ 3 天
Phase 5 前端监控页 ██████████ 2.5 天
Phase 6 权限+测试 ████ 1 天
────────
总计 12.5 天
说明:Phase 4 预留了额外缓冲(Vue Flow 首次使用风险)。如果 Phase 0 验证顺利,Phase 4 可缩短至 2 天。