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

125 KiB
Raw Permalink Blame History

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 完成后都有可验证的产出,允许中途停下来评审。

Phase 0 ─ 准备(依赖验证、技术验证、代码骨架)
  │
Phase 1 ─ 后端数据层4 个 Entity + agent 扩展字段)
  │
Phase 2 ─ 后端核心引擎CrewOrchestrator + CrewDelegate + 3 个工具)
  │
Phase 3 ─ 后端通信层CrewGateway + CrewTrigger + CrewScheduler
  │
Phase 4 ─ 前端编排页(画布 + 侧栏 + 属性面板)
  │
Phase 5 ─ 前端监控页(运行列表 + 实时画布 + 时间线 + 日志)
  │
Phase 6 ─ 权限集成 + 端到端测试

关键约定

约定 说明
Entity 继承 全部继承 BaseEntity(自带 id/createTime/updateTime/tenantId
Controller 风格 使用 @CoolController 自动 CRUD复杂接口手写
文件命名 下划线法(如 crew_agent.ts
字段命名 驼峰法(如 masterAgentId
WebSocket 独立 /crew 命名空间,不侵入现有 /netaclaw
子 Agent 执行 直接调用 runAgent() 纯函数,零 runtime 改动
注释语言 中文

文件影响矩阵

packages/backend/src/modules/netaclaw/
├── entity/                          ← Phase 1: 新增 4 个文件
│   ├── crew.ts                      [新增]
│   ├── crew_agent.ts                [新增]
│   ├── crew_run.ts                  [新增]
│   ├── crew_task.ts                 [新增]
│   └── agent.ts                     [修改: +isCrewMaster 字段]
├── controller/
│   └── admin/
│       ├── crew.ts                  [新增] Phase 3
│       ├── crew_run.ts              [新增] Phase 3
│       └── crew_trigger.ts          [新增] Phase 3
├── gateway/
│   └── crew_server.ts               [新增] Phase 3
├── service/
│   ├── crew.ts                      [新增] Phase 2
│   ├── crew_orchestrator.ts         [新增] Phase 2
│   ├── crew_delegate.ts             [新增] Phase 2
│   └── crew_scheduler.ts            [新增] Phase 3
├── tools/
│   ├── builtin/
│   │   ├── delegate_task.ts         [新增] Phase 2
│   │   ├── delegate_parallel.ts     [新增] Phase 2
│   │   └── escalate.ts              [新增] Phase 2
└── config.ts                        [修改: 注册新 Entity] Phase 1

packages/frontend/src/modules/agent/
├── config.ts                        [修改: +2 路由] Phase 4
├── views/
│   ├── crew-editor.vue              [新增] Phase 4
│   └── crew-monitor.vue             [新增] Phase 5
├── components/crew/
│   ├── crew-canvas.vue              [新增] Phase 4
│   ├── crew-agent-node.vue          [新增] Phase 4
│   ├── crew-edge-label.vue          [新增] Phase 4
│   ├── crew-sidebar.vue             [新增] Phase 4
│   ├── crew-property-panel.vue      [新增] Phase 4
│   ├── crew-trigger-config.vue      [新增] Phase 4
│   ├── crew-context-menu.vue        [新增] Phase 4
│   ├── crew-run-table.vue           [新增] Phase 5
│   ├── crew-run-detail.vue          [新增] Phase 5
│   ├── crew-timeline.vue            [新增] Phase 5
│   └── crew-log-panel.vue           [新增] Phase 5
├── hooks/
│   ├── crew-canvas.ts               [新增] Phase 4
│   ├── crew-orchestration.ts        [新增] Phase 4
│   └── crew-monitor.ts              [新增] Phase 5
└── store/
    └── crew.ts                      [新增] Phase 4

Phase 0准备工作

目标:搭建开发环境,确认依赖可用,创建代码骨架 预估0.5 天

Step 0.1:确认前端依赖可用

项目已安装但未实际使用的关键依赖,需验证版本兼容:

cd packages/frontend
# 检查 @vue-flow 系列
pnpm list @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background
# 检查 elkjs自动布局
pnpm list elkjs

预期结果

  • @vue-flow/core ≥ 1.42.1 ✓
  • @vue-flow/minimap@vue-flow/controls@vue-flow/background 已安装 ✓
  • elkjs ≥ 0.9.3 ✓

如果缺失

pnpm add @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background elkjs

Step 0.2:确认后端依赖

cd packages/backend
# cron 包(定时调度用)
pnpm list cron

如果缺失

pnpm add cron
pnpm add -D @types/cron

Step 0.3Vue Flow 技术验证(⚠️ 关键风险点)

这是本项目首次实际使用 Vue Flow。在正式开发前先用最小 demo 验证核心功能。

创建临时验证文件 packages/frontend/src/modules/agent/views/__test-flow.vue

<template>
  <div style="width: 100%; height: 600px">
    <VueFlow :nodes="nodes" :edges="edges" @node-drag-stop="onNodeDragStop">
      <MiniMap />
      <Controls />
      <Background />
    </VueFlow>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { Controls } from '@vue-flow/controls';
import { Background } from '@vue-flow/background';

const nodes = ref([
  { id: '1', position: { x: 100, y: 100 }, data: { label: '主 Agent' } },
  { id: '2', position: { x: 300, y: 200 }, data: { label: '子 Agent' } },
]);
const edges = ref([
  { id: 'e1-2', source: '1', target: '2' },
]);
function onNodeDragStop(event: any) {
  console.log('节点拖拽完成', event);
}
</script>

验证清单

  • 画布正常渲染,节点可见
  • 节点可自由拖拽,连线跟随
  • MiniMap、Controls、Background 正常显示
  • 自定义节点slot 方式)可渲染
  • 连线可通过 Handle 创建

通过后删除测试文件,正式进入 Phase 1。

Step 0.4:创建后端模块骨架目录

cd packages/backend/src/modules/netaclaw
# 确认以下目录存在
ls entity/
ls controller/admin/
ls service/
ls gateway/
ls tools/builtin/

Phase 0 验收

  • 前端 Vue Flow 依赖可用且 demo 验证通过
  • 后端 cron 依赖可用
  • 目录结构就绪

Phase 1后端数据层Entity + Migration

目标:建立 4 个新表 + 扩展 agent 表TypeORM synchronize 自动建表 预估1 天 依赖Phase 0 完成

Step 1.1:创建 crew.ts Entity

文件packages/backend/src/modules/netaclaw/entity/crew.ts

import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';

/**
 * Agent 集群Crew
 */
@Entity('netaclaw_crew')
export class NetaClawCrewEntity extends BaseEntity {
  @Column({ comment: '集群唯一标识', length: 100, unique: true })
  name: string;

  @Column({ comment: '显示名称', length: 200 })
  label: string;

  @Column({ comment: '集群描述', type: 'text', nullable: true })
  description: string;

  @Column({ comment: '图标', length: 500, nullable: true })
  icon: string;

  @Index()
  @Column({ comment: '主 Agent ID', nullable: true })
  masterAgentId: number;

  @Column({ type: 'json', comment: '画布布局数据', nullable: true })
  canvasData: Record<string, unknown>;

  @Column({ type: 'json', comment: '触发配置', nullable: true })
  triggerConfig: {
    manual?: boolean;
    cron?: { enabled: boolean; expression: string; timezone: string };
    webhook?: { enabled: boolean; secret: string };
    api?: { enabled: boolean; apiKey: string };
  };

  @Column({ type: 'json', comment: '委派提示(从连线生成)', nullable: true })
  delegateHints: {
    hints: string;
    edges: Array<{ from: string; to: string; type: string; note?: string }>;
  };

  @Index()
  @Column({ comment: '状态: 0=草稿 1=已发布', default: 0 })
  status: number;

  @Column({ comment: '最大并发子 Agent 数', default: 3 })
  maxConcurrent: number;

  @Column({ comment: '单个子任务默认超时(秒)', default: 300 })
  taskTimeout: number;

  @Column({ type: 'json', comment: '全局重试策略', nullable: true })
  retryPolicy: { maxRetries: number; retryDelay: number };
}

设计要点

  • triggerConfig / delegateHints / retryPolicy 使用 JSON 列,灵活扩展
  • masterAgentId 不使用外键约束(遵循项目规范),通过应用层关联
  • status 索引优化发布状态查询

Step 1.2:创建 crew_agent.ts Entity

文件packages/backend/src/modules/netaclaw/entity/crew_agent.ts

import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';

/**
 * 集群-Agent 关联(成员关系权威来源)
 */
@Entity('netaclaw_crew_agent')
export class NetaClawCrewAgentEntity extends BaseEntity {
  @Index()
  @Column({ comment: '集群 ID' })
  crewId: number;

  @Index()
  @Column({ comment: 'Agent ID' })
  agentId: number;

  @Column({ comment: '该 Agent 在集群中的角色描述', type: 'text', nullable: true })
  role: string;

  @Column({ type: 'json', comment: '画布位置', nullable: true })
  canvasPosition: { x: number; y: number };

  @Column({ comment: '分组名', length: 100, nullable: true })
  groupName: string;
}

设计要点

  • crewId + agentId 双索引,支持高频关联查询
  • canvasPosition 用 JSON 存画布坐标,前端序列化/反序列化
  • 此表是成员关系的权威来源canvasData 仅存渲染信息

Step 1.3:创建 crew_run.ts Entity

文件packages/backend/src/modules/netaclaw/entity/crew_run.ts

import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';

/**
 * 集群运行记录
 */
@Entity('netaclaw_crew_run')
export class NetaClawCrewRunEntity extends BaseEntity {
  @Index()
  @Column({ comment: '集群 ID' })
  crewId: number;

  @Column({ comment: '触发方式', length: 20 })
  triggerType: string;  // manual / cron / webhook / api

  @Column({ comment: '触发输入参数', type: 'text', nullable: true })
  triggerInput: string;

  @Index()
  @Column({ comment: '运行状态', length: 20, default: 'pending' })
  status: string;  // pending / running / paused / completed / failed

  @Column({ comment: '主 Agent 会话 ID', nullable: true })
  masterSessionId: number;

  @Column({ comment: '开始时间', type: 'datetime', nullable: true })
  startTime: Date;

  @Column({ comment: '结束时间', type: 'datetime', nullable: true })
  endTime: Date;

  @Column({ type: 'json', comment: '运行结果摘要', nullable: true })
  result: Record<string, unknown>;

  @Column({ comment: '错误信息', type: 'text', nullable: true })
  error: string;

  @Column({ type: 'json', comment: '升级暂停时保存的对话上下文', nullable: true })
  pausedState: unknown;

  @Column({ type: 'json', comment: '累计 token 消耗', nullable: true })
  tokenUsage: { inputTokens: number; outputTokens: number };
}

Step 1.4:创建 crew_task.ts Entity

文件packages/backend/src/modules/netaclaw/entity/crew_task.ts

import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';

/**
 * 子 Agent 任务记录
 */
@Entity('netaclaw_crew_task')
export class NetaClawCrewTaskEntity extends BaseEntity {
  @Index()
  @Column({ comment: '运行记录 ID' })
  runId: number;

  @Index()
  @Column({ comment: '子 Agent ID' })
  agentId: number;

  @Column({ comment: '任务描述', type: 'text' })
  taskDescription: string;

  @Index()
  @Column({ comment: '任务状态', length: 20, default: 'pending' })
  status: string;  // pending / running / completed / failed / retrying

  @Column({ comment: '子 Agent 会话 ID', nullable: true })
  sessionId: number;

  @Column({ comment: '开始时间', type: 'datetime', nullable: true })
  startTime: Date;

  @Column({ comment: '结束时间', type: 'datetime', nullable: true })
  endTime: Date;

  @Column({ type: 'json', comment: '执行结果', nullable: true })
  result: Record<string, unknown>;

  @Column({ comment: '错误信息', type: 'text', nullable: true })
  error: string;

  @Column({ comment: '已重试次数', default: 0 })
  retryCount: number;

  @Column({ comment: '该任务超时(秒),空则使用集群默认', nullable: true })
  timeout: number;

  @Column({ type: 'json', comment: '该任务 token 消耗', nullable: true })
  tokenUsage: { inputTokens: number; outputTokens: number };

  @Column({ comment: '父任务 ID支持嵌套委派', nullable: true })
  parentTaskId: number;
}

Step 1.5:扩展 agent.ts Entity

文件packages/backend/src/modules/netaclaw/entity/agent.ts 操作:在现有 status 字段后新增一个字段

// 在 status 字段之后添加:
@Column({ comment: '是否可作为集群主 Agent', default: 0 })
isCrewMaster: number;  // 0=否 1=是UI 筛选用)

这是唯一一处修改现有文件的地方,风险极低(仅新增列,不改现有字段)。

Step 1.6:注册 Entity 到模块配置

文件packages/backend/src/modules/netaclaw/config.ts

在现有 Entity 导入列表中追加:

import { NetaClawCrewEntity } from './entity/crew.js';
import { NetaClawCrewAgentEntity } from './entity/crew_agent.js';
import { NetaClawCrewRunEntity } from './entity/crew_run.js';
import { NetaClawCrewTaskEntity } from './entity/crew_task.js';

// 在 typeorm.entity 数组中追加:
NetaClawCrewEntity,
NetaClawCrewAgentEntity,
NetaClawCrewRunEntity,
NetaClawCrewTaskEntity,

Step 1.7:验证建表

cd packages/backend
pnpm dev  # 启动开发服务器TypeORM synchronize=true 自动建表

验证清单

  • 服务启动无报错
  • MySQL 中新增 4 张表:netaclaw_crewnetaclaw_crew_agentnetaclaw_crew_runnetaclaw_crew_task
  • netaclaw_agent 表新增 isCrewMaster 字段
  • 所有索引正确创建
  • JSON 列可正常读写(手动插入测试数据验证)

Step 1.8:提交代码

git add packages/backend/src/modules/netaclaw/entity/crew*.ts
git add packages/backend/src/modules/netaclaw/entity/agent.ts
git add packages/backend/src/modules/netaclaw/config.ts
git commit -m "feat(crew): 新增 Crew 编排数据模型 - 4 个 Entity + agent 扩展字段"

Phase 1 验收

  • 4 个新 Entity 文件创建完成
  • agent.ts 新增 isCrewMaster 字段
  • config.ts 注册新 Entity
  • 自动建表成功,字段/索引/JSON 列均正确
  • 代码已提交

Phase 2后端核心引擎编排器 + 委派工具)

目标:实现 CrewOrchestrator 编排调度器 + CrewDelegate 委派执行器 + 3 个委派工具 预估2.5 天 依赖Phase 1 完成 本阶段是整个系统的核心,代码量最大,逻辑最复杂

Step 2.1:定义类型接口

文件packages/backend/src/modules/netaclaw/service/crew_types.ts

先定义所有跨模块共享的类型,避免循环引用。

/**
 * Crew 编排系统共享类型定义
 */

/** 委派执行结果 */
export interface DelegateResult {
  agent: string;
  status: 'completed' | 'failed';
  result: string;
  error?: string;
  duration: string;
  tokenUsage: { inputTokens: number; outputTokens: number };
}

/** 委派回调 */
export interface CrewCallbacks {
  /** 日志推送 */
  onLog: (runId: number, taskId: number, agentName: string, level: string, message: string) => void;
  /** 任务状态变更推送 */
  onTaskStatus: (runId: number, taskId: number, agentName: string, status: string, result?: any, error?: string) => void;
  /** 运行状态变更推送 */
  onRunStatus: (runId: number, status: string, progress?: string) => void;
  /** 升级推送 */
  onEscalation: (runId: number, taskId: number | undefined, reason: string, error?: string) => void;
}

/** 并行委派任务项 */
export interface ParallelTaskItem {
  agent_name: string;
  task_description: string;
  context?: string;
}

/** 集群运行上下文(编排器运行期间的内存状态) */
export interface CrewRunContext {
  runId: number;
  crewId: number;
  masterAgentId: number;
  memberAgents: Array<{
    id: number;
    name: string;
    label: string;
    role: string;
    agent: any;  // NetaClawAgentEntity
  }>;
  maxConcurrent: number;
  taskTimeout: number;
  callbacks: CrewCallbacks;
  /** 获取当前主 Agent 对话历史escalate 持久化用) */
  getConversation?: () => any[];
}

Step 2.2:实现 CrewDelegate委派执行器

文件packages/backend/src/modules/netaclaw/service/crew_delegate.ts

职责:执行子 Agent 任务的核心函数,直接调用 runAgent()

import { Provide, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
import { runAgent, AgentConfig } from '../runtime/agent.js';
import { AnyAgentTool } from '../tools/common.js';
import { DelegateResult, CrewCallbacks } from './crew_types.js';
import { SkillLoaderService } from '../service/skill_loader.js';
import { buildSkillContext } from '../service/skill_context.js';
// 导入内置工具
import { bashTool } from '../tools/builtin/bash.js';
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';

/** 委派相关工具名,子 Agent 不可使用(防止无限嵌套) */
const CREW_TOOLS = ['delegate_task', 'delegate_parallel', 'escalate'];

@Provide()
export class CrewDelegateService {
  @Logger()
  logger: ILogger;

  @InjectEntityModel(NetaClawCrewTaskEntity)
  crewTaskRepo: Repository<NetaClawCrewTaskEntity>;

  @Inject()
  skillLoader: SkillLoaderService;

  /**
   * 执行单个子 Agent 任务
   */
  async executeSubAgent(
    agent: NetaClawAgentEntity,
    taskDescription: string,
    context: string | undefined,
    runId: number,
    taskTimeout: number,
    callbacks: CrewCallbacks
  ): Promise<DelegateResult> {
    const startTime = Date.now();

    // 1. 创建 crew_task 记录
    const task = await this.crewTaskRepo.save({
      runId,
      agentId: agent.id,
      taskDescription,
      status: 'running',
      startTime: new Date(),
    } as any);

    callbacks.onTaskStatus(runId, task.id, agent.name, 'running');

    try {
      // 2. 加载子 Agent 的 Skill 上下文(继承 Agent 自身配置的 skills
      let skillSystemPrompt = '';
      if (agent.skills?.length) {
        const skillContext = await buildSkillContext(this.skillLoader, agent.skills);
        skillSystemPrompt = skillContext ? `\n\n${skillContext}` : '';
      }

      // 3. 构建 Agent 配置
      const agentConfig: AgentConfig = {
        name: agent.name,
        systemPrompt: `${agent.systemPrompt || ''}${skillSystemPrompt}\n\n## 当前任务\n${taskDescription}`,
        model: agent.modelConfig?.modelId || 'anthropic:claude-sonnet-4-20250514',
        apiKey: agent.modelConfig?.apiKey || '',
        baseUrl: agent.modelConfig?.apiUrl,
        skills: agent.skills || [],
      };

      // 4. 加载子 Agent 工具集(内置工具 + Skill 工具,移除委派工具)
      const builtinTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool];
      const skillTools = await this.skillLoader.loadToolsForAgent(agent);
      const tools = [...builtinTools, ...skillTools].filter(t => !CREW_TOOLS.includes(t.name));

      // 5. 构建用户消息
      const userMessage = context
        ? `任务:${taskDescription}\n\n上下文${context}`
        : taskDescription;

      // 6. 带超时调用 runAgent()
      const timeout = taskTimeout * 1000;
      const result = await Promise.race([
        runAgent({
          agentConfig,
          tools,
          userMessage,
          history: [],  // 空历史 = 独立会话
          onToken: (text) => callbacks.onLog(runId, task.id, agent.name, 'info', text),
          onToolCall: (name) => callbacks.onLog(runId, task.id, agent.name, 'tool', `调用工具: ${name}`),
        }),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error(`子 Agent "${agent.name}" 执行超时 (${taskTimeout}s)`)), timeout)
        ),
      ]) as any;

      // 7. 更新 crew_task 为完成
      const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
      await this.crewTaskRepo.update(task.id, {
        status: 'completed',
        result: { text: result.finalContent },
        tokenUsage: result.usage,
        endTime: new Date(),
      } as any);

      const delegateResult: DelegateResult = {
        agent: agent.name,
        status: 'completed',
        result: result.finalContent,
        duration,
        tokenUsage: result.usage,
      };

      callbacks.onTaskStatus(runId, task.id, agent.name, 'completed', delegateResult);
      return delegateResult;

    } catch (err: any) {
      // 8. 失败处理
      const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
      await this.crewTaskRepo.update(task.id, {
        status: 'failed',
        error: err.message,
        endTime: new Date(),
      } as any);

      const delegateResult: DelegateResult = {
        agent: agent.name,
        status: 'failed',
        result: '',
        error: err.message,
        duration,
        tokenUsage: { inputTokens: 0, outputTokens: 0 },
      };

      callbacks.onTaskStatus(runId, task.id, agent.name, 'failed', undefined, err.message);
      return delegateResult;
    }
  }

  /**
   * 并行执行多个子 Agent 任务(受 maxConcurrent 限制)
   */
  async executeParallel(
    tasks: Array<{ agent: NetaClawAgentEntity; taskDescription: string; context?: string }>,
    runId: number,
    maxConcurrent: number,
    taskTimeout: number,
    callbacks: CrewCallbacks
  ): Promise<DelegateResult[]> {
    // 简单的并发限制:分批执行
    const results: DelegateResult[] = [];
    for (let i = 0; i < tasks.length; i += maxConcurrent) {
      const batch = tasks.slice(i, i + maxConcurrent);
      const batchResults = await Promise.all(
        batch.map(t => this.executeSubAgent(t.agent, t.taskDescription, t.context, runId, taskTimeout, callbacks))
      );
      results.push(...batchResults);
    }
    return results;
  }
}

