# Multi-Agent Crew 编排与运行监控 — 实施规划

> 基于设计文档：`2026-04-14-multi-agent-crew-orchestration-design.md`
> 日期：2026-04-14
> 状态：待审核
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
---

## 目录

- [总览](#总览)
- [Phase 0：准备工作](#phase-0准备工作)
- [Phase 1：后端数据层（Entity + Migration）](#phase-1后端数据层entity--migration)
- [Phase 2：后端核心引擎（编排器 + 委派工具）](#phase-2后端核心引擎编排器--委派工具)
- [Phase 3：后端通信层（WebSocket + 触发 + 定时）](#phase-3后端通信层websocket--触发--定时)
- [Phase 4：前端编排页](#phase-4前端编排页)
- [Phase 5：前端监控页](#phase-5前端监控页)
- [Phase 6：权限集成 + 端到端测试](#phase-6权限集成--端到端测试)
- [风险应对与降级方案](#风险应对与降级方案)
- [验收标准](#验收标准)

---

## 总览

### 实施策略

采用 **纵向切片、逐层推进** 策略。每个 Phase 完成后都有可验证的产出，允许中途停下来评审。

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

### 关键约定

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

### 文件影响矩阵

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

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

---

## Phase 0：准备工作

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

### Step 0.1：确认前端依赖可用

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

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

**预期结果**：
- `@vue-flow/core` ≥ 1.42.1 ✓
- `@vue-flow/minimap`、`@vue-flow/controls`、`@vue-flow/background` 已安装 ✓
- `elkjs` ≥ 0.9.3 ✓

**如果缺失**：
```bash
pnpm add @vue-flow/core @vue-flow/minimap @vue-flow/controls @vue-flow/background elkjs
```

### Step 0.2：确认后端依赖

```bash
cd packages/backend
# cron 包（定时调度用）
pnpm list cron
```

**如果缺失**：
```bash
pnpm add cron
pnpm add -D @types/cron
```

### Step 0.3：Vue Flow 技术验证（⚠️ 关键风险点）

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

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

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

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

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

**验证清单**：
- [ ] 画布正常渲染，节点可见
- [ ] 节点可自由拖拽，连线跟随
- [ ] MiniMap、Controls、Background 正常显示
- [ ] 自定义节点（slot 方式）可渲染
- [ ] 连线可通过 Handle 创建

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

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

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

### Phase 0 验收

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

---

## Phase 1：后端数据层（Entity + Migration）

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

### Step 1.1：创建 `crew.ts` Entity

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**设计要点**：
- `triggerConfig` / `delegateHints` / `retryPolicy` 使用 JSON 列，灵活扩展
- `masterAgentId` 不使用外键约束（遵循项目规范），通过应用层关联
- `status` 索引优化发布状态查询

### Step 1.2：创建 `crew_agent.ts` Entity

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

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

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

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

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

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

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

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

### Step 1.3：创建 `crew_run.ts` Entity

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 1.4：创建 `crew_task.ts` Entity

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 1.5：扩展 `agent.ts` Entity

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

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

> 这是唯一一处修改现有文件的地方，风险极低（仅新增列，不改现有字段）。

### Step 1.6：注册 Entity 到模块配置

**文件**：`packages/backend/src/modules/netaclaw/config.ts`

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

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

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

### Step 1.7：验证建表

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

**验证清单**：
- [ ] 服务启动无报错
- [ ] MySQL 中新增 4 张表：`netaclaw_crew`、`netaclaw_crew_agent`、`netaclaw_crew_run`、`netaclaw_crew_task`
- [ ] `netaclaw_agent` 表新增 `isCrewMaster` 字段
- [ ] 所有索引正确创建
- [ ] JSON 列可正常读写（手动插入测试数据验证）

### Step 1.8：提交代码

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

### Phase 1 验收

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

---

## Phase 2：后端核心引擎（编排器 + 委派工具）

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

### Step 2.1：定义类型接口

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

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

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

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

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

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

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

### Step 2.2：实现 CrewDelegate（委派执行器）

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

**职责**：执行子 Agent 任务的核心函数，直接调用 `runAgent()`。

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

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

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

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

  @Inject()
  skillLoader: SkillLoaderService;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 2.3：实现 3 个委派工具

#### 2.3.1 `delegate_task.ts`（串行委派）

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

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

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

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

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

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

#### 2.3.2 `delegate_parallel.ts`（并行委派）

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

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

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

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

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

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

#### 2.3.3 `escalate.ts`（升级人工）

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

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

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

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

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

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

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

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

### Step 2.4：实现 CrewOrchestrator（核心编排器）

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

**这是整个系统最核心的文件**，负责：
1. 加载集群配置
2. 构建主 Agent 增强 system prompt
3. 注入委派工具
4. 启动主 Agent ReAct 循环
5. 管理运行生命周期

```typescript
import { Provide, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawCrewEntity } from '../entity/crew.js';
import { NetaClawCrewAgentEntity } from '../entity/crew_agent.js';
import { NetaClawCrewRunEntity } from '../entity/crew_run.js';
import { NetaClawCrewTaskEntity } from '../entity/crew_task.js';
import { NetaClawAgentEntity } from '../entity/agent.js';
import { runAgent, AgentConfig } from '../runtime/agent.js';
import { AnyAgentTool } from '../tools/common.js';
import { CrewDelegateService } from './crew_delegate.js';
import { CrewRunContext, CrewCallbacks } from './crew_types.js';
import { createDelegateTaskTool } from '../tools/builtin/delegate_task.js';
import { createDelegateParallelTool } from '../tools/builtin/delegate_parallel.js';
import { createEscalateTool } from '../tools/builtin/escalate.js';
import { bashTool } from '../tools/builtin/bash.js';
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';

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

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

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

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

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

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

  @Inject()
  crewDelegate: CrewDelegateService;

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

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

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

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

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

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

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

    return run.id;
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return prompt;
  }

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

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

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

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

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

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

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

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

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

### Step 2.5：实现 CrewService（集群 CRUD 业务层）

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

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

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

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

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

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

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

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

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

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

    return { ...crew, members };
  }

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

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

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

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

### Step 2.6：验证核心引擎

此阶段暂不实现 Controller 和 WebSocket（Phase 3），但可通过单元测试或临时脚本验证：

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

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

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

### Step 2.7：提交代码

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

### Phase 2 验收

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

---

## Phase 3：后端通信层（WebSocket + 触发 + 定时）

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

### Step 3.1：实现 Crew CRUD Controller

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

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

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

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

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

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

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

**自动生成的 API 端点**（Cool Admin 约定，路径基于 `controller/admin/` 目录结构）：
- `POST /admin/netaclaw/crew/add` — 创建集群
- `POST /admin/netaclaw/crew/delete` — 删除集群
- `POST /admin/netaclaw/crew/update` — 更新集群
- `POST /admin/netaclaw/crew/info` — 获取集群信息
- `POST /admin/netaclaw/crew/page` — 分页查询
- `POST /admin/netaclaw/crew/saveCanvas` — 保存画布
- `POST /admin/netaclaw/crew/detail` — 集群详情（含成员）
- `POST /admin/netaclaw/crew/publish` — 发布
- `POST /admin/netaclaw/crew/unpublish` — 取消发布

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

**前端 service 代理路径**：`service.netaclaw.crew.saveCanvas()` 等

### Step 3.2：实现 Crew Run Controller

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

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

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

### Step 3.3：实现 Crew Trigger Controller

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 3.4：实现 CrewGateway（WebSocket 网关）

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

> 独立 `/crew` 命名空间，与现有 `/netaclaw` 隔离。

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

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

  @Logger()
  logger: ILogger;

  @Inject()
  orchestrator: CrewOrchestratorService;

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

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

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

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

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

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

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

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

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

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

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

### Step 3.5：实现 CrewScheduler（定时调度器）

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

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

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

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

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

  @Inject()
  orchestrator: CrewOrchestratorService;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

在 `publish()` 和 `unpublish()` 方法中注入调度器：

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

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

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

### Step 3.7：验证通信层

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

### Step 3.8：提交代码

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

### Phase 3 验收

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

---

## Phase 4：前端编排页

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

### Step 4.1：注册路由与菜单

#### 4.1.1 修改前端路由配置

**文件**：`packages/frontend/src/modules/agent/config.ts`

在 views 数组中追加：

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

#### 4.1.2 插入菜单记录

通过数据库插入 `base_sys_menu` 记录（Cool Admin 菜单管理）：

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

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

### Step 4.2：实现 Pinia Store

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 4.3：实现画布核心 Hook

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

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

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

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

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

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

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

    addNodes([node]);
  }

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

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

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

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

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

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

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

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

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

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

### Step 4.4：实现画布 → delegateHints 转换 Hook

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

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

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

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

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

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

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

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

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

  return { hints, edges: edgeInfos };
}
```

### Step 4.5：实现自定义节点组件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 4.6：实现自定义连线标签

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

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

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

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

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

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

### Step 4.7：实现左侧 Agent 列表

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

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

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

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

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

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

const search = ref('');

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

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

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

### Step 4.8：实现属性面板

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

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

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

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

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

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

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

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

### Step 4.9：实现触发配置子组件

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

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

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

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

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

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

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

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

### Step 4.10：实现编排页主视图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 4.11：实现右键菜单组件

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

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

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

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

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

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

### Step 4.12：实现自动布局（elkjs）

在 `hooks/crew-canvas.ts` 中新增 `autoLayout` 方法：

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

const elk = new ELK();

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

  const layout = await elk.layout(graph);

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

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

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

### Step 4.13：实现框选分组

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

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

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

### Step 4.14：验证编排页

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

### Step 4.15：提交代码

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

### Phase 4 验收

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

---

## Phase 5：前端监控页

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

### Step 5.1：实现 WebSocket 监控 Hook

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  onUnmounted(() => disconnect());

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

### Step 5.2：实现运行记录表格

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

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

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

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

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

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

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

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

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

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

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

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

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

onMounted(() => loadData());

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

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

### Step 5.3：实现任务时间线

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

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

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

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

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

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

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

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

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

### Step 5.4：实现日志面板

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

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

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

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

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

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

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

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

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

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

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

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

### Step 5.5：实现运行详情容器

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 5.6：实现监控页主视图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Step 5.7：验证监控页

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

### Step 5.8：提交代码

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

### Phase 5 验收

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

---

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

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

### Step 6.1：配置菜单权限

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

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

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

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

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

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

### Step 6.2：数据权限（租户隔离）

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

**验证点**：
- [ ] 不同租户创建的集群互不可见
- [ ] 管理员可查看所有集群

### Step 6.3：Controller 权限装饰

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

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

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

### Step 6.4：端到端全流程测试

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

```
前置条件：已有 3 个已发布 Agent（如：登录Agent、数据获取Agent、上架Agent）

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

#### 测试场景 2：并行委派

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

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

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

#### 测试场景 4：定时调度

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

### Step 6.5：性能与稳定性检查

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

### Step 6.6：提交代码

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

### Phase 6 验收

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

---

## 风险应对与降级方案

### 风险 1：Vue Flow 兼容问题

| 级别 | 场景 | 应对 |
|------|------|------|
| 🟢 低 | 样式冲突 | 调整 CSS 隔离，使用 `scoped` + `deep` 选择器 |
| 🟡 中 | 自定义节点渲染异常 | 检查 Vue Flow 版本 API 变更，参考官方 examples |
| 🔴 高 | Vue Flow 与项目 Vue 版本不兼容 | **降级方案**：使用纯 SVG + CSS 实现简化版画布（节点拖拽用 `interact.js`，连线用 SVG `<path>`） |

**预防措施**：Phase 0 的技术验证（Step 0.4）可在正式开发前发现此类问题。

### 风险 2：主 Agent 编排质量差

| 场景 | 应对 |
|------|------|
| 主 Agent 不遵循 delegateHints | 强化 system prompt 中的指令措辞，增加 few-shot 示例 |
| 主 Agent 无限循环委派 | `maxToolRounds = 50` 硬性限制 + 检测连续失败次数 |
| 主 Agent 忽略错误 | 在 tool_result 中显式标记 `status: "failed"`，提示需要处理 |

### 风险 3：长时间运行稳定性

| 场景 | 应对 |
|------|------|
| 服务重启丢失运行状态 | `pausedState` 持久化对话上下文，启动时恢复 |
| 子 Agent 执行卡死 | `Promise.race` 超时控制，默认 300s |
| WebSocket 断线 | 前端 Socket.IO 自动重连 + 重新拉取当前状态 |

### 风险 4：并发 Token 消耗过高

| 场景 | 应对 |
|------|------|
| 并行执行大量子 Agent | `maxConcurrent` 限制（默认 3），分批执行 |
| Token 成本超预期 | 前端展示累计 token 消耗，集群配置 token 预算告警（后续迭代） |

---

## 验收标准

### 功能验收

| # | 功能点 | 验收标准 |
|---|--------|---------|
| 1 | 集群 CRUD | 创建/编辑/删除集群，数据持久化正确 |
| 2 | 画布编排 | 拖拽添加 Agent、自由连线、设置主 Agent、保存/加载画布 |
| 3 | 角色配置 | 每个 Agent 可配置角色描述和分组 |
| 4 | 连线提示 | 连线自动生成 delegateHints，注入主 Agent system prompt |
| 5 | 发布控制 | 发布校验（需主 Agent + ≥2 成员），取消发布 |
| 6 | 手动触发 | 通过 REST API 和 WebSocket 触发运行 |
| 7 | 定时调度 | Cron 表达式配置，发布时注册，取消发布时销毁 |
| 8 | 串行委派 | 主 Agent 通过 delegate_task 串行执行子 Agent |
| 9 | 并行委派 | 主 Agent 通过 delegate_parallel 并行执行子 Agent |
| 10 | 错误处理 | 子 Agent 失败时，主 Agent 收到错误信息并自主决策 |
| 11 | 升级人工 | escalate 暂停运行 → 用户处理 → 恢复执行 |
| 12 | 运行列表 | 分页查询、状态筛选、实时更新 |
| 13 | 实时画布 | 节点状态随任务进度实时变化（呼吸动画/颜色） |
| 14 | 任务时间线 | 按时间排序，并行任务并排显示 |
| 15 | 日志面板 | 实时流式日志，Agent 维度 Tab 切换，搜索过滤 |
| 16 | Token 统计 | 每个任务和整次运行的 token 消耗正确记录 |
| 17 | 权限控制 | 菜单权限 + 租户数据隔离 |

### 非功能验收

| # | 指标 | 标准 |
|---|------|------|
| 1 | 零 runtime 改动 | `runtime/agent.ts` 和 `runtime/attempt.ts` 无修改 |
| 2 | 现有功能不受影响 | Agent 对话、Skill 管理等原有功能正常 |
| 3 | 代码规范 | Entity 继承 BaseEntity、Controller 使用 @CoolController、文件下划线命名 |
| 4 | 并发安全 | 3 个集群同时运行无异常 |
| 5 | 超时控制 | 子 Agent 超时后正确标记 failed |

### 交付物清单

| 类别 | 数量 | 文件 |
|------|------|------|
| 后端 Entity | 4 新增 + 1 修改 | `crew.ts`, `crew_agent.ts`, `crew_run.ts`, `crew_task.ts`, `agent.ts` |
| 后端 Service | 5 新增 | `crew_types.ts`, `crew.ts`, `crew_orchestrator.ts`, `crew_delegate.ts`, `crew_scheduler.ts` |
| 后端 Tool | 3 新增 | `delegate_task.ts`, `delegate_parallel.ts`, `escalate.ts` |
| 后端 Controller | 3 新增 | `crew.ts`, `crew_run.ts`, `crew_trigger.ts` |
| 后端 Gateway | 1 新增 | `crew_server.ts` |
| 前端 View | 2 新增 | `crew-editor.vue`, `crew-monitor.vue` |
| 前端 Component | 11 新增 | `crew-agent-node.vue`, `crew-edge-label.vue`, `crew-sidebar.vue`, `crew-property-panel.vue`, `crew-trigger-config.vue`, `crew-context-menu.vue`, `crew-run-table.vue`, `crew-run-detail.vue`, `crew-timeline.vue`, `crew-log-panel.vue`, `crew-canvas.vue`（可选抽取） |
| 前端 Hook | 3 新增 | `crew-canvas.ts`, `crew-orchestration.ts`, `crew-monitor.ts` |
| 前端 Store | 1 新增 | `crew.ts` |
| **合计** | **34 个文件** | 31 新增 + 3 修改 |

---

## 时间线总览

```
Phase 0  准备工作                  ██                    0.5 天
Phase 1  后端数据层                ████                  1   天
Phase 2  后端核心引擎              ██████████            2.5 天
Phase 3  后端通信层                ████████              2   天
Phase 4  前端编排页                ████████████          3   天
Phase 5  前端监控页                ██████████            2.5 天
Phase 6  权限+测试                 ████                  1   天
                                                        ────────
                                   总计                  12.5 天
```

> **说明**：Phase 4 预留了额外缓冲（Vue Flow 首次使用风险）。如果 Phase 0 验证顺利，Phase 4 可缩短至 2 天。

