GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-14-multi-agent-crew-orchestration-impl-plan.md
2026-05-20 21:39:12 +08:00

4056 lines
125 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-0准备工作)
- [Phase 1后端数据层Entity + Migration](#phase-1后端数据层entity--migration)
- [Phase 2后端核心引擎编排器 + 委派工具)](#phase-2后端核心引擎编排器--委派工具)
- [Phase 3后端通信层WebSocket + 触发 + 定时)](#phase-3后端通信层websocket--触发--定时)
- [Phase 4前端编排页](#phase-4前端编排页)
- [Phase 5前端监控页](#phase-5前端监控页)
- [Phase 6权限集成 + 端到端测试](#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:确认前端依赖可用
项目已安装但未实际使用的关键依赖,需验证版本兼容:
```bash
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 ✓
**如果缺失**
```bash
pnpm add @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background elkjs
```
### Step 0.2:确认后端依赖
```bash
cd packages/backend
# cron 包(定时调度用)
pnpm list cron
```
**如果缺失**
```bash
pnpm add cron
pnpm add -D @types/cron
```
### Step 0.3Vue Flow 技术验证(⚠️ 关键风险点)
> 这是本项目首次实际使用 Vue Flow。在正式开发前先用最小 demo 验证核心功能。
创建临时验证文件 `packages/frontend/src/modules/agent/views/__test-flow.vue`
```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:创建后端模块骨架目录
```bash
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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` 字段后新增一个字段
```typescript
// 在 status 字段之后添加:
@Column({ comment: '是否可作为集群主 Agent', default: 0 })
isCrewMaster: number; // 0=否 1=是UI 筛选用)
```
> 这是唯一一处修改现有文件的地方,风险极低(仅新增列,不改现有字段)。
### Step 1.6:注册 Entity 到模块配置
**文件**`packages/backend/src/modules/netaclaw/config.ts`
在现有 Entity 导入列表中追加:
```typescript
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:验证建表
```bash
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:提交代码
```bash
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`
> 先定义所有跨模块共享的类型,避免循环引用。
```typescript
/**
* 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()`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
**这是整个系统最核心的文件**,负责:
1. 加载集群配置
2. 构建主 Agent 增强 system prompt
3. 注入委派工具
4. 启动主 Agent ReAct 循环
5. 管理运行生命周期
```typescript
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`
```typescript
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 和 WebSocketPhase 3但可通过单元测试或临时脚本验证
**验证方式**:编写一个临时测试服务,在后端启动后通过 API 手动调用
```typescript
// 临时测试(可在 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:提交代码
```bash
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`
```typescript
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`
```typescript
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`
```typescript
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:实现 CrewGatewayWebSocket 网关)
**文件**`packages/backend/src/modules/netaclaw/gateway/crew_server.ts`
> 独立 `/crew` 命名空间,与现有 `/netaclaw` 隔离。
```typescript
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`
```typescript
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()` 方法中注入调度器:
```typescript
// 在 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 测试集群的创建、查询、更新、删除
- [ ] **画布保存**`saveCanvas` API 正确保存 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:提交代码
```bash
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 数组中追加:
```typescript
{
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 菜单管理):
```sql
-- 在 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`
```typescript
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`
```typescript
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`
```typescript
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`
```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`
```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`
```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`
```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`
```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`
```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`
```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` 方法:
```typescript
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` 事件实现框选:
```typescript
// 在 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:提交代码
```bash
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`
```typescript
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`
```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`
```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`
```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`
```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`
```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:提交代码
```bash
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 管理后台插入菜单和权限记录:
```sql
-- 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.3Controller 权限装饰
如需更细粒度的权限控制,在 Controller 方法上添加装饰器:
```typescript
// 示例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:提交代码
```bash
git add -A
git commit -m "feat(crew): 完成权限集成 + 端到端测试验证"
```
### Phase 6 验收
- [ ] 菜单权限配置完成
- [ ] 租户数据隔离验证通过
- [ ] 端到端测试场景 1-4 全部通过
- [ ] 性能与稳定性检查通过
- [ ] 代码已提交
---
## 风险应对与降级方案
### 风险 1Vue 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 天。