设计要点

  • runAgent() 是纯函数调用,history: [] 保证独立会话
  • Promise.race 实现超时控制
  • 动态加载子 Agent 的 skills/tools(通过 SkillLoaderService而非硬编码
  • 移除 delegate/escalate 工具防止子 Agent 嵌套委派
  • 并行执行通过分批 + Promise.all 实现,受 maxConcurrent 限制

Step 2.3:实现 3 个委派工具

2.3.1 delegate_task.ts(串行委派)

文件packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts

import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext, DelegateResult } from '../../service/crew_types.js';

/**
 * 创建 delegate_task 工具实例
 * 闭包绑定当前 crew 运行上下文,使主 Agent 可通过工具调用委派子 Agent
 */
export function createDelegateTaskTool(ctx: CrewRunContext): AnyAgentTool {
  return {
    name: 'delegate_task',
    label: '委派任务',
    description: '将任务委派给团队中的一个子 Agent 执行,等待执行完成后返回结果。',
    visibility: 'tool',
    parameters: Type.Object({
      agent_name: Type.String({ description: '子 Agent 名称' }),
      task_description: Type.String({ description: '任务描述' }),
      context: Type.Optional(Type.String({ description: '上下文(前序任务结果等)' })),
    }),
    execute: async (_id: string, params: any): Promise<string> => {
      const { agent_name, task_description, context } = params;

      // 查找目标子 Agent
      const member = ctx.memberAgents.find(m => m.name === agent_name);
      if (!member) {
        return JSON.stringify({
          agent: agent_name,
          status: 'failed',
          error: `未找到名为 "${agent_name}" 的子 Agent。可用成员: ${ctx.memberAgents.map(m => m.name).join(', ')}`,
        });
      }

      // 调用 CrewDelegate 执行
      // 注意ctx._delegate 在 CrewOrchestrator 构建时注入
      const result: DelegateResult = await (ctx as any)._delegate.executeSubAgent(
        member.agent,
        task_description,
        context,
        ctx.runId,
        ctx.taskTimeout,
        ctx.callbacks,
      );

      return JSON.stringify(result);
    },
  };
}

2.3.2 delegate_parallel.ts(并行委派)

文件packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts

import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext, DelegateResult } from '../../service/crew_types.js';

/**
 * 创建 delegate_parallel 工具实例
 */
export function createDelegateParallelTool(ctx: CrewRunContext): AnyAgentTool {
  return {
    name: 'delegate_parallel',
    label: '并行委派',
    description: '将多个任务同时委派给不同的子 Agent 并行执行,全部完成后返回所有结果。',
    visibility: 'tool',
    parameters: Type.Object({
      tasks: Type.Array(
        Type.Object({
          agent_name: Type.String({ description: '子 Agent 名称' }),
          task_description: Type.String({ description: '任务描述' }),
          context: Type.Optional(Type.String({ description: '上下文' })),
        }),
        { description: '要并行执行的任务列表' }
      ),
    }),
    execute: async (_id: string, params: any): Promise<string> => {
      const { tasks } = params;

      // 解析并验证所有目标 Agent
      const resolvedTasks = [];
      for (const t of tasks) {
        const member = ctx.memberAgents.find(m => m.name === t.agent_name);
        if (!member) {
          return JSON.stringify([{
            agent: t.agent_name,
            status: 'failed',
            error: `未找到名为 "${t.agent_name}" 的子 Agent`,
          }]);
        }
        resolvedTasks.push({
          agent: member.agent,
          taskDescription: t.task_description,
          context: t.context,
        });
      }

      // 并行执行
      const results: DelegateResult[] = await (ctx as any)._delegate.executeParallel(
        resolvedTasks,
        ctx.runId,
        ctx.maxConcurrent,
        ctx.taskTimeout,
        ctx.callbacks,
      );

      return JSON.stringify(results);
    },
  };
}

2.3.3 escalate.ts(升级人工)

文件packages/backend/src/modules/netaclaw/tools/builtin/escalate.ts

import { Type } from '@sinclair/typebox';
import type { AnyAgentTool } from '../common.js';
import type { CrewRunContext } from '../../service/crew_types.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';

/**
 * 内存中保存 escalate 的 resolve 回调(用于暂停/恢复)
 * key: runId, value: resolve 函数
 */
export const escalateResolvers = new Map<number, (userMessage: string) => void>();

/**
 * 创建 escalate 工具实例
 * 需要传入 crewRunRepo 以持久化 pausedState
 */
export function createEscalateTool(
  ctx: CrewRunContext,
  crewRunRepo: Repository<NetaClawCrewRunEntity>
): AnyAgentTool {
  return {
    name: 'escalate',
    label: '升级人工',
    description: '当你无法处理某个问题时,升级给人工处理。集群将暂停等待人工介入。',
    visibility: 'tool',
    parameters: Type.Object({
      reason: Type.String({ description: '升级原因' }),
      failed_task: Type.Optional(Type.String({ description: '失败的任务描述' })),
    }),
    execute: async (_id: string, params: any): Promise<string> => {
      const { reason, failed_task } = params;

      // 1. 持久化当前对话上下文到 pausedState进程重启恢复用
      const conversation = ctx.getConversation?.() || [];
      await crewRunRepo.update(ctx.runId, {
        status: 'paused',
        pausedState: JSON.stringify(conversation),
      } as any);

      // 2. 推送升级事件 + 运行状态变更
      ctx.callbacks.onEscalation(ctx.runId, undefined, reason, failed_task);
      ctx.callbacks.onRunStatus(ctx.runId, 'paused');

      // 3. 返回不 resolve 的 Promise阻塞 runAttempt 循环
      // 用户在监控页点击"恢复"时,通过 escalateResolvers 回调 resolve
      return new Promise<string>((resolve) => {
        escalateResolvers.set(ctx.runId, (userMessage: string) => {
          resolve(JSON.stringify({
            status: 'resumed',
            userMessage,
            instruction: '用户已提供处理意见,请根据用户意见继续执行。',
          }));
        });
      });
    },
  };
}

设计要点

  • escalateResolvers 内存 Map 是暂停/恢复的核心机制
  • execute() 返回的 Promise 不立即 resolve → runAttempt 阻塞在该 tool_call
  • 恢复时通过 Map 取出 resolve 回调,注入用户意见作为 tool_result
  • 进程重启恢复在 Phase 3 的 CrewScheduler 中处理

Step 2.4:实现 CrewOrchestrator核心编排器

文件packages/backend/src/modules/netaclaw/service/crew_orchestrator.ts

这是整个系统最核心的文件,负责:

  1. 加载集群配置
  2. 构建主 Agent 增强 system prompt
  3. 注入委派工具
  4. 启动主 Agent ReAct 循环
  5. 管理运行生命周期
import { Provide, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewAgentEntity } from '../entity/crew_agent.js';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
import { runAgent, AgentConfig } from '../runtime/agent.js';
import { AnyAgentTool } from '../tools/common.js';
import { CrewDelegateService } from './crew_delegate.js';
import { CrewRunContext, CrewCallbacks } from './crew_types.js';
import { createDelegateTaskTool } from '../tools/builtin/delegate_task.js';
import { createDelegateParallelTool } from '../tools/builtin/delegate_parallel.js';
import { createEscalateTool } from '../tools/builtin/escalate.js';
import { bashTool } from '../tools/builtin/bash.js';
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';

@Provide()
export class CrewOrchestratorService {
  @Logger()
  logger: ILogger;

  @InjectEntityModel(NetaClawCrewEntity)
  crewRepo: Repository<NetaClawCrewEntity>;

  @InjectEntityModel(NetaClawCrewAgentEntity)
  crewAgentRepo: Repository<NetaClawCrewAgentEntity>;

  @InjectEntityModel(NetaClawCrewRunEntity)
  crewRunRepo: Repository<NetaClawCrewRunEntity>;

  @InjectEntityModel(NetaClawCrewTaskEntity)
  crewTaskRepo: Repository<NetaClawCrewTaskEntity>;

  @InjectEntityModel(NetaClawAgentEntity)
  agentRepo: Repository<NetaClawAgentEntity>;

  @Inject()
  crewDelegate: CrewDelegateService;

  /**
   * 启动一次集群运行
   */
  async start(
    crewId: number,
    triggerType: string,
    triggerInput: string,
    callbacks: CrewCallbacks
  ): Promise<number> {
    // 1. 加载集群配置
    const crew = await this.crewRepo.findOne({ where: { id: crewId } });
    if (!crew) throw new Error(`集群 ${crewId} 不存在`);
    if (crew.status !== 1) throw new Error(`集群 ${crewId} 未发布`);

    // 2. 加载主 Agent
    const masterAgent = await this.agentRepo.findOne({ where: { id: crew.masterAgentId } });
    if (!masterAgent) throw new Error(`主 Agent ${crew.masterAgentId} 不存在`);

    // 3. 加载集群成员
    const crewAgents = await this.crewAgentRepo.find({ where: { crewId } });
    const memberAgents = [];
    for (const ca of crewAgents) {
      if (ca.agentId === crew.masterAgentId) continue; // 排除主 Agent 自身
      const agent = await this.agentRepo.findOne({ where: { id: ca.agentId } });
      if (agent) {
        memberAgents.push({
          id: agent.id,
          name: agent.name,
          label: agent.label,
          role: ca.role || agent.description || '',
          agent,
        });
      }
    }

    // 4. 创建运行记录
    const run = await this.crewRunRepo.save({
      crewId,
      triggerType,
      triggerInput,
      status: 'running',
      startTime: new Date(),
      tokenUsage: { inputTokens: 0, outputTokens: 0 },
    } as any);

    callbacks.onRunStatus(run.id, 'running', `0/${memberAgents.length}`);

    // 5. 构建运行上下文
    const ctx: CrewRunContext = {
      runId: run.id,
      crewId,
      masterAgentId: crew.masterAgentId,
      memberAgents,
      maxConcurrent: crew.maxConcurrent || 3,
      taskTimeout: crew.taskTimeout || 300,
      callbacks,
      // getConversation 在 runOrchestration 中注入(需要闭包引用 runAgent 的内部状态)
    };
    // 注入 delegate 服务引用
    (ctx as any)._delegate = this.crewDelegate;

    // 6. 异步执行编排(不阻塞调用方)
    this.runOrchestration(crew, masterAgent, ctx, triggerInput, callbacks)
      .catch(err => {
        this.logger.error(`集群 ${crewId} 运行 ${run.id} 异常:`, err);
      });

    return run.id;
  }

  /**
   * 核心编排执行逻辑
   */
  private async runOrchestration(
    crew: NetaClawCrewEntity,
    masterAgent: NetaClawAgentEntity,
    ctx: CrewRunContext,
    triggerInput: string,
    callbacks: CrewCallbacks
  ): Promise<void> {
    try {
      // 1. 构建增强 system prompt
      const enhancedPrompt = this.buildEnhancedPrompt(masterAgent, ctx, crew);

      // 2. 构建主 Agent 配置
      const agentConfig: AgentConfig = {
        name: masterAgent.name,
        systemPrompt: enhancedPrompt,
        model: masterAgent.modelConfig?.modelId || 'anthropic:claude-sonnet-4-20250514',
        apiKey: masterAgent.modelConfig?.apiKey || '',
        baseUrl: masterAgent.modelConfig?.apiUrl,
        maxToolRounds: 50,  // 主 Agent 可能需要多轮委派
      };

      // 3. 用闭包捕获对话历史引用(供 escalate 持久化用)
      // runAgent 内部会构建 messages 数组,我们通过 history 参数的引用来追踪
      const conversationTracker: any[] = [];
      ctx.getConversation = () => conversationTracker;

      // 4. 构建主 Agent 工具集(基础工具 + 委派工具)
      const tools: AnyAgentTool[] = [
        bashTool, readFileTool, writeFileTool, listDirTool,
        createDelegateTaskTool(ctx),
        createDelegateParallelTool(ctx),
        createEscalateTool(ctx, this.crewRunRepo),  // 传入 repo 以持久化 pausedState
      ];

      // 5. 执行主 Agent ReAct 循环
      const result = await runAgent({
        agentConfig,
        tools,
        userMessage: triggerInput || '请根据团队成员和调度建议,开始执行任务。',
        history: [],
        onToken: (text) => {
          callbacks.onLog(ctx.runId, 0, masterAgent.name, 'info', text);
          // 追踪对话内容(简化版,完整版需在 runAttempt 层获取)
          conversationTracker.push({ role: 'assistant_token', content: text });
        },
        onToolCall: (name, args) => callbacks.onLog(ctx.runId, 0, masterAgent.name, 'tool', `调用 ${name}`),
      });

      // 6. 计算累计 Token主 Agent + 所有子 Agent 任务)
      const tasks = await this.crewTaskRepo.find({ where: { runId: ctx.runId } });
      const totalTokens = tasks.reduce((acc, t) => ({
        inputTokens: acc.inputTokens + (t.tokenUsage?.inputTokens || 0),
        outputTokens: acc.outputTokens + (t.tokenUsage?.outputTokens || 0),
      }), {
        inputTokens: result.usage.inputTokens,
        outputTokens: result.usage.outputTokens,
      });

      // 7. 更新运行记录为完成
      await this.crewRunRepo.update(ctx.runId, {
        status: 'completed',
        result: { summary: result.finalContent },
        tokenUsage: totalTokens,
        endTime: new Date(),
      } as any);

      callbacks.onRunStatus(ctx.runId, 'completed');

    } catch (err: any) {
      // 失败处理
      await this.crewRunRepo.update(ctx.runId, {
        status: 'failed',
        error: err.message,
        endTime: new Date(),
      } as any);

      callbacks.onRunStatus(ctx.runId, 'failed');
      this.logger.error(`集群运行 ${ctx.runId} 失败:`, err);
    }
  }

  /**
   * 构建主 Agent 增强 system prompt
   */
  private buildEnhancedPrompt(
    masterAgent: NetaClawAgentEntity,
    ctx: CrewRunContext,
    crew: NetaClawCrewEntity
  ): string {
    let prompt = masterAgent.systemPrompt || '';

    // 追加团队成员信息
    prompt += '\n\n## 团队成员\n';
    prompt += '你是团队的主管 Agent负责将任务分配给以下子 Agent 执行:\n\n';
    for (const member of ctx.memberAgents) {
      prompt += `- **${member.name}**${member.label}: ${member.role}\n`;
    }

    // 追加调度建议(如有连线提示)
    if (crew.delegateHints?.hints) {
      prompt += `\n## 调度建议\n${crew.delegateHints.hints}\n`;
    }

    // 追加工具使用说明
    prompt += `\n## 工具说明\n`;
    prompt += `- 使用 \`delegate_task\` 将任务委派给一个子 Agent 串行执行\n`;
    prompt += `- 使用 \`delegate_parallel\` 将多个独立任务同时委派给不同子 Agent 并行执行\n`;
    prompt += `- 当遇到无法解决的问题时,使用 \`escalate\` 升级给人工处理\n`;
    prompt += `- 根据任务之间的依赖关系决定串行还是并行执行\n`;
    prompt += `- 收到子 Agent 的执行结果后,分析结果并决定下一步行动\n`;

    return prompt;
  }

  /**
   * 终止运行
   */
  async stop(runId: number): Promise<void> {
    await this.crewRunRepo.update(runId, {
      status: 'failed',
      error: '用户手动终止',
      endTime: new Date(),
    } as any);
  }

  /**
   * 暂停运行(由 escalate 工具自动触发,此方法用于手动暂停)
   */
  async pause(runId: number): Promise<void> {
    await this.crewRunRepo.update(runId, {
      status: 'paused',
    } as any);
  }

  /**
   * 从 pausedState 恢复运行(进程重启后使用)
   */
  async resumeFromPausedState(
    runId: number,
    pausedHistory: any[],
    userMessage: string,
    callbacks: CrewCallbacks
  ): Promise<void> {
    const run = await this.crewRunRepo.findOne({ where: { id: runId } });
    if (!run) throw new Error(`运行 ${runId} 不存在`);

    const crew = await this.crewRepo.findOne({ where: { id: run.crewId } });
    if (!crew) throw new Error(`集群 ${run.crewId} 不存在`);

    const masterAgent = await this.agentRepo.findOne({ where: { id: crew.masterAgentId } });
    if (!masterAgent) throw new Error(`主 Agent 不存在`);

    // 更新状态为 running
    await this.crewRunRepo.update(runId, { status: 'running' } as any);

    // 重新构建上下文并继续执行(将用户恢复消息追加到历史中)
    // 简化实现:以用户消息重新触发,历史已持久化
    const crewAgents = await this.crewAgentRepo.find({ where: { crewId: crew.id } });
    const memberAgents = [];
    for (const ca of crewAgents) {
      if (ca.agentId === crew.masterAgentId) continue;
      const agent = await this.agentRepo.findOne({ where: { id: ca.agentId } });
      if (agent) {
        memberAgents.push({ id: agent.id, name: agent.name, label: agent.label, role: ca.role || '', agent });
      }
    }

    const ctx: CrewRunContext = {
      runId, crewId: crew.id, masterAgentId: crew.masterAgentId,
      memberAgents, maxConcurrent: crew.maxConcurrent || 3,
      taskTimeout: crew.taskTimeout || 300, callbacks,
    };
    (ctx as any)._delegate = this.crewDelegate;

    // 用恢复消息作为新一轮 triggerInput
    this.runOrchestration(crew, masterAgent, ctx, `用户恢复指令: ${userMessage}`, callbacks)
      .catch(err => this.logger.error(`恢复运行 ${runId} 失败:`, err));
  }
}

Step 2.5:实现 CrewService集群 CRUD 业务层)

文件packages/backend/src/modules/netaclaw/service/crew.ts

import { Provide, Inject } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, In } from 'typeorm';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewAgentEntity } from '../entity/crew_agent.js';
import { NetaClawAgentEntity } from '../entity/agent.js';

@Provide()
export class CrewService {
  @InjectEntityModel(NetaClawCrewEntity)
  crewRepo: Repository<NetaClawCrewEntity>;

  @InjectEntityModel(NetaClawCrewAgentEntity)
  crewAgentRepo: Repository<NetaClawCrewAgentEntity>;

  @InjectEntityModel(NetaClawAgentEntity)
  agentRepo: Repository<NetaClawAgentEntity>;

  /**
   * 保存画布数据 + 同步成员关系
   */
  async saveCanvas(crewId: number, data: {
    canvasData: any;
    members: Array<{ agentId: number; role?: string; canvasPosition?: any; groupName?: string }>;
    delegateHints?: any;
  }): Promise<void> {
    // 1. 更新画布数据
    await this.crewRepo.update(crewId, {
      canvasData: data.canvasData,
      delegateHints: data.delegateHints,
    } as any);

    // 2. 同步成员关系(先删后插,简单可靠)
    await this.crewAgentRepo.delete({ crewId });
    if (data.members?.length) {
      const entities = data.members.map(m => ({
        crewId,
        agentId: m.agentId,
        role: m.role || '',
        canvasPosition: m.canvasPosition,
        groupName: m.groupName || '',
      }));
      await this.crewAgentRepo.save(entities as any[]);
    }
  }

  /**
   * 获取集群详情(含成员信息)
   */
  async getDetail(crewId: number): Promise<any> {
    const crew = await this.crewRepo.findOne({ where: { id: crewId } });
    if (!crew) return null;

    const crewAgents = await this.crewAgentRepo.find({ where: { crewId } });
    const agentIds = crewAgents.map(ca => ca.agentId);
    const agents = agentIds.length
      ? await this.agentRepo.find({ where: { id: In(agentIds) } })
      : [];

    const members = crewAgents.map(ca => {
      const agent = agents.find(a => a.id === ca.agentId);
      return {
        ...ca,
        agentName: agent?.name,
        agentLabel: agent?.label,
        agentIcon: agent?.icon,
        agentDescription: agent?.description,
      };
    });

    return { ...crew, members };
  }

  /**
   * 发布集群
   */
  async publish(crewId: number): Promise<void> {
    const crew = await this.crewRepo.findOne({ where: { id: crewId } });
    if (!crew) throw new Error('集群不存在');
    if (!crew.masterAgentId) throw new Error('请先设置主 Agent');

    const members = await this.crewAgentRepo.find({ where: { crewId } });
    if (members.length < 2) throw new Error('集群至少需要 2 个成员(含主 Agent');

    await this.crewRepo.update(crewId, { status: 1 } as any);
  }

  /**
   * 取消发布
   */
  async unpublish(crewId: number): Promise<void> {
    await this.crewRepo.update(crewId, { status: 0 } as any);
  }
}

Step 2.6:验证核心引擎

此阶段暂不实现 Controller 和 WebSocketPhase 3但可通过单元测试或临时脚本验证

验证方式:编写一个临时测试服务,在后端启动后通过 API 手动调用

// 临时测试(可在 service 中加一个 test 方法)
// 1. 创建一个 crew 记录
// 2. 创建 crew_agent 记录
// 3. 调用 orchestrator.start() 
// 4. 检查 crew_run 和 crew_task 表是否正确记录

验证清单

  • CrewDelegateService 可正确调用 runAgent() 执行子 Agent
  • delegate_task 工具可找到目标 Agent 并返回结果
  • delegate_parallel 工具可并行执行多个子 Agent
  • escalate 工具可阻塞 ReAct 循环
  • CrewOrchestrator 可完成完整的编排流程
  • crew_run / crew_task 记录正确写入数据库
  • token 消耗正确统计

Step 2.7:提交代码

git add packages/backend/src/modules/netaclaw/service/crew_types.ts
git add packages/backend/src/modules/netaclaw/service/crew_delegate.ts
git add packages/backend/src/modules/netaclaw/service/crew_orchestrator.ts
git add packages/backend/src/modules/netaclaw/service/crew.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts
git add packages/backend/src/modules/netaclaw/tools/builtin/escalate.ts
git commit -m "feat(crew): 实现核心编排引擎 - Orchestrator + Delegate + 3个委派工具"

Phase 2 验收

  • crew_types.ts 共享类型定义完成
  • crew_delegate.ts 子 Agent 执行器完成(含超时控制、并发限制)
  • delegate_task.ts 串行委派工具完成
  • delegate_parallel.ts 并行委派工具完成
  • escalate.ts 升级人工工具完成(含暂停/恢复机制)
  • crew_orchestrator.ts 核心编排器完成(含增强 prompt 构建)
  • crew.ts 集群 CRUD 业务层完成
  • 核心流程验证通过
  • 代码已提交

Phase 3后端通信层WebSocket + 触发 + 定时)

目标:实现 Controller API + CrewGateway WebSocket + 定时调度器 预估2 天 依赖Phase 2 完成

Step 3.1:实现 Crew CRUD Controller

文件packages/backend/src/modules/netaclaw/controller/admin/crew.ts

import { Body, Inject, Post, Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { NetaClawCrewEntity } from '../../entity/crew.js';
import { CrewService } from '../../service/crew.js';

/**
 * 集群管理 CRUD
 * 自动生成: add / delete / update / info / list / page
 */
@Provide()
@CoolController({
  api: ['add', 'delete', 'update', 'info', 'list', 'page'],
  entity: NetaClawCrewEntity,
  pageQueryOp: {
    fieldEq: ['status'],
    keyWordLikeFields: ['name', 'label'],
  },
})
export class AdminCrewController extends BaseController {
  @Inject()
  crewService: CrewService;

  /**
   * 保存画布 + 同步成员关系
   */
  @Post('/saveCanvas')
  async saveCanvas(@Body() body: any) {
    const { crewId, canvasData, members, delegateHints } = body;
    await this.crewService.saveCanvas(crewId, { canvasData, members, delegateHints });
    return this.ok();
  }

  /**
   * 获取集群详情(含成员 Agent 信息)
   */
  @Post('/detail')
  async detail(@Body() body: any) {
    const result = await this.crewService.getDetail(body.id);
    return this.ok(result);
  }

  /**
   * 发布集群
   */
  @Post('/publish')
  async publish(@Body() body: any) {
    await this.crewService.publish(body.id);
    return this.ok();
  }

  /**
   * 取消发布
   */
  @Post('/unpublish')
  async unpublish(@Body() body: any) {
    await this.crewService.unpublish(body.id);
    return this.ok();
  }
}

自动生成的 API 端点Cool Admin 约定,路径基于 controller/admin/ 目录结构):

  • POST /admin/netaclaw/crew/add — 创建集群
  • POST /admin/netaclaw/crew/delete — 删除集群
  • POST /admin/netaclaw/crew/update — 更新集群
  • POST /admin/netaclaw/crew/info — 获取集群信息
  • POST /admin/netaclaw/crew/page — 分页查询
  • POST /admin/netaclaw/crew/saveCanvas — 保存画布
  • POST /admin/netaclaw/crew/detail — 集群详情(含成员)
  • POST /admin/netaclaw/crew/publish — 发布
  • POST /admin/netaclaw/crew/unpublish — 取消发布

注意:设计文档中写的 POST /admin/crew/trigger/start 是简写。实际 Cool Admin 路由映射为 POST /admin/netaclaw/crewTrigger/start(基于文件路径 controller/admin/crew_trigger.ts,驼峰转换)。前端通过 service 代理调用,无需关心具体路径。

前端 service 代理路径service.netaclaw.crew.saveCanvas()

Step 3.2:实现 Crew Run Controller

文件packages/backend/src/modules/netaclaw/controller/admin/crew_run.ts

import { Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';

/**
 * 集群运行记录查询
 */
@Provide()
@CoolController({
  api: ['info', 'list', 'page'],
  entity: NetaClawCrewRunEntity,
  pageQueryOp: {
    fieldEq: ['crewId', 'status', 'triggerType'],
    where: async (ctx) => {
      // 可根据需求添加筛选逻辑
      return [];
    },
  },
})
export class AdminCrewRunController extends BaseController {}

Step 3.3:实现 Crew Trigger Controller

文件packages/backend/src/modules/netaclaw/controller/admin/crew_trigger.ts

import { Body, Inject, Post, Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { CrewOrchestratorService } from '../../service/crew_orchestrator.js';
import { escalateResolvers } from '../../tools/builtin/escalate.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../../entity/crew_run.js';

/**
 * 集群触发控制
 */
@Provide()
@CoolController()
export class AdminCrewTriggerController extends BaseController {
  @Inject()
  orchestrator: CrewOrchestratorService;

  @InjectEntityModel(NetaClawCrewRunEntity)
  crewRunRepo: Repository<NetaClawCrewRunEntity>;

  /**
   * 手动触发运行
   */
  @Post('/start')
  async start(@Body() body: { crewId: number; triggerInput?: string }) {
    const { crewId, triggerInput = '' } = body;

    // 创建 noop 回调WebSocket 推送在 Gateway 中处理)
    // 这里提供基础回调,实际的 WebSocket 推送在 Phase 3.4 集成
    const callbacks = this.createNoopCallbacks();

    const runId = await this.orchestrator.start(crewId, 'manual', triggerInput, callbacks);
    return this.ok({ runId });
  }

  /**
   * 终止运行
   */
  @Post('/stop')
  async stop(@Body() body: { runId: number }) {
    await this.orchestrator.stop(body.runId);
    return this.ok();
  }

  /**
   * 恢复暂停的运行(处理 escalate
   */
  @Post('/resume')
  async resume(@Body() body: { runId: number; userMessage: string }) {
    const { runId, userMessage } = body;

    // 优先从内存 Map 中取出 resolve 回调
    const resolver = escalateResolvers.get(runId);
    if (resolver) {
      // 内存中有 → 直接恢复(进程未重启)
      resolver(userMessage);
      escalateResolvers.delete(runId);
      await this.crewRunRepo.update(runId, { status: 'running' } as any);
      return this.ok();
    }

    // 内存中没有(进程重启了)→ 从 pausedState 恢复
    const run = await this.crewRunRepo.findOne({ where: { id: runId } });
    if (run?.pausedState) {
      const history = JSON.parse(run.pausedState as string);
      const callbacks = this.createNoopCallbacks();
      await this.orchestrator.resumeFromPausedState(runId, history, userMessage, callbacks);
      return this.ok();
    }

    return this.fail('运行已过期或无暂停状态,请重新触发');
  }

  /**
   * 获取运行详情(含子任务列表)
   */
  @Post('/runDetail')
  async runDetail(@Body() body: { runId: number }) {
    const run = await this.crewRunRepo.findOne({ where: { id: body.runId } });
    // 查询关联的 crew_task
    // ... 补充查询逻辑
    return this.ok(run);
  }

  private createNoopCallbacks() {
    return {
      onLog: () => {},
      onTaskStatus: () => {},
      onRunStatus: () => {},
      onEscalation: () => {},
    };
  }
}

Step 3.4:实现 CrewGatewayWebSocket 网关)

文件packages/backend/src/modules/netaclaw/gateway/crew_server.ts

独立 /crew 命名空间,与现有 /netaclaw 隔离。

import { Inject, Logger, WSController, OnWSConnection, OnWSMessage } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { Context } from '@midwayjs/socketio';
import { CrewOrchestratorService } from '../service/crew_orchestrator.js';
import { escalateResolvers } from '../tools/builtin/escalate.js';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { CrewCallbacks } from '../service/crew_types.js';

/**
 * Crew WebSocket 网关
 * 命名空间: /crew
 *
 * 服务端 → 客户端事件:
 *   crew:run:status   - 运行状态变更
 *   crew:task:status  - 子任务状态变更
 *   crew:escalation   - 升级人工通知
 *   crew:log          - 实时日志
 *
 * 客户端 → 服务端事件:
 *   crew:trigger      - 触发运行
 *   crew:control      - 控制运行(暂停/恢复/终止)
 */
@WSController('/crew')
export class CrewGateway {
  @Inject()
  ctx: Context;

  @Logger()
  logger: ILogger;

  @Inject()
  orchestrator: CrewOrchestratorService;

  @InjectEntityModel(NetaClawCrewRunEntity)
  crewRunRepo: Repository<NetaClawCrewRunEntity>;

  @OnWSConnection()
  async onConnection() {
    this.logger.info('[Crew WS] 客户端连接:', this.ctx.id);
  }

  @OnWSMessage('crew:trigger')
  async onTrigger(data: { crewId: number; triggerInput?: string }) {
    const { crewId, triggerInput = '' } = data;

    // 构建 WebSocket 推送回调
    const callbacks = this.createCallbacks();

    try {
      const runId = await this.orchestrator.start(crewId, 'manual', triggerInput, callbacks);
      this.ctx.emit('crew:run:status', { runId, status: 'running' });
    } catch (err: any) {
      this.ctx.emit('crew:run:status', { runId: 0, status: 'failed', error: err.message });
    }
  }

  @OnWSMessage('crew:control')
  async onControl(data: { runId: number; action: string; userMessage?: string }) {
    const { runId, action, userMessage } = data;

    switch (action) {
      case 'stop':
        await this.orchestrator.stop(runId);
        this.ctx.emit('crew:run:status', { runId, status: 'failed' });
        break;

      case 'resume':
        const resolver = escalateResolvers.get(runId);
        if (resolver) {
          resolver(userMessage || '继续执行');
          escalateResolvers.delete(runId);
          await this.crewRunRepo.update(runId, { status: 'running' } as any);
          this.ctx.emit('crew:run:status', { runId, status: 'running' });
        }
        break;

      case 'pause':
        await this.orchestrator.pause(runId);
        this.ctx.emit('crew:run:status', { runId, status: 'paused' });
        break;

      case 'retry': {
        // 重新触发一次运行(复制原 run 的 crewId 和 triggerInput
        const run = await this.crewRunRepo.findOne({ where: { id: runId } });
        if (run) {
          const callbacks = this.createCallbacks();
          const newRunId = await this.orchestrator.start(run.crewId, 'manual', run.triggerInput || '', callbacks);
          this.ctx.emit('crew:run:status', { runId: newRunId, status: 'running' });
        }
        break;
      }
    }
  }

  /**
   * 构建 WebSocket 推送回调
   */
  private createCallbacks(): CrewCallbacks {
    const ctx = this.ctx;
    return {
      onLog: (runId, taskId, agentName, level, message) => {
        ctx.emit('crew:log', {
          runId, taskId, agentName, level, message,
          timestamp: new Date().toISOString(),
        });
      },
      onTaskStatus: (runId, taskId, agentName, status, result, error) => {
        ctx.emit('crew:task:status', { runId, taskId, agentName, status, result, error });
      },
      onRunStatus: (runId, status, progress) => {
        ctx.emit('crew:run:status', { runId, status, progress });
      },
      onEscalation: (runId, taskId, reason, error) => {
        ctx.emit('crew:escalation', { runId, taskId, reason, error });
      },
    };
  }
}

Step 3.5:实现 CrewScheduler定时调度器

文件packages/backend/src/modules/netaclaw/service/crew_scheduler.ts

import { Provide, Inject, Logger, Init } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CronJob } from 'cron';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { CrewOrchestratorService } from './crew_orchestrator.js';
import { escalateResolvers } from '../tools/builtin/escalate.js';

/**
 * 集群定时调度器
 * - 管理 CronJob 实例的动态创建/销毁
 * - 服务启动时恢复已发布集群的定时任务
 * - 服务启动时恢复 paused 状态的运行
 */
@Provide()
export class CrewSchedulerService {
  @Logger()
  logger: ILogger;

  @InjectEntityModel(NetaClawCrewEntity)
  crewRepo: Repository<NetaClawCrewEntity>;

  @InjectEntityModel(NetaClawCrewRunEntity)
  crewRunRepo: Repository<NetaClawCrewRunEntity>;

  @Inject()
  orchestrator: CrewOrchestratorService;

  /** 活跃的 CronJob 实例 */
  private activeJobs = new Map<number, CronJob>();

  /**
   * 服务启动时初始化
   */
  @Init()
  async init() {
    // 延迟初始化,等待其他服务就绪
    setTimeout(() => {
      this.restoreSchedules().catch(err =>
        this.logger.error('[CrewScheduler] 恢复定时任务失败:', err)
      );
      this.restorePausedRuns().catch(err =>
        this.logger.error('[CrewScheduler] 恢复暂停运行失败:', err)
      );
    }, 5000);
  }

  /**
   * 注册定时任务
   */
  registerCron(crew: NetaClawCrewEntity): void {
    if (!crew.triggerConfig?.cron?.enabled) return;

    // 先取消已有的
    this.unregisterCron(crew.id);

    try {
      const job = new CronJob(
        crew.triggerConfig.cron.expression,
        () => {
          this.logger.info(`[CrewScheduler] 定时触发集群 ${crew.id}: ${crew.label}`);
          const callbacks = {
            onLog: () => {},
            onTaskStatus: () => {},
            onRunStatus: () => {},
            onEscalation: () => {},
          };
          this.orchestrator.start(crew.id, 'cron', '', callbacks)
            .catch(err => this.logger.error(`[CrewScheduler] 定时运行失败:`, err));
        },
        null,
        true,
        crew.triggerConfig.cron.timezone || 'Asia/Shanghai'
      );

      this.activeJobs.set(crew.id, job);
      this.logger.info(`[CrewScheduler] 注册定时任务: 集群 ${crew.id}, cron=${crew.triggerConfig.cron.expression}`);
    } catch (err) {
      this.logger.error(`[CrewScheduler] 注册定时任务失败:`, err);
    }
  }

  /**
   * 取消定时任务
   */
  unregisterCron(crewId: number): void {
    const job = this.activeJobs.get(crewId);
    if (job) {
      job.stop();
      this.activeJobs.delete(crewId);
      this.logger.info(`[CrewScheduler] 取消定时任务: 集群 ${crewId}`);
    }
  }

  /**
   * 恢复所有已发布集群的定时任务
   */
  private async restoreSchedules(): Promise<void> {
    const publishedCrews = await this.crewRepo.find({ where: { status: 1 } });
    const cronCrews = publishedCrews.filter(c => c.triggerConfig?.cron?.enabled);

    this.logger.info(`[CrewScheduler] 恢复 ${cronCrews.length} 个定时任务`);
    cronCrews.forEach(crew => this.registerCron(crew));
  }

  /**
   * 恢复 paused 状态的运行(进程重启后)
   */
  private async restorePausedRuns(): Promise<void> {
    const pausedRuns = await this.crewRunRepo.find({ where: { status: 'paused' } });

    if (pausedRuns.length === 0) return;
    this.logger.info(`[CrewScheduler] 发现 ${pausedRuns.length} 个暂停的运行,等待用户恢复`);

    // 暂停的运行不自动恢复,而是等待用户手动处理
    // 如果 pausedState 存在,可以在用户恢复时使用
  }

  /**
   * 获取活跃定时任务数
   */
  getActiveJobCount(): number {
    return this.activeJobs.size;
  }
}

Step 3.6:集成定时调度到发布/取消发布流程

修改文件packages/backend/src/modules/netaclaw/service/crew.ts

publish()unpublish() 方法中注入调度器:

// 在 CrewService 中注入
@Inject()
scheduler: CrewSchedulerService;

// publish() 中追加:
async publish(crewId: number): Promise<void> {
  // ... 现有校验逻辑 ...
  await this.crewRepo.update(crewId, { status: 1 } as any);
  
  // 注册定时任务
  const crew = await this.crewRepo.findOne({ where: { id: crewId } });
  if (crew) this.scheduler.registerCron(crew);
}

// unpublish() 中追加:
async unpublish(crewId: number): Promise<void> {
  await this.crewRepo.update(crewId, { status: 0 } as any);
  
  // 取消定时任务
  this.scheduler.unregisterCron(crewId);
}

Step 3.7:验证通信层

验证清单

  • CRUD API:通过 Postman/curl 测试集群的创建、查询、更新、删除
  • 画布保存saveCanvas API 正确保存 canvasData + 同步成员关系
  • 发布流程:发布时校验主 Agent 和成员数量
  • 手动触发:通过 REST API 和 WebSocket 均可触发运行
  • WebSocket 事件:连接 /crew 命名空间,触发运行后能收到 crew:run:statuscrew:task:statuscrew:log 事件
  • 终止运行crew:control { action: 'stop' } 正确终止
  • 升级恢复escalate 后通过 crew:control { action: 'resume' } 正确恢复
  • 定时调度:发布带 cron 配置的集群后,定时任务注册成功
  • 前端 service 代理路径可访问:service.netaclaw.crew.*

Step 3.8:提交代码

git add packages/backend/src/modules/netaclaw/controller/admin/crew*.ts
git add packages/backend/src/modules/netaclaw/gateway/crew_server.ts
git add packages/backend/src/modules/netaclaw/service/crew_scheduler.ts
# + crew.ts 的修改
git commit -m "feat(crew): 实现通信层 - Controller + WebSocket Gateway + 定时调度器"

Phase 3 验收

  • 3 个 Controller 创建完成crew / crew_run / crew_trigger
  • CrewGateway WebSocket 网关工作正常
  • CrewScheduler 定时调度器工作正常
  • 发布/取消发布联动定时调度
  • REST API + WebSocket 双通道触发验证通过
  • 代码已提交

Phase 4前端编排页

目标实现完整的集群编排画布页面Vue Flow 画布 + Agent 侧栏 + 属性面板) 预估3 天Vue Flow 首次使用,额外预留缓冲) 依赖Phase 3 完成(后端 API 可用)

Step 4.1:注册路由与菜单

4.1.1 修改前端路由配置

文件packages/frontend/src/modules/agent/config.ts

在 views 数组中追加:

{
  path: '/agent/crew-editor',
  meta: { label: 'Agent 编排' },
  component: () => import('./views/crew-editor.vue')
},
{
  path: '/agent/crew-monitor',
  meta: { label: '运行监控' },
  component: () => import('./views/crew-monitor.vue')
},

4.1.2 插入菜单记录

通过数据库插入 base_sys_menu 记录Cool Admin 菜单管理):

-- 在 Agent 管理父菜单下新增
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms)
VALUES
  ('Agent 编排', '/agent/crew-editor', <agent父菜单ID>, 5, 1, 'icon-crew', 'agent:crew:editor'),
  ('运行监控', '/agent/crew-monitor', <agent父菜单ID>, 6, 1, 'icon-monitor', 'agent:crew:monitor');

具体 parentId 需查询当前数据库中 Agent 管理的菜单 ID。

Step 4.2:实现 Pinia Store

文件packages/frontend/src/modules/agent/store/crew.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useCool } from '/@/cool';

/**
 * Crew 编排状态管理
 */
export const useCrewStore = defineStore('agent-crew', () => {
  const { service } = useCool();

  // 集群列表
  const crewList = ref<any[]>([]);
  // 当前编辑的集群
  const currentCrew = ref<any>(null);
  // 当前集群的成员列表
  const currentMembers = ref<any[]>([]);
  // 可用 Agent 列表(已发布的 Agent
  const availableAgents = ref<any[]>([]);

  // 加载集群列表
  async function loadCrewList() {
    const res = await service.netaclaw.crew.list();
    crewList.value = res || [];
  }

  // 加载集群详情
  async function loadCrewDetail(crewId: number) {
    const res = await service.netaclaw.crew.detail({ id: crewId });
    currentCrew.value = res;
    currentMembers.value = res?.members || [];
    return res;
  }

  // 加载可用 Agent
  async function loadAvailableAgents() {
    const res = await service.netaclaw.agent.list({ status: 1 });
    availableAgents.value = res || [];
  }

  // 保存画布
  async function saveCanvas(crewId: number, canvasData: any, members: any[], delegateHints: any) {
    await service.netaclaw.crew.saveCanvas({
      crewId, canvasData, members, delegateHints,
    });
  }

  // 创建集群
  async function createCrew(data: any) {
    const res = await service.netaclaw.crew.add(data);
    await loadCrewList();
    return res;
  }

  // 发布/取消发布
  async function publish(crewId: number) {
    await service.netaclaw.crew.publish({ id: crewId });
    await loadCrewDetail(crewId);
  }

  async function unpublish(crewId: number) {
    await service.netaclaw.crew.unpublish({ id: crewId });
    await loadCrewDetail(crewId);
  }

  // 判断某 Agent 是否已在当前集群中
  const isMember = computed(() => {
    const ids = new Set(currentMembers.value.map((m: any) => m.agentId));
    return (agentId: number) => ids.has(agentId);
  });

  return {
    crewList, currentCrew, currentMembers, availableAgents,
    loadCrewList, loadCrewDetail, loadAvailableAgents,
    saveCanvas, createCrew, publish, unpublish,
    isMember,
  };
});

Step 4.3:实现画布核心 Hook

文件packages/frontend/src/modules/agent/hooks/crew-canvas.ts

import { ref, watch } from 'vue';
import { useVueFlow, type Node, type Edge } from '@vue-flow/core';

/**
 * 画布操作 Hook
 * 封装 Vue Flow 的节点/连线增删、序列化/反序列化
 */
export function useCrewCanvas() {
  const {
    nodes, edges, addNodes, removeNodes,
    addEdges, removeEdges, fitView,
    onConnect, onNodeDragStop,
  } = useVueFlow();

  // 主 Agent ID
  const masterAgentId = ref<number | null>(null);

  /**
   * 添加 Agent 节点到画布
   */
  function addAgentNode(agent: any, position?: { x: number; y: number }) {
    const id = `agent-${agent.id}`;

    // 避免重复添加
    if (nodes.value.find(n => n.id === id)) return;

    const node: Node = {
      id,
      type: 'crew-agent',  // 自定义节点类型
      position: position || { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
      data: {
        agentId: agent.id,
        name: agent.name,
        label: agent.label,
        icon: agent.icon,
        description: agent.description,
        role: '',
        groupName: '',
        isMaster: agent.id === masterAgentId.value,
      },
    };

    addNodes([node]);
  }

  /**
   * 移除 Agent 节点
   */
  function removeAgentNode(agentId: number) {
    const nodeId = `agent-${agentId}`;
    const node = nodes.value.find(n => n.id === nodeId);
    if (node) removeNodes([node]);
  }

  /**
   * 设置主 Agent
   */
  function setMasterAgent(agentId: number) {
    masterAgentId.value = agentId;
    // 更新所有节点的 isMaster 标记
    nodes.value.forEach(n => {
      if (n.data) {
        n.data.isMaster = n.data.agentId === agentId;
      }
    });
  }

  /**
   * 序列化画布数据(保存用)
   */
  function serializeCanvas() {
    return {
      nodes: nodes.value.map(n => ({
        id: n.id,
        type: n.type,
        position: n.position,
        data: n.data,
      })),
      edges: edges.value.map(e => ({
        id: e.id,
        source: e.source,
        target: e.target,
        data: e.data,
      })),
    };
  }

  /**
   * 反序列化画布数据(加载用)
   */
  function deserializeCanvas(canvasData: any) {
    if (!canvasData?.nodes) return;

    // 清空当前画布
    removeNodes(nodes.value);
    removeEdges(edges.value);

    // 恢复节点
    addNodes(canvasData.nodes.map((n: any) => ({
      ...n,
      type: n.type || 'crew-agent',
    })));

    // 恢复连线
    if (canvasData.edges?.length) {
      addEdges(canvasData.edges);
    }

    // 延迟 fitView
    setTimeout(() => fitView({ padding: 0.2 }), 100);
  }

  /**
   * 从画布状态提取成员列表(保存到 crew_agent 表)
   */
  function extractMembers() {
    return nodes.value
      .filter(n => n.data?.agentId)
      .map(n => ({
        agentId: n.data.agentId,
        role: n.data.role || '',
        canvasPosition: n.position,
        groupName: n.data.groupName || '',
      }));
  }

  return {
    nodes, edges, masterAgentId,
    addAgentNode, removeAgentNode, setMasterAgent,
    serializeCanvas, deserializeCanvas, extractMembers,
    onConnect, onNodeDragStop, fitView,
  };
}

Step 4.4:实现画布 → delegateHints 转换 Hook

文件packages/frontend/src/modules/agent/hooks/crew-orchestration.ts

import type { Edge, Node } from '@vue-flow/core';

/**
 * 从画布连线生成 delegateHints
 * 供主 Agent system prompt 使用
 */
export function canvasToHints(
  nodes: Node[],
  edges: Edge[]
): { hints: string; edges: Array<{ from: string; to: string; type: string; note?: string }> } {
  if (!edges.length) {
    return { hints: '无特定调度建议,请根据任务需求自行安排。', edges: [] };
  }

  const nodeMap = new Map<string, string>();
  nodes.forEach(n => {
    if (n.data?.label) nodeMap.set(n.id, n.data.label);
  });

  // 解析连线
  const edgeInfos = edges.map(e => ({
    from: nodeMap.get(e.source) || e.source,
    to: nodeMap.get(e.target) || e.target,
    type: e.data?.edgeType || 'serial',
    note: e.data?.note || '',
  }));

  // 生成自然语言提示
  const serialEdges = edgeInfos.filter(e => e.type === 'serial');
  const parallelEdges = edgeInfos.filter(e => e.type === 'parallel');

  let hints = '建议执行顺序:\n';

  if (serialEdges.length) {
    // 构建串行链
    const chains: string[] = [];
    serialEdges.forEach(e => {
      const note = e.note ? `${e.note}` : '';
      chains.push(`${e.from}${e.to}${note}`);
    });
    hints += `串行依赖:${chains.join('')}\n`;
  }

  if (parallelEdges.length) {
    const parallel = parallelEdges.map(e => `${e.from}${e.to}`);
    hints += `可并行执行:${parallel.join('')}\n`;
  }

  return { hints, edges: edgeInfos };
}

Step 4.5:实现自定义节点组件

文件packages/frontend/src/modules/agent/components/crew/crew-agent-node.vue

<template>
  <div
    class="crew-agent-node"
    :class="{
      'is-master': data.isMaster,
      'is-running': liveStatus === 'running',
      'is-completed': liveStatus === 'completed',
      'is-failed': liveStatus === 'failed',
    }"
  >
    <!--  Agent 星标 -->
    <div v-if="data.isMaster" class="master-badge">★</div>

    <!-- 分组色带 -->
    <div v-if="data.groupName" class="group-ribbon" :style="{ background: groupColor }">
      {{ data.groupName }}
    </div>

    <!-- Agent 图标 -->
    <div class="node-icon">
      <el-icon v-if="data.icon" :size="28"><component :is="data.icon" /></el-icon>
      <el-icon v-else :size="28"><UserFilled /></el-icon>
    </div>

    <!-- Agent 名称 -->
    <div class="node-label">{{ data.label || data.name }}</div>

    <!-- 角色标签 -->
    <div v-if="data.role" class="node-role">{{ data.role }}</div>

    <!-- 连线锚点 (四边) -->
    <Handle type="source" :position="Position.Right" />
    <Handle type="source" :position="Position.Bottom" />
    <Handle type="target" :position="Position.Left" />
    <Handle type="target" :position="Position.Top" />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { Handle, Position } from '@vue-flow/core';
import { UserFilled } from '@element-plus/icons-vue';

const props = defineProps<{
  data: {
    agentId: number;
    name: string;
    label: string;
    icon?: string;
    description?: string;
    role?: string;
    groupName?: string;
    isMaster?: boolean;
  };
  liveStatus?: string; // 监控页使用
}>();

// 分组颜色(基于名称 hash
const groupColor = computed(() => {
  if (!props.data.groupName) return '';
  const hash = [...props.data.groupName].reduce((acc, c) => acc + c.charCodeAt(0), 0);
  const hue = hash % 360;
  return `hsl(${hue}, 60%, 85%)`;
});
</script>

<style scoped>
.crew-agent-node {
  padding: 12px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 12px;
  background: white;
  min-width: 140px;
  text-align: center;
  position: relative;
  transition: all 0.3s;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.crew-agent-node:hover {
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}

.crew-agent-node.is-master {
  border-color: #e6a23c;
  background: linear-gradient(135deg, #fffbf0, #fff8e6);
}

.crew-agent-node.is-running {
  border-color: #409eff;
  animation: breathe 2s ease-in-out infinite;
}

.crew-agent-node.is-completed {
  border-color: #67c23a;
}

.crew-agent-node.is-failed {
  border-color: #f56c6c;
}

.master-badge {
  position: absolute;
  top: -8px;
  right: -8px;
  width: 24px;
  height: 24px;
  background: #e6a23c;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
}

.group-ribbon {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  padding: 2px 8px;
  font-size: 10px;
  border-radius: 10px 10px 0 0;
  text-align: center;
}

.node-icon { margin-bottom: 6px; }
.node-label { font-weight: 600; font-size: 14px; }
.node-role { font-size: 11px; color: #909399; margin-top: 4px; }

@keyframes breathe {
  0%, 100% { box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); }
  50% { box-shadow: 0 0 0 8px rgba(64, 158, 255, 0); }
}
</style>

Step 4.6:实现自定义连线标签

文件packages/frontend/src/modules/agent/components/crew/crew-edge-label.vue

<template>
  <div class="crew-edge-label" :class="{ 'is-parallel': isParallel }">
    <el-icon :size="14">
      <component :is="isParallel ? 'Refresh' : 'Right'" />
    </el-icon>
    <span v-if="label">{{ label }}</span>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{
  data?: { edgeType?: string; note?: string };
}>();

const isParallel = computed(() => props.data?.edgeType === 'parallel');
const label = computed(() => props.data?.note || '');
</script>

<style scoped>
.crew-edge-label {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  background: white;
  border: 1px solid #ddd;
  border-radius: 10px;
  font-size: 11px;
  white-space: nowrap;
}
.crew-edge-label.is-parallel {
  border-color: #409eff;
  color: #409eff;
}
</style>

Step 4.7:实现左侧 Agent 列表

文件packages/frontend/src/modules/agent/components/crew/crew-sidebar.vue

<template>
  <div class="crew-sidebar">
    <el-input v-model="search" placeholder="搜索 Agent..." clearable prefix-icon="Search" />

    <div class="agent-list">
      <div
        v-for="agent in filteredAgents"
        :key="agent.id"
        class="agent-item"
        :class="{ 'is-member': isMember(agent.id) }"
        draggable="true"
        @dragstart="onDragStart($event, agent)"
      >
        <div class="agent-info">
          <span class="agent-name">{{ agent.label || agent.name }}</span>
          <span class="agent-desc">{{ agent.description }}</span>
        </div>
        <el-icon v-if="isMember(agent.id)" color="#67c23a"><Check /></el-icon>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { Check } from '@element-plus/icons-vue';

const props = defineProps<{
  agents: any[];
  isMember: (id: number) => boolean;
}>();

const emit = defineEmits<{
  (e: 'drop-agent', agent: any, position: { x: number; y: number }): void;
}>();

const search = ref('');

const filteredAgents = computed(() => {
  if (!search.value) return props.agents;
  const q = search.value.toLowerCase();
  return props.agents.filter(a =>
    a.name?.toLowerCase().includes(q) ||
    a.label?.toLowerCase().includes(q) ||
    a.description?.toLowerCase().includes(q)
  );
});

function onDragStart(event: DragEvent, agent: any) {
  event.dataTransfer?.setData('application/crew-agent', JSON.stringify(agent));
}
</script>

<style scoped>
.crew-sidebar {
  width: 240px;
  border-right: 1px solid #eee;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.agent-list { flex: 1; overflow-y: auto; }
.agent-item {
  padding: 10px 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  margin-bottom: 8px;
  cursor: grab;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transition: background 0.2s;
}
.agent-item:hover { background: #f5f7fa; }
.agent-item.is-member { background: #f0f9eb; }
.agent-info { flex: 1; }
.agent-name { font-weight: 500; display: block; }
.agent-desc { font-size: 12px; color: #909399; }
</style>

Step 4.8:实现属性面板

文件packages/frontend/src/modules/agent/components/crew/crew-property-panel.vue

<template>
  <div class="crew-property-panel">
    <!-- 选中节点时 -->
    <template v-if="selectedNode">
      <h4>Agent 属性</h4>
      <el-form label-position="top" size="small">
        <el-form-item label="名称">
          <span>{{ selectedNode.data.label }}</span>
        </el-form-item>
        <el-form-item label="角色描述">
          <el-input
            v-model="selectedNode.data.role"
            type="textarea"
            :rows="3"
            placeholder="描述该 Agent 在集群中的角色..."
          />
        </el-form-item>
        <el-form-item label="分组">
          <el-input v-model="selectedNode.data.groupName" placeholder="如:淘宝组" />
        </el-form-item>
        <el-form-item label="重试次数">
          <el-input-number v-model="selectedNode.data.retryCount" :min="0" :max="5" />
        </el-form-item>
        <el-button type="primary" text @click="$emit('set-master', selectedNode.data.agentId)">
          设为主 Agent
        </el-button>
      </el-form>
    </template>

    <!-- 选中连线时 -->
    <template v-else-if="selectedEdge">
      <h4>连线属性</h4>
      <el-form label-position="top" size="small">
        <el-form-item label="类型">
          <el-radio-group v-model="selectedEdge.data.edgeType">
            <el-radio value="serial">串行依赖</el-radio>
            <el-radio value="parallel">并行建议</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="标注">
          <el-input v-model="selectedEdge.data.note" placeholder="如需要先登录获取cookie" />
        </el-form-item>
      </el-form>
    </template>

    <!-- 未选中时全局配置 -->
    <template v-else>
      <h4>集群配置</h4>
      <el-form v-if="crew" label-position="top" size="small">
        <el-form-item label="集群名称">
          <el-input v-model="crew.label" />
        </el-form-item>
        <el-form-item label="描述">
          <el-input v-model="crew.description" type="textarea" :rows="2" />
        </el-form-item>
        <el-form-item label="最大并发数">
          <el-input-number v-model="crew.maxConcurrent" :min="1" :max="10" />
        </el-form-item>
        <el-form-item label="默认超时(秒)">
          <el-input-number v-model="crew.taskTimeout" :min="60" :max="3600" :step="60" />
        </el-form-item>
        <!-- 触发配置子组件 -->
        <crew-trigger-config v-model="crew.triggerConfig" />
      </el-form>
    </template>
  </div>
</template>

<script setup lang="ts">
import CrewTriggerConfig from './crew-trigger-config.vue';

defineProps<{
  selectedNode?: any;
  selectedEdge?: any;
  crew?: any;
}>();

defineEmits<{
  (e: 'set-master', agentId: number): void;
}>();
</script>

<style scoped>
.crew-property-panel {
  border-top: 1px solid #eee;
  padding: 12px 16px;
  max-height: 280px;
  overflow-y: auto;
}
h4 { margin: 0 0 12px; font-size: 14px; }
</style>

Step 4.9:实现触发配置子组件

文件packages/frontend/src/modules/agent/components/crew/crew-trigger-config.vue

<template>
  <div class="trigger-config">
    <el-divider content-position="left">触发方式</el-divider>

    <el-form-item label="手动触发">
      <el-switch v-model="config.manual" />
    </el-form-item>

    <el-form-item label="定时触发">
      <el-switch v-model="config.cron.enabled" />
    </el-form-item>
    <template v-if="config.cron?.enabled">
      <el-form-item label="Cron 表达式">
        <el-input v-model="config.cron.expression" placeholder="0 9 * * *" />
      </el-form-item>
      <el-form-item label="时区">
        <el-select v-model="config.cron.timezone">
          <el-option value="Asia/Shanghai" label="Asia/Shanghai" />
          <el-option value="UTC" label="UTC" />
        </el-select>
      </el-form-item>
    </template>
  </div>
</template>

<script setup lang="ts">
import { reactive, watch } from 'vue';

const props = defineProps<{ modelValue?: any }>();
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>();

const config = reactive({
  manual: true,
  cron: { enabled: false, expression: '0 9 * * *', timezone: 'Asia/Shanghai' },
  ...props.modelValue,
});

watch(config, (v) => emit('update:modelValue', { ...v }), { deep: true });
</script>

Step 4.10:实现编排页主视图

文件packages/frontend/src/modules/agent/views/crew-editor.vue

<template>
  <div class="crew-editor">
    <!-- 顶部工具栏 -->
    <div class="toolbar">
      <el-select v-model="selectedCrewId" placeholder="选择集群" @change="onCrewChange">
        <el-option
          v-for="c in crewStore.crewList"
          :key="c.id" :label="c.label" :value="c.id"
        />
      </el-select>
      <el-button @click="onNewCrew">+ 新建集群</el-button>
      <el-divider direction="vertical" />
      <el-button type="primary" @click="onSave" :loading="saving">保存</el-button>
      <el-button
        v-if="crewStore.currentCrew?.status === 0"
        type="success" @click="onPublish"
      >发布</el-button>
      <el-button
        v-else-if="crewStore.currentCrew?.status === 1"
        type="warning" @click="onUnpublish"
      >取消发布</el-button>
      <el-button @click="onTestRun" :disabled="crewStore.currentCrew?.status !== 1">
        试运行
      </el-button>
    </div>

    <!-- 主体左侧栏 + 画布 -->
    <div class="main-area">
      <crew-sidebar
        :agents="crewStore.availableAgents"
        :is-member="crewStore.isMember"
      />

      <div class="canvas-container" @drop="onDrop" @dragover.prevent>
        <VueFlow
          :nodes="canvasHook.nodes.value"
          :edges="canvasHook.edges.value"
          @node-click="onNodeClick"
          @edge-click="onEdgeClick"
          @pane-click="onPaneClick"
          @connect="onConnect"
        >
          <template #node-crew-agent="{ data }">
            <crew-agent-node :data="data" />
          </template>

          <MiniMap />
          <Controls />
          <Background />
        </VueFlow>
      </div>
    </div>

    <!-- 底部属性面板 -->
    <crew-property-panel
      :selected-node="selectedNode"
      :selected-edge="selectedEdge"
      :crew="crewStore.currentCrew"
      @set-master="onSetMaster"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { Controls } from '@vue-flow/controls';
import { Background } from '@vue-flow/background';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/controls/dist/style.css';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useCrewStore } from '../store/crew';
import { useCrewCanvas } from '../hooks/crew-canvas';
import { canvasToHints } from '../hooks/crew-orchestration';
import CrewSidebar from '../components/crew/crew-sidebar.vue';
import CrewAgentNode from '../components/crew/crew-agent-node.vue';
import CrewPropertyPanel from '../components/crew/crew-property-panel.vue';

const router = useRouter();
const crewStore = useCrewStore();
const canvasHook = useCrewCanvas();

const selectedCrewId = ref<number | null>(null);
const selectedNode = ref<any>(null);
const selectedEdge = ref<any>(null);
const saving = ref(false);

onMounted(async () => {
  await crewStore.loadCrewList();
  await crewStore.loadAvailableAgents();
});

async function onCrewChange(crewId: number) {
  const detail = await crewStore.loadCrewDetail(crewId);
  if (detail?.canvasData) {
    canvasHook.deserializeCanvas(detail.canvasData);
  }
  if (detail?.masterAgentId) {
    canvasHook.setMasterAgent(detail.masterAgentId);
  }
}

function onNodeClick({ node }: any) {
  selectedNode.value = node;
  selectedEdge.value = null;
}

function onEdgeClick({ edge }: any) {
  selectedEdge.value = edge;
  selectedNode.value = null;
  // 确保 edge.data 存在
  if (!edge.data) edge.data = { edgeType: 'serial', note: '' };
}

function onPaneClick() {
  selectedNode.value = null;
  selectedEdge.value = null;
}

function onConnect(params: any) {
  canvasHook.edges.value.push({
    ...params,
    id: `e-${params.source}-${params.target}`,
    data: { edgeType: 'serial', note: '' },
  });
}

function onDrop(event: DragEvent) {
  const data = event.dataTransfer?.getData('application/crew-agent');
  if (!data) return;
  const agent = JSON.parse(data);
  const rect = (event.target as HTMLElement).getBoundingClientRect();
  canvasHook.addAgentNode(agent, {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  });
}

function onSetMaster(agentId: number) {
  canvasHook.setMasterAgent(agentId);
  if (crewStore.currentCrew) {
    crewStore.currentCrew.masterAgentId = agentId;
  }
}

async function onSave() {
  if (!selectedCrewId.value) return;
  saving.value = true;
  try {
    const canvasData = canvasHook.serializeCanvas();
    const members = canvasHook.extractMembers();
    const hints = canvasToHints(canvasHook.nodes.value, canvasHook.edges.value);
    await crewStore.saveCanvas(selectedCrewId.value, canvasData, members, hints);
    ElMessage.success('保存成功');
  } finally {
    saving.value = false;
  }
}

async function onPublish() {
  if (!selectedCrewId.value) return;
  await crewStore.publish(selectedCrewId.value);
  ElMessage.success('发布成功');
}

async function onUnpublish() {
  if (!selectedCrewId.value) return;
  await crewStore.unpublish(selectedCrewId.value);
  ElMessage.success('已取消发布');
}

async function onNewCrew() {
  // 弹出创建对话框(简化版直接创建)
  const crew = await crewStore.createCrew({
    name: `crew-${Date.now()}`,
    label: '新建集群',
    status: 0,
    triggerConfig: { manual: true, cron: { enabled: false, expression: '', timezone: '' } },
  });
  selectedCrewId.value = crew.id;
}

function onTestRun() {
  router.push(`/agent/crew-monitor?crewId=${selectedCrewId.value}&autoRun=1`);
}
</script>

<style scoped>
.crew-editor {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  border-bottom: 1px solid #eee;
}
.main-area {
  flex: 1;
  display: flex;
  overflow: hidden;
}
.canvas-container {
  flex: 1;
  position: relative;
}
</style>

Step 4.11:实现右键菜单组件

文件packages/frontend/src/modules/agent/components/crew/crew-context-menu.vue

<template>
  <div
    v-if="visible"
    class="crew-context-menu"
    :style="{ left: position.x + 'px', top: position.y + 'px' }"
  >
    <template v-if="targetType === 'node'">
      <div class="menu-item" @click="$emit('set-master')">设为主 Agent</div>
      <div class="menu-item" @click="$emit('edit-agent')">编辑 Agent</div>
      <div class="menu-item danger" @click="$emit('remove-node')">移出集群</div>
    </template>
    <template v-else-if="targetType === 'edge'">
      <div class="menu-item danger" @click="$emit('remove-edge')">删除连线</div>
    </template>
    <template v-else>
      <div class="menu-item" @click="$emit('auto-layout')">自动布局</div>
      <div class="menu-item" v-if="hasSelection" @click="$emit('group-selection')">设为分组</div>
    </template>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  visible: boolean;
  position: { x: number; y: number };
  targetType: 'node' | 'edge' | 'pane';
  hasSelection?: boolean;
}>();

defineEmits<{
  (e: 'set-master'): void;
  (e: 'edit-agent'): void;
  (e: 'remove-node'): void;
  (e: 'remove-edge'): void;
  (e: 'auto-layout'): void;
  (e: 'group-selection'): void;
}>();
</script>

<style scoped>
.crew-context-menu {
  position: fixed;
  background: white;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  padding: 4px 0;
  min-width: 140px;
  z-index: 1000;
}
.menu-item {
  padding: 8px 16px;
  cursor: pointer;
  font-size: 13px;
}
.menu-item:hover { background: #f5f7fa; }
.menu-item.danger { color: #f56c6c; }
</style>

crew-editor.vue 中集成右键菜单:在 <VueFlow> 上监听 @node-context-menu@edge-context-menu@pane-context-menu 事件,控制菜单显示/隐藏和位置。

Step 4.12实现自动布局elkjs

hooks/crew-canvas.ts 中新增 autoLayout 方法:

import ELK from 'elkjs/lib/elk.bundled.js';

const elk = new ELK();

/**
 * 使用 elkjs 自动布局
 */
async function autoLayout() {
  const graph = {
    id: 'root',
    layoutOptions: {
      'elk.algorithm': 'layered',
      'elk.direction': 'RIGHT',
      'elk.spacing.nodeNode': '80',
      'elk.layered.spacing.nodeNodeBetweenLayers': '120',
    },
    children: nodes.value.map(n => ({
      id: n.id,
      width: 160,
      height: 80,
    })),
    edges: edges.value.map(e => ({
      id: e.id,
      sources: [e.source],
      targets: [e.target],
    })),
  };

  const layout = await elk.layout(graph);

  // 应用布局结果到节点位置
  layout.children?.forEach(child => {
    const node = nodes.value.find(n => n.id === child.id);
    if (node && child.x !== undefined && child.y !== undefined) {
      node.position = { x: child.x, y: child.y };
    }
  });

  setTimeout(() => fitView({ padding: 0.2 }), 100);
}

useCrewCanvas 的返回值中导出 autoLayout,右键菜单的"自动布局"调用此方法。

Step 4.13:实现框选分组

crew-editor.vue 中利用 Vue Flow 的 selectionMode@selection-end 事件实现框选:

// 在 crew-editor.vue 的 <VueFlow> 上添加:
// selection-mode="box"
// @selection-end="onSelectionEnd"

function onSelectionEnd({ nodes: selectedNodes }: any) {
  if (selectedNodes.length < 2) return;
  // 弹出分组名输入框
  ElMessageBox.prompt('请输入分组名', '设为分组', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
  }).then(({ value }) => {
    selectedNodes.forEach((n: any) => {
      if (n.data) n.data.groupName = value;
    });
  }).catch(() => {});
}

Step 4.14:验证编排页

验证清单

  • 路由 /agent/crew-editor 正常访问
  • 左侧 Agent 列表正确加载
  • 拖拽 Agent 到画布,节点正常渲染
  • 节点间可拖拽连线
  • 右键节点:设为主 Agent / 移出集群 / 编辑 Agent
  • 右键连线:删除连线
  • 右键空白自动布局elkjs
  • 框选多节点 → 右键"设为分组"
  • 主 Agent 显示金色边框 + 星标
  • 点击节点/连线/空白,底部属性面板正确切换
  • 属性面板:角色描述、分组名、重试次数均可编辑
  • 连线类型(串行/并行)可切换
  • 保存功能正常canvasData + members + delegateHints 持久化)
  • 加载已保存的集群,画布正确恢复
  • 发布/取消发布功能正常
  • MiniMap / Controls / Background 正常显示

Step 4.15:提交代码

git add packages/frontend/src/modules/agent/config.ts
git add packages/frontend/src/modules/agent/store/crew.ts
git add packages/frontend/src/modules/agent/hooks/crew-canvas.ts
git add packages/frontend/src/modules/agent/hooks/crew-orchestration.ts
git add packages/frontend/src/modules/agent/views/crew-editor.vue
git add packages/frontend/src/modules/agent/components/crew/
git commit -m "feat(crew): 实现编排页 - Vue Flow 画布 + Agent 侧栏 + 属性面板"

Phase 4 验收

  • 前端路由注册完成
  • Pinia Store 完成
  • 画布操作 Hook 完成(含 elkjs 自动布局)
  • delegateHints 转换 Hook 完成
  • 自定义节点/连线组件完成
  • 右键菜单组件完成(节点/连线/画布三种模式)
  • 侧栏/属性面板(含重试次数)/触发配置组件完成
  • 框选分组功能完成
  • 编排页主视图完成
  • 拖拽添加 Agent → 连线 → 配置角色 → 保存 全流程验证通过
  • 代码已提交

Phase 5前端监控页

目标:实现运行列表 + 运行详情(实时画布 + 任务时间线 + 日志面板) 预估2.5 天 依赖Phase 4 完成(画布组件可复用)

Step 5.1:实现 WebSocket 监控 Hook

文件packages/frontend/src/modules/agent/hooks/crew-monitor.ts

import { ref, onUnmounted } from 'vue';
import { io, Socket } from 'socket.io-client';
import { useStore } from '../../../store';
import config from '/@/config';

/**
 * Crew 监控 WebSocket Hook
 * 连接 /crew 命名空间,订阅实时事件
 */
export function useCrewMonitor() {
  const socket = ref<Socket | null>(null);
  const { user } = useStore();

  // 实时状态
  const runStatusMap = ref<Map<number, any>>(new Map());
  const taskStatusMap = ref<Map<number, any>>(new Map());
  const logs = ref<any[]>([]);
  const escalations = ref<any[]>([]);

  /** 连接 WebSocket */
  function connect() {
    const wsUrl = (config as any).host || 'http://127.0.0.1:8001';
    socket.value = io(`${wsUrl}/crew`, {
      auth: { token: user.token, isAdmin: true },
      transports: ['websocket'],
    });

    socket.value.on('crew:run:status', (data: any) => {
      runStatusMap.value.set(data.runId, data);
    });

    socket.value.on('crew:task:status', (data: any) => {
      taskStatusMap.value.set(data.taskId, data);
    });

    socket.value.on('crew:log', (data: any) => {
      logs.value.push(data);
      // 限制日志条数,防止内存溢出
      if (logs.value.length > 5000) logs.value.splice(0, 1000);
    });

    socket.value.on('crew:escalation', (data: any) => {
      escalations.value.push(data);
    });
  }

  /** 触发运行 */
  function triggerRun(crewId: number, triggerInput?: string) {
    socket.value?.emit('crew:trigger', { crewId, triggerInput });
  }

  /** 控制运行 */
  function controlRun(runId: number, action: string, userMessage?: string) {
    socket.value?.emit('crew:control', { runId, action, userMessage });
  }

  /** 获取某次运行的日志 */
  function getRunLogs(runId: number, agentName?: string) {
    return logs.value.filter(l => {
      if (agentName) return l.agentName === agentName;
      return true; // 返回所有日志
    });
  }

  /** 获取某次运行的任务状态列表 */
  function getRunTasks(runId: number) {
    return [...taskStatusMap.value.values()].filter(t => t.runId === runId);
  }

  /** 清理日志 */
  function clearLogs() {
    logs.value = [];
  }

  /** 断开连接 */
  function disconnect() {
    socket.value?.disconnect();
    socket.value = null;
  }

  onUnmounted(() => disconnect());

  return {
    socket, runStatusMap, taskStatusMap, logs, escalations,
    connect, disconnect, triggerRun, controlRun,
    getRunLogs, getRunTasks, clearLogs,
  };
}

Step 5.2:实现运行记录表格

文件packages/frontend/src/modules/agent/components/crew/crew-run-table.vue

<template>
  <div class="crew-run-table">
    <!-- 筛选栏 -->
    <div class="filter-bar">
      <el-select v-model="filters.crewId" placeholder="集群" clearable>
        <el-option v-for="c in crewList" :key="c.id" :label="c.label" :value="c.id" />
      </el-select>
      <el-select v-model="filters.status" placeholder="状态" clearable>
        <el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
      </el-select>
      <el-date-picker
        v-model="filters.timeRange"
        type="daterange"
        range-separator=""
        start-placeholder="开始日期"
        end-placeholder="结束日期"
        value-format="YYYY-MM-DD"
      />
      <el-button @click="loadData">刷新</el-button>
    </div>

    <!-- 表格 -->
    <el-table :data="tableData" @row-click="onRowClick" highlight-current-row>
      <el-table-column prop="crewId" label="集群" width="120">
        <template #default="{ row }">
          {{ getCrewLabel(row.crewId) }}
        </template>
      </el-table-column>
      <el-table-column prop="triggerType" label="触发方式" width="100" />
      <el-table-column prop="status" label="状态" width="100">
        <template #default="{ row }">
          <span class="status-dot" :class="'status-' + row.status" />
          {{ statusLabel(row.status) }}
        </template>
      </el-table-column>
      <el-table-column prop="startTime" label="开始时间" width="180" />
      <el-table-column label="耗时" width="100">
        <template #default="{ row }">
          {{ calcDuration(row.startTime, row.endTime) }}
        </template>
      </el-table-column>
      <el-table-column label="进度" width="80">
        <template #default="{ row }">
          {{ row.progress || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="Token" width="120">
        <template #default="{ row }">
          {{ row.tokenUsage ? `${row.tokenUsage.inputTokens + row.tokenUsage.outputTokens}` : '-' }}
        </template>
      </el-table-column>
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button
            v-if="row.status === 'running'"
            size="small" type="warning" text
            @click.stop="$emit('control', row.id, 'stop')"
          >终止</el-button>
          <el-button
            v-if="row.status === 'paused'"
            size="small" type="success" text
            @click.stop="$emit('resume', row.id)"
          >恢复</el-button>
          <el-button
            size="small" text
            @click.stop="$emit('detail', row.id)"
          >详情</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useCool } from '/@/cool';

const props = defineProps<{ crewList: any[] }>();
const emit = defineEmits<{
  (e: 'detail', runId: number): void;
  (e: 'control', runId: number, action: string): void;
  (e: 'resume', runId: number): void;
  (e: 'row-click', row: any): void;
}>();

const { service } = useCool();
const tableData = ref<any[]>([]);
const filters = reactive({ crewId: null as number | null, status: '', timeRange: null as string[] | null });

const statusOptions = [
  { value: 'pending', label: '等待中' },
  { value: 'running', label: '运行中' },
  { value: 'paused', label: '已暂停' },
  { value: 'completed', label: '已完成' },
  { value: 'failed', label: '已失败' },
];

function statusLabel(s: string) {
  return statusOptions.find(o => o.value === s)?.label || s;
}

function getCrewLabel(crewId: number) {
  return props.crewList.find(c => c.id === crewId)?.label || crewId;
}

function calcDuration(start: string, end: string) {
  if (!start) return '-';
  const s = new Date(start).getTime();
  const e = end ? new Date(end).getTime() : Date.now();
  const sec = Math.round((e - s) / 1000);
  return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${sec % 60}s`;
}

async function loadData() {
  const params: any = {};
  if (filters.crewId) params.crewId = filters.crewId;
  if (filters.status) params.status = filters.status;
  const res = await service.netaclaw.crewRun.page({ ...params, size: 50, page: 1 });
  tableData.value = res?.list || [];
}

function onRowClick(row: any) {
  emit('row-click', row);
}

/** 外部调用:实时更新某行状态 */
function updateRowStatus(runId: number, data: any) {
  const row = tableData.value.find(r => r.id === runId);
  if (row) Object.assign(row, data);
}

onMounted(() => loadData());

defineExpose({ loadData, updateRowStatus });
</script>

<style scoped>
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.status-dot {
  display: inline-block;
  width: 8px; height: 8px;
  border-radius: 50%;
  margin-right: 4px;
}
.status-running { background: #67c23a; }
.status-completed { background: #409eff; }
.status-paused { background: #e6a23c; }
.status-failed { background: #f56c6c; }
.status-pending { background: #c0c4cc; }
</style>

Step 5.3:实现任务时间线

文件packages/frontend/src/modules/agent/components/crew/crew-timeline.vue

<template>
  <div class="crew-timeline">
    <h4>任务时间线</h4>
    <el-timeline>
      <el-timeline-item
        v-for="task in sortedTasks"
        :key="task.taskId"
        :type="timelineType(task.status)"
        :timestamp="task.startTime || task.timestamp"
        placement="top"
      >
        <div class="task-item" @click="$emit('select-task', task)">
          <span class="task-agent">{{ task.agentName }}</span>
          <span class="task-status" :class="'status-' + task.status">
            {{ statusIcon(task.status) }} {{ task.status }}
          </span>
          <p class="task-desc">{{ task.taskDescription || task.result?.agent }}</p>
          <span v-if="task.duration" class="task-duration">{{ task.duration }}</span>
        </div>
      </el-timeline-item>
    </el-timeline>

    <div v-if="!sortedTasks.length" class="empty">暂无任务记录</div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{ tasks: any[] }>();
defineEmits<{ (e: 'select-task', task: any): void }>();

const sortedTasks = computed(() =>
  [...props.tasks].sort((a, b) => {
    const ta = a.startTime || a.timestamp || '';
    const tb = b.startTime || b.timestamp || '';
    return ta.localeCompare(tb);
  })
);

function timelineType(status: string) {
  const map: Record<string, string> = {
    running: 'primary', completed: 'success',
    failed: 'danger', pending: 'info', retrying: 'warning',
  };
  return map[status] || 'info';
}

function statusIcon(status: string) {
  const map: Record<string, string> = {
    running: '⏳', completed: '✅', failed: '❌', pending: '⏸', retrying: '🔄',
  };
  return map[status] || '•';
}
</script>

<style scoped>
.crew-timeline { padding: 12px; overflow-y: auto; }
h4 { margin: 0 0 12px; font-size: 14px; }
.task-item { cursor: pointer; }
.task-agent { font-weight: 600; margin-right: 8px; }
.task-status { font-size: 12px; }
.task-desc { font-size: 12px; color: #606266; margin: 4px 0 0; }
.task-duration { font-size: 11px; color: #909399; }
.empty { text-align: center; color: #c0c4cc; padding: 40px; }
</style>

Step 5.4:实现日志面板

文件packages/frontend/src/modules/agent/components/crew/crew-log-panel.vue

<template>
  <div class="crew-log-panel">
    <!-- Tab 切换 Agent / 各子 Agent -->
    <el-tabs v-model="activeTab">
      <el-tab-pane label="全部日志" name="all" />
      <el-tab-pane
        v-for="agent in agentNames"
        :key="agent" :label="agent" :name="agent"
      />
    </el-tabs>

    <!-- 搜索过滤 -->
    <el-input
      v-model="searchText" placeholder="搜索日志..."
      clearable size="small" style="margin-bottom: 8px"
    />

    <!-- 日志流 -->
    <div ref="logContainer" class="log-container">
      <div
        v-for="(log, i) in filteredLogs"
        :key="i"
        class="log-line"
        :class="'level-' + log.level"
      >
        <span class="log-time">{{ formatTime(log.timestamp) }}</span>
        <span class="log-agent">[{{ log.agentName }}]</span>
        <span class="log-message">{{ log.message }}</span>
      </div>
      <div v-if="!filteredLogs.length" class="empty">等待日志...</div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';

const props = defineProps<{ logs: any[] }>();

const activeTab = ref('all');
const searchText = ref('');
const logContainer = ref<HTMLElement>();

// 提取所有出现过的 Agent 名称
const agentNames = computed(() => {
  const names = new Set<string>();
  props.logs.forEach(l => { if (l.agentName) names.add(l.agentName); });
  return [...names];
});

const filteredLogs = computed(() => {
  let result = props.logs;
  if (activeTab.value !== 'all') {
    result = result.filter(l => l.agentName === activeTab.value);
  }
  if (searchText.value) {
    const q = searchText.value.toLowerCase();
    result = result.filter(l => l.message?.toLowerCase().includes(q));
  }
  return result;
});

function formatTime(ts: string) {
  if (!ts) return '';
  return new Date(ts).toLocaleTimeString();
}

// 自动滚动到底部
watch(() => props.logs.length, async () => {
  await nextTick();
  if (logContainer.value) {
    logContainer.value.scrollTop = logContainer.value.scrollHeight;
  }
});
</script>

<style scoped>
.crew-log-panel { display: flex; flex-direction: column; height: 100%; }
.log-container {
  flex: 1; overflow-y: auto;
  font-family: 'Consolas', 'Monaco', monospace;
  font-size: 12px; line-height: 1.6;
  background: #1e1e1e; color: #d4d4d4;
  padding: 8px 12px; border-radius: 6px;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-time { color: #6a9955; margin-right: 8px; }
.log-agent { color: #569cd6; margin-right: 8px; }
.level-error .log-message { color: #f44747; }
.level-tool .log-message { color: #dcdcaa; }
.empty { text-align: center; color: #666; padding: 40px; }
</style>

Step 5.5:实现运行详情容器

文件packages/frontend/src/modules/agent/components/crew/crew-run-detail.vue

<template>
  <div class="crew-run-detail">
    <!-- 三栏布局 -->
    <div class="detail-columns">
      <!-- 实时画布 -->
      <div class="col-canvas">
        <VueFlow
          :nodes="canvasNodes"
          :edges="canvasEdges"
          :nodes-draggable="false"
          :nodes-connectable="false"
          :edges-updatable="false"
          fit-view-on-init
          :class-func="edgeClassFunc"
        >
          <template #node-crew-agent="{ data }">
            <crew-agent-node :data="data" :live-status="getNodeStatus(data.agentId)" />
          </template>
          <MiniMap />
        </VueFlow>
      </div>

      <!-- 任务时间线 -->
      <div class="col-timeline">
        <crew-timeline
          :tasks="tasks"
          @select-task="onSelectTask"
        />
      </div>

      <!-- 日志面板 -->
      <div class="col-logs">
        <crew-log-panel :logs="logs" />
      </div>
    </div>

    <!-- 升级处理对话框 -->
    <el-dialog v-model="showEscalation" title="需要人工处理" width="500px">
      <p>{{ currentEscalation?.reason }}</p>
      <el-input
        v-model="resumeMessage"
        type="textarea" :rows="4"
        placeholder="请输入处理意见..."
      />
      <template #footer>
        <el-button @click="showEscalation = false">取消</el-button>
        <el-button type="primary" @click="onResume">恢复执行</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/minimap/dist/style.css';
import CrewAgentNode from './crew-agent-node.vue';
import CrewTimeline from './crew-timeline.vue';
import CrewLogPanel from './crew-log-panel.vue';

const props = defineProps<{
  run: any;
  canvasData: any;
  tasks: any[];
  logs: any[];
  escalations: any[];
}>();

const emit = defineEmits<{
  (e: 'resume', runId: number, message: string): void;
}>();

// 画布数据(只读模式)
const canvasNodes = computed(() => props.canvasData?.nodes || []);
const canvasEdges = computed(() => props.canvasData?.edges || []);

// 节点实时状态映射
function getNodeStatus(agentId: number): string {
  const task = props.tasks.find(t => t.agentId === agentId);
  return task?.status || 'idle';
}

// 活跃连线动画:当 source 节点正在运行时,连线显示流动虚线
function edgeClassFunc(edge: any) {
  const sourceNode = canvasNodes.value.find((n: any) => n.id === edge.source);
  if (sourceNode?.data?.agentId) {
    const status = getNodeStatus(sourceNode.data.agentId);
    if (status === 'running') return 'is-active';
  }
  return '';
}

// 升级处理
const showEscalation = ref(false);
const currentEscalation = ref<any>(null);
const resumeMessage = ref('');

watch(() => props.escalations.length, () => {
  const latest = props.escalations[props.escalations.length - 1];
  if (latest && latest.runId === props.run?.id) {
    currentEscalation.value = latest;
    showEscalation.value = true;
  }
});

function onResume() {
  if (!props.run?.id) return;
  emit('resume', props.run.id, resumeMessage.value);
  showEscalation.value = false;
  resumeMessage.value = '';
}

function onSelectTask(task: any) {
  // 可以滚动日志到对应位置
}
</script>

<style scoped>
.crew-run-detail { height: 100%; }
.detail-columns {
  display: flex; height: 100%; gap: 1px;
  background: #eee;
}
.col-canvas { flex: 2; background: white; min-height: 300px; }
.col-timeline { flex: 1; background: white; overflow-y: auto; }
.col-logs { flex: 1.5; background: white; }

/* 活跃连线流动虚线动画 */
:deep(.vue-flow__edge.is-active path) {
  stroke-dasharray: 8 4;
  animation: flow-dash 0.6s linear infinite;
}
@keyframes flow-dash {
  to { stroke-dashoffset: -12; }
}
</style>

Step 5.6:实现监控页主视图

文件packages/frontend/src/modules/agent/views/crew-monitor.vue

<template>
  <div class="crew-monitor">
    <!-- 上半部运行列表 -->
    <div class="run-list-section">
      <crew-run-table
        ref="runTableRef"
        :crew-list="crewStore.crewList"
        @row-click="onRowClick"
        @control="onControl"
        @resume="onResumeClick"
        @detail="onDetailClick"
      />
    </div>

    <!-- 下半部运行详情 -->
    <div v-if="selectedRun" class="run-detail-section">
      <crew-run-detail
        :run="selectedRun"
        :canvas-data="selectedCanvasData"
        :tasks="monitorHook.getRunTasks(selectedRun.id)"
        :logs="monitorHook.logs.value"
        :escalations="monitorHook.escalations.value"
        @resume="onResume"
      />
    </div>
    <div v-else class="empty-detail">
      <p>点击运行记录查看详情</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useCrewStore } from '../store/crew';
import { useCrewMonitor } from '../hooks/crew-monitor';
import CrewRunTable from '../components/crew/crew-run-table.vue';
import CrewRunDetail from '../components/crew/crew-run-detail.vue';

const route = useRoute();
const crewStore = useCrewStore();
const monitorHook = useCrewMonitor();

const runTableRef = ref<InstanceType<typeof CrewRunTable>>();
const selectedRun = ref<any>(null);
const selectedCanvasData = ref<any>(null);

onMounted(async () => {
  await crewStore.loadCrewList();
  monitorHook.connect();

  // 监听实时状态更新
  monitorHook.runStatusMap.value.forEach((data, runId) => {
    runTableRef.value?.updateRowStatus(runId, data);
  });

  // 如果从编排页跳转过来,自动触发运行
  const autoRunCrewId = route.query.crewId;
  if (route.query.autoRun === '1' && autoRunCrewId) {
    monitorHook.triggerRun(Number(autoRunCrewId));
  }
});

async function onRowClick(row: any) {
  selectedRun.value = row;
  // 加载该集群的画布数据
  const detail = await crewStore.loadCrewDetail(row.crewId);
  selectedCanvasData.value = detail?.canvasData;
  monitorHook.clearLogs();
}

function onDetailClick(runId: number) {
  // 同 onRowClick通过 runId 查找行数据
}

function onControl(runId: number, action: string) {
  monitorHook.controlRun(runId, action);
}

function onResumeClick(runId: number) {
  // 打开恢复对话框(由 crew-run-detail 内部处理)
  selectedRun.value = { ...selectedRun.value, id: runId };
}

function onResume(runId: number, message: string) {
  monitorHook.controlRun(runId, 'resume', message);
}
</script>

<style scoped>
.crew-monitor {
  display: flex; flex-direction: column; height: 100%;
}
.run-list-section {
  padding: 16px;
  border-bottom: 1px solid #eee;
  max-height: 40%;
  overflow-y: auto;
}
.run-detail-section { flex: 1; overflow: hidden; }
.empty-detail {
  flex: 1; display: flex; align-items: center;
  justify-content: center; color: #c0c4cc;
}
</style>

Step 5.7:验证监控页

验证清单

  • 路由 /agent/crew-monitor 正常访问
  • 运行记录表格正确加载,筛选功能正常
  • 状态颜色圆点正确显示
  • WebSocket 连接 /crew 命名空间成功
  • 触发运行后,表格实时更新状态
  • 点击行展开详情,三栏布局正确渲染
  • 实时画布节点状态随任务进度变化idle→running→completed/failed
  • 任务时间线:按时间排序,状态图标正确
  • 日志面板实时滚动Tab 切换过滤,搜索功能正常
  • 升级处理escalate 触发后弹出对话框,输入意见后恢复执行
  • 终止运行功能正常

Step 5.8:提交代码

git add packages/frontend/src/modules/agent/hooks/crew-monitor.ts
git add packages/frontend/src/modules/agent/views/crew-monitor.vue
git add packages/frontend/src/modules/agent/components/crew/crew-run-table.vue
git add packages/frontend/src/modules/agent/components/crew/crew-run-detail.vue
git add packages/frontend/src/modules/agent/components/crew/crew-timeline.vue
git add packages/frontend/src/modules/agent/components/crew/crew-log-panel.vue
git commit -m "feat(crew): 实现监控页 - 运行列表 + 实时画布 + 时间线 + 日志面板"

Phase 5 验收

  • WebSocket 监控 Hook 完成
  • 运行记录表格完成(含实时更新)
  • 任务时间线完成
  • 日志面板完成Tab 切换 + 搜索 + 自动滚动)
  • 运行详情三栏布局完成(画布 + 时间线 + 日志)
  • 升级处理对话框完成
  • 监控页主视图完成
  • 端到端实时监控验证通过
  • 代码已提交

Phase 6权限集成 + 端到端测试

目标:配置菜单权限、数据权限,并完成端到端全流程测试 预估1 天 依赖Phase 5 完成

Step 6.1:配置菜单权限

通过数据库或 Cool Admin 管理后台插入菜单和权限记录:

-- 1. 查询 Agent 管理父菜单 ID
SELECT id FROM base_sys_menu WHERE router = '/agent' OR name = 'Agent 管理';

-- 2. 插入 Agent 编排页菜单
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms, isShow)
VALUES ('Agent 编排', '/agent/crew-editor', @parentId, 5, 1, 'icon-crew', NULL, 1);

-- 3. 插入运行监控页菜单
INSERT INTO base_sys_menu (name, router, parentId, orderNum, type, icon, perms, isShow)
VALUES ('运行监控', '/agent/crew-monitor', @parentId, 6, 1, 'icon-monitor', NULL, 1);

-- 4. 插入操作权限按钮(挂在编排页菜单下)
SET @crewMenuId = LAST_INSERT_ID(); -- 需要调整
INSERT INTO base_sys_menu (name, parentId, type, perms) VALUES
  ('创建集群', @crewMenuId, 2, 'agent:crew:create'),
  ('编辑集群', @crewMenuId, 2, 'agent:crew:edit'),
  ('发布集群', @crewMenuId, 2, 'agent:crew:publish'),
  ('触发运行', @crewMenuId, 2, 'agent:crew:trigger'),
  ('控制运行', @crewMenuId, 2, 'agent:crew:control'),
  ('查看监控', @crewMenuId, 2, 'agent:crew:view');

实际操作时建议通过 Cool Admin 的菜单管理页面 UI 操作,更安全。

Step 6.2:数据权限(租户隔离)

netaclaw_crew 继承 BaseEntity 已自带 tenantId 字段。Cool Admin 的 @CoolController 自动在查询时注入租户过滤条件,无需额外代码。

验证点

  • 不同租户创建的集群互不可见
  • 管理员可查看所有集群

Step 6.3Controller 权限装饰

如需更细粒度的权限控制,在 Controller 方法上添加装饰器:

// 示例crew_trigger.ts 中的 start 方法
@Post('/start')
// Cool Admin 权限检查由框架自动处理,基于菜单 perms 配置
async start(@Body() body: { crewId: number; triggerInput?: string }) {
  // ...
}

Cool Admin 的权限机制是:前端请求携带 token → 后端中间件解析用户角色 → 校验用户是否有该路由对应的菜单权限。菜单权限在 Step 6.1 已配置。

Step 6.4:端到端全流程测试

测试场景 1基本编排 + 运行

前置条件:已有 3 个已发布 Agent登录Agent、数据获取Agent、上架Agent

1. 进入编排页 → 新建集群"淘宝运营"
2. 从左侧拖入 3 个 Agent
3. 设置"登录Agent"为主 Agent
4. 连线登录Agent → 数据获取Agent → 上架Agent串行
5. 给每个 Agent 配置角色描述
6. 保存 → 发布
7. 点击"试运行" → 跳转监控页
8. 观察:
   - 运行列表新增一行status=running
   - 实时画布节点状态变化
   - 时间线记录各任务执行
   - 日志面板实时输出
9. 运行完成 → status=completed
10. 检查数据库crew_run / crew_task 记录正确

测试场景 2并行委派

1. 编排A(主) → B(串行), A → C(并行), A → D(并行)
2. 触发运行
3. 验证B 先执行完,然后 C 和 D 并行执行
4. 检查 crew_task 表C 和 D 的 startTime 接近

测试场景 3错误处理 + 升级

1. 编排包含一个"必然失败"的 Agent如 API Key 无效)
2. 触发运行
3. 验证:主 Agent 收到错误,尝试处理
4. 如果主 Agent 判断无法处理 → 调用 escalate
5. 监控页弹出升级对话框
6. 输入处理意见 → 点击恢复
7. 验证主 Agent 继续执行

测试场景 4定时调度

1. 编辑集群触发配置:启用 cron表达式 = "*/2 * * * *"每2分钟
2. 发布集群
3. 等待 2 分钟,验证自动触发运行
4. 取消发布 → 验证定时任务停止

Step 6.5:性能与稳定性检查

  • 并发安全:同时触发 3 个集群运行,无死锁或数据混乱
  • WebSocket 断线重连:刷新页面后重新连接 /crew,状态恢复
  • 超时控制:子 Agent 执行超过 taskTimeout 后正确标记 failed
  • 内存泄漏:长时间运行后检查 Node.js 内存使用
  • 日志量:大量日志时前端不卡顿(已设置 5000 条上限)

Step 6.6:提交代码

git add -A
git commit -m "feat(crew): 完成权限集成 + 端到端测试验证"

Phase 6 验收

  • 菜单权限配置完成
  • 租户数据隔离验证通过
  • 端到端测试场景 1-4 全部通过
  • 性能与稳定性检查通过
  • 代码已提交

风险应对与降级方案

风险 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.tsruntime/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 天。