Add managed multi-agent collaboration runtime
This commit is contained in:
parent
f40f12f935
commit
8067916b5b
@ -2,44 +2,50 @@
|
||||
|
||||
## 概述
|
||||
|
||||
XWorkmate 当前有三组独立但可组合的集成面:
|
||||
XWorkmate 现阶段的集成基线已经从“单一 Codex bridge”升级为“统一发现与分发中心”。App 负责发现、托管和分发三类协作资产:
|
||||
|
||||
1. **OpenClaw Gateway**
|
||||
- 设备配对
|
||||
- Agent 列表与聊天
|
||||
- `cron.list` 只读任务视图
|
||||
- `memory/sync` 同步能力
|
||||
2. **AI Gateway**
|
||||
- 统一模型入口
|
||||
- 模型目录同步
|
||||
- 给 Codex CLI 提供模型桥接
|
||||
3. **Code Agent Runtime**
|
||||
- 当前唯一可交付路径是外部 `Codex CLI`
|
||||
- 内置 Rust FFI 仍是 future work
|
||||
- 所有 runtime 都挂在 `XWorkmate App node` 后面,而不是直接挂到 Gateway
|
||||
1. `skills`
|
||||
2. `MCP server list`
|
||||
3. `AI Gateway` 默认注入
|
||||
|
||||
## 当前真实链路
|
||||
运行时上,XWorkmate 不再把 CLI 视为孤立工具,而是通过本地 broker 与编排层统一驱动 `OpenClaw / Codex / Claude / Gemini / OpenCode`。
|
||||
|
||||
## 当前架构基线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
X["XWorkmate App Node"] -->|JSON-RPC over stdio| C["Codex CLI"]
|
||||
C -->|HTTP| A["AI Gateway"]
|
||||
X -->|WebSocket RPC| G["OpenClaw Gateway"]
|
||||
X -->|agent/register + chat.send metadata| G
|
||||
X["XWorkmate App"] --> D["Discovery / Distribution Catalog"]
|
||||
X --> B["MultiAgentBroker<br/>WebSocket JSON-RPC"]
|
||||
X --> G["OpenClaw Gateway / Host"]
|
||||
B --> O["MultiAgentOrchestrator"]
|
||||
O --> C["Codex CLI"]
|
||||
O --> L["Claude CLI"]
|
||||
O --> M["Gemini CLI"]
|
||||
O --> P["OpenCode CLI"]
|
||||
C --> A["AI Gateway"]
|
||||
L --> A
|
||||
M --> A
|
||||
P --> A
|
||||
A --> OL["Ollama / Upstream Model Endpoints"]
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- `Codex CLI` 不直接连接 `OpenClaw Gateway`
|
||||
- `XWorkmate App` 是唯一的 cooperative node
|
||||
- 本地内置/扩展/外部 CLI 都是 node 后端 runtime
|
||||
- AI Gateway 与 OpenClaw Gateway 是两套不同职责的集成面
|
||||
- `XWorkmate App` 是唯一的 discovery / distribution center。
|
||||
- `MultiAgentBroker` 是多 CLI 协作的本地运行时入口。
|
||||
- `OpenClaw` 既是现有 Gateway 集成面,也是可被托管发现的宿主控制面。
|
||||
- `AI Gateway` 的语义是“XWorkmate 协作运行默认 provider”,不是用户全局 provider 替换器。
|
||||
|
||||
## 1. OpenClaw Gateway
|
||||
## 1. OpenClaw Gateway / Host
|
||||
|
||||
用途:运行时协同、响应返回和设备信任边界。
|
||||
用途:
|
||||
|
||||
当前已用到的能力:
|
||||
- 运行时协同
|
||||
- 设备与信任边界
|
||||
- Agent / Session / Chat 通道
|
||||
- 宿主控制面发现
|
||||
|
||||
已使用能力:
|
||||
|
||||
- `health`
|
||||
- `status`
|
||||
@ -51,59 +57,87 @@ flowchart LR
|
||||
- `agent/register`
|
||||
- `memory/sync`
|
||||
|
||||
当前产品边界:
|
||||
新的定位:
|
||||
|
||||
- Scheduled Tasks 只读展示 `cron.list`
|
||||
- Memory 只暴露同步语义,不提供 CRUD UI
|
||||
- 远程模式必须保持 TLS 显式配置
|
||||
- Gateway 接收到的是来自 `XWorkmate App node` 的交互和 metadata,不是 CLI 直连 RPC
|
||||
- 继续作为 Gateway RPC 面存在。
|
||||
- 额外纳入“可挂载目标”集合。
|
||||
- 发现 `agents / skills / plugins` 状态,但不覆盖用户现有默认 agent。
|
||||
|
||||
## 2. AI Gateway
|
||||
|
||||
用途:为外部 Codex CLI 提供统一模型桥接。
|
||||
用途:
|
||||
|
||||
当前链路:
|
||||
- 统一模型入口
|
||||
- 作为 XWorkmate 协作运行的默认模型路由
|
||||
- 为外部 CLI 提供 launch-scoped 或托管 provider 注入
|
||||
|
||||
1. 用户在设置中配置 AI Gateway URL、模型和 API Key。
|
||||
2. `CodexConfigBridge` 把配置写入 `~/.codex/config.toml`。
|
||||
3. 外部 `codex app-server` 通过该配置把推理请求转发到 AI Gateway。
|
||||
边界:
|
||||
|
||||
这部分不负责:
|
||||
- 不负责设备配对
|
||||
- 不负责 session / agent 生命周期
|
||||
- 不替换用户现有默认 provider / model
|
||||
|
||||
- 设备配对
|
||||
- 任务调度
|
||||
- Agent 注册
|
||||
当前策略:
|
||||
|
||||
## 3. Code Agent Runtime
|
||||
- `Codex` 可以追加 `xworkmate` provider 托管块
|
||||
- `Claude / Gemini / OpenCode` 优先采用 launch-scoped 注入
|
||||
- Gateway 不可用时允许回退到 CLI 原有配置
|
||||
|
||||
### 当前可用路径
|
||||
## 3. Multi-Agent Runtime
|
||||
|
||||
- `RuntimeCoordinator`
|
||||
- `CodexRuntime.startStdio()`
|
||||
- `ExternalCodeAgentProvider`
|
||||
- `CodeAgentNodeOrchestrator`
|
||||
### 编排层
|
||||
|
||||
已支持:
|
||||
`MultiAgentOrchestrator` 负责:
|
||||
|
||||
- 显式启用 / 停用 bridge
|
||||
- 手动覆盖 Codex 二进制路径
|
||||
- Gateway 已连接时注册为 `code-agent-bridge`
|
||||
- `chat.send` 携带 node / provider / bridge dispatch metadata
|
||||
- 为未来其他外部 CLI 预留统一 provider contract
|
||||
- Architect 任务分析
|
||||
- Engineer 实现
|
||||
- Tester / Doc 审阅
|
||||
- 迭代评分与回退
|
||||
|
||||
### 当前不可用路径
|
||||
### Broker 层
|
||||
|
||||
Built-in Codex / Rust FFI 仍不可用。
|
||||
`MultiAgentBroker` 负责:
|
||||
|
||||
现状:
|
||||
- 本地 `WebSocket JSON-RPC`
|
||||
- run lifecycle
|
||||
- worker CLI 启动
|
||||
- selected skills / MCP / Gateway 上下文注入
|
||||
- 结构化事件流回写当前会话
|
||||
|
||||
- `builtIn` 只保留配置位
|
||||
- UI 只显示 `Experimental / Unavailable`
|
||||
- Rust FFI 核心 TODO 尚未补完
|
||||
### UI 接线
|
||||
|
||||
## 4. 外部 Provider 预留
|
||||
- Assistant 继续复用现有 composer、附件、当前会话
|
||||
- Settings 继续复用现有 Multi-Agent 区块
|
||||
- 不新增独立任务页面
|
||||
|
||||
当前统一 contract:
|
||||
## 4. 发现与分发
|
||||
|
||||
XWorkmate 统一维护两类状态:
|
||||
|
||||
- `managed`
|
||||
- 由 App 创建与维护的托管项
|
||||
- `external`
|
||||
- 外部已有配置或 CLI 自带配置
|
||||
|
||||
统一规则:
|
||||
|
||||
- 只更新 XWorkmate 托管项
|
||||
- 不删除外部已有项
|
||||
- 启动时与保存设置后自动 reconcile
|
||||
|
||||
## 5. 挂载入口矩阵
|
||||
|
||||
| 目标 | Skills 挂载入口 | MCP 挂载入口 | AI Gateway 挂载入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenClaw | 发现 `skills / plugins / agents`,broker 注入上下文 | 不作为 MCP 主挂载点 | XWorkmate 协作路径默认 route |
|
||||
| Codex | `AGENTS.md` / skill 上下文 / broker 注入 | `~/.codex/config.toml` 托管块 | `model_providers.xworkmate`,不替换用户默认 |
|
||||
| Claude | broker 注入 | `claude mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入 |
|
||||
| Gemini | broker 注入,后续可扩展 `extensions` | `gemini mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入 |
|
||||
| OpenCode | broker 注入,后续可扩展 agent preset | `~/.opencode/config.toml` 托管块 | 启动参数或托管 preset 注入 |
|
||||
|
||||
## 6. 外部 Provider 与执行路径
|
||||
|
||||
保留现有统一 contract:
|
||||
|
||||
- `ExternalCodeAgentProvider.id`
|
||||
- `name`
|
||||
@ -112,30 +146,30 @@ Built-in Codex / Rust FFI 仍不可用。
|
||||
- `capabilities`
|
||||
- `CodeAgentNodeOrchestrator.buildGatewayDispatch()`
|
||||
|
||||
当前 active provider:
|
||||
现状:
|
||||
|
||||
- `codex`
|
||||
- `codex` 仍是当前最完整 provider
|
||||
- 其他 CLI 通过 `CliMountAdapter` 与 broker 接入
|
||||
- 多 provider 调度 UI 不是当前交付目标
|
||||
|
||||
暂不实现:
|
||||
|
||||
- provider 切换 UI
|
||||
- capability discovery UI
|
||||
- 多 provider 调度策略
|
||||
|
||||
## 5. 安全边界
|
||||
## 7. 安全边界
|
||||
|
||||
- `.env` 仅用于开发预填充,不自动连接,不作为持久化真值源
|
||||
- AI Gateway API Key 和 Gateway 凭证继续走 secure storage
|
||||
- 外部 Codex CLI 路径仅保存文件路径,不保存 secret
|
||||
- `chat.send` 的 node metadata 仅上传 node/provider 状态,不上传 Gateway secret 或本地 CLI 绝对路径
|
||||
- AI Gateway API Key 与 Gateway 凭证继续走 secure storage
|
||||
- 新增协作路径不得把 secret 写入 `SharedPreferences`
|
||||
- Launch-scoped 注入优先于全局配置改写
|
||||
- 远程 Gateway 不允许静默降级为非 TLS
|
||||
- 协作事件与 metadata 不上传本地 secret 或本机绝对路径
|
||||
|
||||
## 相关代码
|
||||
|
||||
- `lib/app/app_controller.dart`
|
||||
- `lib/runtime/runtime_coordinator.dart`
|
||||
- `lib/runtime/codex_runtime.dart`
|
||||
- `lib/features/assistant/assistant_page.dart`
|
||||
- `lib/features/settings/settings_page.dart`
|
||||
- `lib/runtime/runtime_models.dart`
|
||||
- `lib/runtime/multi_agent_orchestrator.dart`
|
||||
- `lib/runtime/multi_agent_broker.dart`
|
||||
- `lib/runtime/multi_agent_mounts.dart`
|
||||
- `lib/runtime/codex_config_bridge.dart`
|
||||
- `lib/runtime/code_agent_node_orchestrator.dart`
|
||||
- `lib/runtime/agent_registry.dart`
|
||||
- `lib/runtime/gateway_runtime.dart`
|
||||
- `lib/runtime/opencode_config_bridge.dart`
|
||||
- `lib/runtime/runtime_coordinator.dart`
|
||||
|
||||
175
docs/plans/2026-03-19-xworkmate-multi-agent-enhancement.md
Normal file
175
docs/plans/2026-03-19-xworkmate-multi-agent-enhancement.md
Normal file
@ -0,0 +1,175 @@
|
||||
# XWorkmate Multi-Agent 协作增强方案
|
||||
|
||||
## 背景与目标
|
||||
|
||||
XWorkmate 已具备 Assistant 工作台、Gateway 连接、AI Gateway 模型路由、Ollama 本地模型配置,以及外部 CLI 运行时基础。当前缺口不是再造一个新界面,而是把现有入口收敛成一条完整的多代理协作链路。
|
||||
|
||||
本次增强的目标有三点:
|
||||
|
||||
1. 复用现有 Assistant composer、附件、skill picker 与当前会话。
|
||||
2. 让 App 成为 `skills`、`MCP server list`、`AI Gateway 默认注入` 的统一发现与分发中心。
|
||||
3. 在不破坏现有主布局的前提下,引入 `MultiAgentBroker` 与 mount adapter,把 `OpenClaw / Codex / Claude / Gemini / OpenCode` 纳入统一协作运行时。
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 文档先行,固化术语、架构和验收标准。
|
||||
- 在 `SettingsSnapshot.multiAgent` 中正式持久化多代理配置。
|
||||
- 复用现有 Settings 页面中的 Multi-Agent 区块,不新增页面。
|
||||
- 复用现有 Assistant 输入与当前会话,不新增独立任务对话框。
|
||||
- 新增本地 `WebSocket JSON-RPC` broker,驱动协作 run 与事件回写。
|
||||
- 新增 mount/discovery 适配层,最小支持 `Codex / Claude / Gemini / OpenCode / OpenClaw`。
|
||||
|
||||
## 非范围
|
||||
|
||||
- 首版不要求每个 CLI 都完成原生 skills 安装。
|
||||
- 首版不要求 App 管理用户全部 MCP 项,只管理 `xworkmate/*` 托管项。
|
||||
- 首版不替换用户原有 provider、默认模型或默认 agent。
|
||||
- 首版不直接合并外部仓库实现,只保留适配接口与挂载接线点。
|
||||
|
||||
## 目标链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Assistant Composer"] --> X["XWorkmate App"]
|
||||
X --> B["MultiAgentBroker<br/>WebSocket JSON-RPC"]
|
||||
X --> D["Discovery / Distribution Catalog"]
|
||||
D --> M["Managed Skills / MCP / Gateway Injection"]
|
||||
B --> O["MultiAgentOrchestrator"]
|
||||
O --> G["Gemini / Claude / Codex / OpenCode"]
|
||||
X --> OC["OpenClaw Gateway / Host"]
|
||||
G --> A["AI Gateway / Ollama"]
|
||||
```
|
||||
|
||||
## 配置与数据模型
|
||||
|
||||
`SettingsSnapshot.multiAgent` 是多代理配置的唯一持久化真值源,至少包含:
|
||||
|
||||
- 协作启用状态
|
||||
- `architect / engineer / tester` 三角色配置
|
||||
- 自动同步开关
|
||||
- AI Gateway 注入策略
|
||||
- 托管 `skills`
|
||||
- 托管 `MCP server list`
|
||||
- 已发现的挂载目标状态
|
||||
|
||||
### 关键模型
|
||||
|
||||
- `MultiAgentConfig`
|
||||
- `ManagedSkillEntry`
|
||||
- `ManagedMcpServerEntry`
|
||||
- `ManagedMountTargetState`
|
||||
- `AiGatewayInjectionPolicy`
|
||||
- `MultiAgentRunEvent`
|
||||
|
||||
### 状态分层
|
||||
|
||||
- `managed`
|
||||
- 由 XWorkmate 创建、更新、回收的托管项
|
||||
- `external`
|
||||
- 来自 CLI 自身、本地文件或现有环境的补充发现项
|
||||
|
||||
XWorkmate 只维护 `managed`,绝不覆盖 `external`。
|
||||
|
||||
## 挂载入口矩阵
|
||||
|
||||
| 目标 | Skills | MCP Server List | AI Gateway 默认注入 |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenClaw | 发现 `skills/plugins/agents`,协作时由 broker 注入上下文 | 不作为 MCP 主目标 | 仅为 XWorkmate 托管协作路径提供默认 provider / route 语义 |
|
||||
| Codex | `AGENTS.md` / skill 上下文注入 | `~/.codex/config.toml` 托管块 + `codex mcp` 兼容 | 仅新增 `xworkmate` provider,不替换用户默认 |
|
||||
| Claude | broker 注入 | `claude mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入,不写全局默认 |
|
||||
| Gemini | broker 注入,后续可扩展 `extensions` | `gemini mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入,不改用户默认模型 |
|
||||
| OpenCode | broker 注入,后续可扩展 agent preset | `~/.opencode/config.toml` 托管块 | 托管 preset 或启动参数注入,不替换用户主 agent |
|
||||
|
||||
## 同步与 reconcile 策略
|
||||
|
||||
统一规则:
|
||||
|
||||
- 启动时自动发现。
|
||||
- 保存 Multi-Agent 设置后重新 reconcile。
|
||||
- 只管理 `xworkmate/*` 托管项。
|
||||
- 外部已有项只发现、不删除。
|
||||
- 配置写入以“托管块”或“增量追加”为准,不整体重写。
|
||||
|
||||
### AI Gateway 默认注入
|
||||
|
||||
语义是:
|
||||
|
||||
- 只对 XWorkmate 发起的协作 run 生效。
|
||||
- 优先作为默认 provider / model route 使用。
|
||||
- 失败时允许回退到原 CLI 路由。
|
||||
- 不替换用户全局默认 provider / model。
|
||||
|
||||
## 运行时架构
|
||||
|
||||
`MultiAgentOrchestrator` 保留为编排层,负责:
|
||||
|
||||
- Architect 任务分析
|
||||
- Engineer 实现
|
||||
- Tester / Doc 审阅
|
||||
- 迭代与评分策略
|
||||
|
||||
`MultiAgentBroker` 作为 App 与 CLI worker 之间的本地 broker,负责:
|
||||
|
||||
- `WebSocket JSON-RPC` run lifecycle
|
||||
- worker CLI 启动
|
||||
- selected skills / 托管 MCP / AI Gateway 上下文注入
|
||||
- 结构化事件流
|
||||
- 失败、取消与回退状态返回
|
||||
|
||||
## UI 接线原则
|
||||
|
||||
### Assistant
|
||||
|
||||
- 继续复用现有输入框、附件、技能选择、当前会话。
|
||||
- 协作模式开启时,`_submitPrompt()` 改走 broker。
|
||||
- 协作事件流写回当前 session。
|
||||
|
||||
### Settings
|
||||
|
||||
- 继续复用现有 Multi-Agent 区块。
|
||||
- 最小增量展示:
|
||||
- 协作启用状态
|
||||
- 三角色 CLI / 模型
|
||||
- 自动同步
|
||||
- AI Gateway 注入策略
|
||||
- mount target 发现与同步状态
|
||||
|
||||
## 安全约束
|
||||
|
||||
- `.env` 仍然只用于开发预填充,不作为持久化真值源。
|
||||
- Gateway secret 与 AI Gateway API Key 继续走 secure storage。
|
||||
- 新的协作路径不得把 secret 写入 `SharedPreferences`。
|
||||
- Launch-scoped 注入优先于持久配置改写。
|
||||
- 远程 Gateway 不得静默降级为非 TLS。
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 配置
|
||||
|
||||
- `SettingsSnapshot.multiAgent` 能正确保存与加载。
|
||||
- secrets 不进入普通 settings JSON。
|
||||
|
||||
### 分发
|
||||
|
||||
- `~/.codex/config.toml` 与 `~/.opencode/config.toml` 只更新托管块。
|
||||
- `Claude / Gemini` 的 discovery 与状态刷新不破坏现有配置。
|
||||
- `OpenClaw` 不改用户现有默认 agent/provider。
|
||||
|
||||
### 运行时
|
||||
|
||||
- 协作模式开启时,Assistant 走 broker 路径。
|
||||
- 关闭时仍走原有单 Agent chat 链路。
|
||||
- 阶段事件可持续写回当前 session。
|
||||
- AI Gateway 不可用时有清晰回退路径。
|
||||
|
||||
### UI
|
||||
|
||||
- Assistant 主布局不变。
|
||||
- Settings 只做增量信息扩展。
|
||||
|
||||
### 验证
|
||||
|
||||
- `flutter analyze`
|
||||
- 相关单测
|
||||
- 非破坏性托管配置验证
|
||||
- 遵循 `docs/security/secure-development-rules.md`
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@ -19,6 +20,9 @@ import '../runtime/codex_config_bridge.dart';
|
||||
import '../runtime/code_agent_node_orchestrator.dart';
|
||||
import '../runtime/mode_switcher.dart';
|
||||
import '../runtime/agent_registry.dart';
|
||||
import '../runtime/multi_agent_broker.dart';
|
||||
import '../runtime/multi_agent_mounts.dart';
|
||||
import '../runtime/multi_agent_orchestrator.dart';
|
||||
|
||||
enum CodexCooperationState { notStarted, bridgeOnly, registered }
|
||||
|
||||
@ -62,6 +66,11 @@ class AppController extends ChangeNotifier {
|
||||
_tasksController = DerivedTasksController();
|
||||
_desktopPlatformService =
|
||||
desktopPlatformService ?? createDesktopPlatformService();
|
||||
_multiAgentMountManager = MultiAgentMountManager();
|
||||
_multiAgentOrchestrator = MultiAgentOrchestrator(
|
||||
config: _resolveMultiAgentConfig(_settingsController.snapshot),
|
||||
);
|
||||
|
||||
_attachChildListeners();
|
||||
unawaited(_initialize());
|
||||
}
|
||||
@ -83,6 +92,14 @@ class AppController extends ChangeNotifier {
|
||||
late final DevicesController _devicesController;
|
||||
late final DerivedTasksController _tasksController;
|
||||
late final DesktopPlatformService _desktopPlatformService;
|
||||
late final MultiAgentMountManager _multiAgentMountManager;
|
||||
late final MultiAgentOrchestrator _multiAgentOrchestrator;
|
||||
MultiAgentBrokerServer? _multiAgentBrokerServer;
|
||||
MultiAgentBrokerClient? _multiAgentBrokerClient;
|
||||
final Map<String, List<GatewayChatMessage>> _localSessionMessages =
|
||||
<String, List<GatewayChatMessage>>{};
|
||||
bool _multiAgentRunPending = false;
|
||||
int _localMessageCounter = 0;
|
||||
|
||||
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
@ -116,6 +133,7 @@ class AppController extends ChangeNotifier {
|
||||
SettingsController get settingsController => _settingsController;
|
||||
GatewayAgentsController get agentsController => _agentsController;
|
||||
GatewaySessionsController get sessionsController => _sessionsController;
|
||||
MultiAgentOrchestrator get multiAgentOrchestrator => _multiAgentOrchestrator;
|
||||
GatewayChatController get chatController => _chatController;
|
||||
InstancesController get instancesController => _instancesController;
|
||||
SkillsController get skillsController => _skillsController;
|
||||
@ -170,12 +188,142 @@ class AppController extends ChangeNotifier {
|
||||
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
|
||||
configuredCodeAgentRuntimeMode;
|
||||
CodexCooperationState get codexCooperationState => _codexCooperationState;
|
||||
bool get isMultiAgentRunPending => _multiAgentRunPending;
|
||||
bool _desktopPlatformBusy = false;
|
||||
|
||||
Future<String> loadAiGatewayApiKey() async {
|
||||
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
}
|
||||
|
||||
Future<void> saveMultiAgentConfig(MultiAgentConfig config) async {
|
||||
final resolved = _resolveMultiAgentConfig(
|
||||
settings.copyWith(multiAgent: config),
|
||||
);
|
||||
await saveSettings(
|
||||
settings.copyWith(multiAgent: resolved),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await refreshMultiAgentMounts(sync: resolved.autoSync);
|
||||
}
|
||||
|
||||
Future<void> refreshMultiAgentMounts({bool sync = false}) async {
|
||||
final resolved = _resolveMultiAgentConfig(settings);
|
||||
final reconciled = await _multiAgentMountManager.reconcile(
|
||||
config: sync ? resolved : resolved.copyWith(autoSync: false),
|
||||
aiGatewayUrl: aiGatewayUrl,
|
||||
);
|
||||
if (jsonEncode(reconciled.toJson()) !=
|
||||
jsonEncode(settings.multiAgent.toJson())) {
|
||||
await _settingsController.saveSnapshot(
|
||||
settings.copyWith(multiAgent: reconciled),
|
||||
);
|
||||
}
|
||||
_multiAgentOrchestrator.updateConfig(reconciled);
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> runMultiAgentCollaboration({
|
||||
required String rawPrompt,
|
||||
required String composedPrompt,
|
||||
required List<CollaborationAttachment> attachments,
|
||||
required List<String> selectedSkillLabels,
|
||||
}) async {
|
||||
final sessionKey = currentSessionKey.trim().isEmpty
|
||||
? 'main'
|
||||
: currentSessionKey;
|
||||
final client = await _ensureMultiAgentBrokerClient();
|
||||
final aiGatewayApiKey = await loadAiGatewayApiKey();
|
||||
_multiAgentRunPending = true;
|
||||
_appendLocalSessionMessage(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: _nextLocalMessageId(),
|
||||
role: 'user',
|
||||
text: rawPrompt,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
);
|
||||
_recomputeTasks();
|
||||
try {
|
||||
await for (final event in client.runTask(
|
||||
taskPrompt: composedPrompt,
|
||||
workingDirectory:
|
||||
_resolveCodexWorkingDirectory() ?? Directory.current.path,
|
||||
attachments: attachments,
|
||||
selectedSkills: selectedSkillLabels,
|
||||
aiGatewayBaseUrl: aiGatewayUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
)) {
|
||||
if (event.type == 'result') {
|
||||
final success = event.data['success'] == true;
|
||||
final finalScore = event.data['finalScore'];
|
||||
final iterations = event.data['iterations'];
|
||||
_appendLocalSessionMessage(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: _nextLocalMessageId(),
|
||||
role: 'assistant',
|
||||
text: success
|
||||
? appText(
|
||||
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
|
||||
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
|
||||
)
|
||||
: appText(
|
||||
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
|
||||
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
|
||||
),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: !success,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
_appendLocalSessionMessage(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: _nextLocalMessageId(),
|
||||
role: 'assistant',
|
||||
text: event.message,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: event.title,
|
||||
stopReason: null,
|
||||
pending: event.pending,
|
||||
error: event.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
_appendLocalSessionMessage(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: _nextLocalMessageId(),
|
||||
role: 'assistant',
|
||||
text: error.toString(),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: 'Multi-Agent',
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_multiAgentRunPending = false;
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openOnlineWorkspace() async {
|
||||
const url = 'https://www.svc.plus/Xworkmate';
|
||||
try {
|
||||
@ -256,6 +404,11 @@ class AppController extends ChangeNotifier {
|
||||
|
||||
List<GatewayChatMessage> get chatMessages {
|
||||
final items = List<GatewayChatMessage>.from(_chatController.messages);
|
||||
final localItems =
|
||||
_localSessionMessages[_sessionsController.currentSessionKey];
|
||||
if (localItems != null && localItems.isNotEmpty) {
|
||||
items.addAll(localItems);
|
||||
}
|
||||
final streaming = _chatController.streamingAssistantText?.trim() ?? '';
|
||||
if (streaming.isNotEmpty) {
|
||||
items.add(
|
||||
@ -670,9 +823,12 @@ class AppController extends ChangeNotifier {
|
||||
bool refreshAfterSave = true,
|
||||
}) async {
|
||||
final current = settings;
|
||||
final sanitized = _sanitizeCodeAgentSettings(snapshot);
|
||||
final sanitized = _sanitizeMultiAgentSettings(
|
||||
_sanitizeCodeAgentSettings(snapshot),
|
||||
);
|
||||
setActiveAppLanguage(sanitized.appLanguage);
|
||||
await _settingsController.saveSnapshot(sanitized);
|
||||
_multiAgentOrchestrator.updateConfig(sanitized.multiAgent);
|
||||
_agentsController.restoreSelection(sanitized.gateway.selectedAgentId);
|
||||
_modelsController.restoreFromSettings(sanitized.aiGateway);
|
||||
if (current.codexCliPath != sanitized.codexCliPath ||
|
||||
@ -689,6 +845,7 @@ class AppController extends ChangeNotifier {
|
||||
if (refreshAfterSave) {
|
||||
_recomputeTasks();
|
||||
}
|
||||
unawaited(refreshMultiAgentMounts(sync: sanitized.multiAgent.autoSync));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -897,6 +1054,7 @@ class AppController extends ChangeNotifier {
|
||||
_tasksController.dispose();
|
||||
_store.dispose();
|
||||
_desktopPlatformService.dispose();
|
||||
unawaited(_multiAgentBrokerServer?.stop() ?? Future<void>.value());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -920,8 +1078,8 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final normalized = _sanitizeCodeAgentSettings(
|
||||
_settingsController.snapshot,
|
||||
final normalized = _sanitizeMultiAgentSettings(
|
||||
_sanitizeCodeAgentSettings(_settingsController.snapshot),
|
||||
);
|
||||
if (normalized.toJsonString() !=
|
||||
_settingsController.snapshot.toJsonString()) {
|
||||
@ -931,6 +1089,7 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
_modelsController.restoreFromSettings(settings.aiGateway);
|
||||
_multiAgentOrchestrator.updateConfig(settings.multiAgent);
|
||||
setActiveAppLanguage(settings.appLanguage);
|
||||
await _desktopPlatformService.initialize(settings.linuxDesktop);
|
||||
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
|
||||
@ -958,6 +1117,7 @@ class AppController extends ChangeNotifier {
|
||||
// Keep the shell usable when auto-connect fails.
|
||||
}
|
||||
}
|
||||
await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync);
|
||||
} catch (error) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
@ -1017,6 +1177,71 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeMultiAgentSettings(SettingsSnapshot snapshot) {
|
||||
final resolved = _resolveMultiAgentConfig(snapshot);
|
||||
if (jsonEncode(snapshot.multiAgent.toJson()) ==
|
||||
jsonEncode(resolved.toJson())) {
|
||||
return snapshot;
|
||||
}
|
||||
return snapshot.copyWith(multiAgent: resolved);
|
||||
}
|
||||
|
||||
MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) {
|
||||
final defaults = MultiAgentConfig.defaults();
|
||||
final current = snapshot.multiAgent;
|
||||
final ollamaEndpoint = snapshot.ollamaLocal.endpoint.trim().isEmpty
|
||||
? current.ollamaEndpoint
|
||||
: snapshot.ollamaLocal.endpoint.trim();
|
||||
final engineerModel = current.engineer.model.trim().isNotEmpty
|
||||
? current.engineer.model.trim()
|
||||
: snapshot.ollamaLocal.defaultModel.trim().isNotEmpty
|
||||
? snapshot.ollamaLocal.defaultModel.trim()
|
||||
: defaults.engineer.model;
|
||||
final architectModel = current.architect.model.trim().isNotEmpty
|
||||
? current.architect.model.trim()
|
||||
: defaults.architect.model;
|
||||
final testerModel = current.tester.model.trim().isNotEmpty
|
||||
? current.tester.model.trim()
|
||||
: defaults.tester.model;
|
||||
return current.copyWith(
|
||||
ollamaEndpoint: ollamaEndpoint,
|
||||
architect: current.architect.copyWith(model: architectModel),
|
||||
engineer: current.engineer.copyWith(model: engineerModel),
|
||||
tester: current.tester.copyWith(model: testerModel),
|
||||
mountTargets: current.mountTargets.isEmpty
|
||||
? MultiAgentConfig.defaults().mountTargets
|
||||
: current.mountTargets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiAgentBrokerClient> _ensureMultiAgentBrokerClient() async {
|
||||
_multiAgentBrokerServer ??= MultiAgentBrokerServer(_multiAgentOrchestrator);
|
||||
await _multiAgentBrokerServer!.start();
|
||||
final uri = _multiAgentBrokerServer!.wsUri;
|
||||
if (uri == null) {
|
||||
throw StateError('Multi-agent broker is unavailable');
|
||||
}
|
||||
_multiAgentBrokerClient = MultiAgentBrokerClient(uri);
|
||||
return _multiAgentBrokerClient!;
|
||||
}
|
||||
|
||||
void _appendLocalSessionMessage(
|
||||
String sessionKey,
|
||||
GatewayChatMessage message,
|
||||
) {
|
||||
final key = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
|
||||
final next = List<GatewayChatMessage>.from(
|
||||
_localSessionMessages[key] ?? const <GatewayChatMessage>[],
|
||||
)..add(message);
|
||||
_localSessionMessages[key] = next;
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
String _nextLocalMessageId() {
|
||||
_localMessageCounter += 1;
|
||||
return 'local-${DateTime.now().microsecondsSinceEpoch}-$_localMessageCounter';
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) {
|
||||
_codexRuntimeWarning =
|
||||
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
|
||||
@ -1179,7 +1404,7 @@ class AppController extends ChangeNotifier {
|
||||
sessions: _sessionsController.sessions,
|
||||
cronJobs: _cronJobsController.items,
|
||||
currentSessionKey: _sessionsController.currentSessionKey,
|
||||
hasPendingRun: _chatController.hasPendingRun,
|
||||
hasPendingRun: _chatController.hasPendingRun || _multiAgentRunPending,
|
||||
activeAgentName: _agentsController.activeAgentName,
|
||||
);
|
||||
}
|
||||
@ -1197,6 +1422,7 @@ class AppController extends ChangeNotifier {
|
||||
_cronJobsController.addListener(_relayChildChange);
|
||||
_devicesController.addListener(_relayChildChange);
|
||||
_tasksController.addListener(_relayChildChange);
|
||||
_multiAgentOrchestrator.addListener(_relayChildChange);
|
||||
}
|
||||
|
||||
void _detachChildListeners() {
|
||||
@ -1212,6 +1438,7 @@ class AppController extends ChangeNotifier {
|
||||
_cronJobsController.removeListener(_relayChildChange);
|
||||
_devicesController.removeListener(_relayChildChange);
|
||||
_tasksController.removeListener(_relayChildChange);
|
||||
_multiAgentOrchestrator.removeListener(_relayChildChange);
|
||||
}
|
||||
|
||||
void _relayChildChange() {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@ -8,6 +9,7 @@ import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
import '../../runtime/multi_agent_orchestrator.dart';
|
||||
import '../../runtime/runtime_models.dart';
|
||||
import '../../theme/app_palette.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
@ -574,12 +576,30 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
);
|
||||
});
|
||||
|
||||
final attachmentPayloads = await _buildAttachmentPayloads(_attachments);
|
||||
await controller.sendChatMessage(
|
||||
prompt,
|
||||
thinking: _thinkingLabel,
|
||||
attachments: attachmentPayloads,
|
||||
);
|
||||
if (controller.settings.multiAgent.enabled) {
|
||||
final collaborationAttachments = _attachments
|
||||
.map(
|
||||
(item) => CollaborationAttachment(
|
||||
name: item.name,
|
||||
description: item.mimeType,
|
||||
path: item.path,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
await controller.runMultiAgentCollaboration(
|
||||
rawPrompt: rawPrompt,
|
||||
composedPrompt: prompt,
|
||||
attachments: collaborationAttachments,
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
);
|
||||
} else {
|
||||
final attachmentPayloads = await _buildAttachmentPayloads(_attachments);
|
||||
await controller.sendChatMessage(
|
||||
prompt,
|
||||
thinking: _thinkingLabel,
|
||||
attachments: attachmentPayloads,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -2084,6 +2104,39 @@ class _ComposerBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: appText(
|
||||
'多 Agent 协作模式(Architect → Engineer → Tester)',
|
||||
'Multi-Agent Collaboration Mode (Architect → Engineer → Tester)',
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: controller.multiAgentOrchestrator,
|
||||
builder: (context, _) {
|
||||
final collab = controller.multiAgentOrchestrator;
|
||||
final enabled = collab.config.enabled;
|
||||
return IconButton(
|
||||
key: const Key('assistant-collaboration-toggle'),
|
||||
icon: Icon(
|
||||
enabled
|
||||
? Icons.auto_awesome
|
||||
: Icons.auto_awesome_outlined,
|
||||
size: 20,
|
||||
color: enabled ? Colors.orange : null,
|
||||
),
|
||||
onPressed:
|
||||
collab.isRunning || controller.isMultiAgentRunPending
|
||||
? null
|
||||
: () => unawaited(
|
||||
controller.saveMultiAgentConfig(
|
||||
collab.config.copyWith(enabled: !enabled),
|
||||
),
|
||||
),
|
||||
splashRadius: 18,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@ -137,6 +137,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
controller,
|
||||
settings,
|
||||
),
|
||||
SettingsTab.agents => _buildAgents(
|
||||
context,
|
||||
controller,
|
||||
settings,
|
||||
),
|
||||
SettingsTab.appearance => _buildAppearance(context, controller),
|
||||
SettingsTab.diagnostics => _buildDiagnostics(
|
||||
context,
|
||||
@ -1261,6 +1266,397 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildAgents(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
final orchestrator = controller.multiAgentOrchestrator;
|
||||
final config = settings.multiAgent;
|
||||
final theme = Theme.of(context);
|
||||
final mountTargets = List<ManagedMountTargetState>.from(config.mountTargets)
|
||||
..sort(
|
||||
(left, right) =>
|
||||
left.label.toLowerCase().compareTo(right.label.toLowerCase()),
|
||||
);
|
||||
final managedSkillCount = config.managedSkills
|
||||
.where((item) => item.selected)
|
||||
.length;
|
||||
final managedMcpCount = config.managedMcpServers
|
||||
.where((item) => item.enabled)
|
||||
.length;
|
||||
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('多 Agent 协作', 'Multi-Agent Collaboration'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appText(
|
||||
'通过 Ollama 驱动多个 CLI 工具协同工作,实现 Architect → Engineer → Tester 的完整工作流。',
|
||||
'Orchestrate multiple CLI agents via Ollama for Architect → Engineer → Tester workflows.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SwitchRow(
|
||||
label: appText('启用协作模式', 'Enable Collaboration'),
|
||||
value: config.enabled,
|
||||
onChanged: (value) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(enabled: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(label: 'Ollama', value: config.ollamaEndpoint),
|
||||
_InfoRow(
|
||||
label: appText('超时时间', 'Timeout'),
|
||||
value: '${config.timeoutSeconds}s',
|
||||
),
|
||||
_InfoRow(
|
||||
label: appText('运行状态', 'Runtime'),
|
||||
value: orchestrator.isRunning
|
||||
? appText('协作执行中', 'Collaboration running')
|
||||
: config.enabled
|
||||
? appText('已启用', 'Enabled')
|
||||
: appText('已停用', 'Disabled'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('角色配置', 'Role Configuration'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AgentRoleCard(
|
||||
title: '🎨 ${appText('Architect(调度者)', 'Architect (Scheduler)')}',
|
||||
description: appText(
|
||||
'负责任务分解、流程编排、宏观设计',
|
||||
'Task decomposition, workflow orchestration, macro design',
|
||||
),
|
||||
cliTool: config.architect.cliTool,
|
||||
model: config.architect.model,
|
||||
enabled: config.architect.enabled,
|
||||
cliOptions: const ['gemini', 'claude', 'codex', 'opencode'],
|
||||
modelOptions: const ['gemini-2.0-flash', 'gemini-2.5-pro'],
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
architect: config.architect.copyWith(cliTool: tool),
|
||||
),
|
||||
),
|
||||
onModelChanged: (model) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
architect: config.architect.copyWith(model: model),
|
||||
),
|
||||
),
|
||||
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
architect: config.architect.copyWith(enabled: enabled),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AgentRoleCard(
|
||||
title: '🔧 ${appText('Engineer(工程师)', 'Engineer (Developer)')}',
|
||||
description: appText(
|
||||
'负责代码实现、重构、调试',
|
||||
'Code implementation, refactoring, debugging',
|
||||
),
|
||||
cliTool: config.engineer.cliTool,
|
||||
model: config.engineer.model,
|
||||
enabled: config.engineer.enabled,
|
||||
cliOptions: const ['claude', 'codex', 'opencode'],
|
||||
modelOptions: _getLocalModelOptions(settings),
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
engineer: config.engineer.copyWith(cliTool: tool),
|
||||
),
|
||||
),
|
||||
onModelChanged: (model) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
engineer: config.engineer.copyWith(model: model),
|
||||
),
|
||||
),
|
||||
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
engineer: config.engineer.copyWith(enabled: enabled),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AgentRoleCard(
|
||||
title: '🔍 ${appText('Tester/Doc(评审)', 'Tester/Doc (Reviewer)')}',
|
||||
description: appText(
|
||||
'负责测试用例生成、代码审阅、文档撰写',
|
||||
'Test generation, code review, documentation',
|
||||
),
|
||||
cliTool: config.tester.cliTool,
|
||||
model: config.tester.model,
|
||||
enabled: config.tester.enabled,
|
||||
cliOptions: const ['codex', 'claude', 'opencode'],
|
||||
modelOptions: const [
|
||||
'gpt-oss:20b',
|
||||
'qwen2.5-coder:latest',
|
||||
'glm-4.7-flash',
|
||||
],
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(tester: config.tester.copyWith(cliTool: tool)),
|
||||
),
|
||||
onModelChanged: (model) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(tester: config.tester.copyWith(model: model)),
|
||||
),
|
||||
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
tester: config.tester.copyWith(enabled: enabled),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('审阅策略', 'Review Strategy'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _EditableField(
|
||||
label: appText('最大迭代次数', 'Max Iterations'),
|
||||
value: config.maxIterations.toString(),
|
||||
onSubmitted: (value) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
_saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(maxIterations: parsed),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _EditableField(
|
||||
label: appText('最低达标分数', 'Min Acceptable Score'),
|
||||
value: config.minAcceptableScore.toString(),
|
||||
onSubmitted: (value) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed != null && parsed >= 1 && parsed <= 10) {
|
||||
_saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(minAcceptableScore: parsed),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'当 Tester 评分低于最低分数时,将进入迭代审阅循环。最多迭代指定次数。',
|
||||
'When Tester score is below minimum, iteration loop runs until max iterations or score达标.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('发现与分发', 'Discovery & Distribution'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appText(
|
||||
'App 作为统一发现与分发中心,维护托管 skills、MCP server list 和 AI Gateway 默认注入,但不会覆盖用户原有 CLI 配置。',
|
||||
'The app acts as the discovery and distribution center for managed skills, MCP server lists, and AI Gateway defaults without overwriting existing CLI config.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () =>
|
||||
controller.refreshMultiAgentMounts(sync: config.autoSync),
|
||||
child: Text(appText('刷新挂载', 'Refresh Mounts')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SwitchRow(
|
||||
label: appText('自动同步托管配置', 'Auto-sync managed config'),
|
||||
value: config.autoSync,
|
||||
onChanged: (value) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(autoSync: value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'multi-agent-injection-${config.aiGatewayInjectionPolicy.name}',
|
||||
),
|
||||
initialValue: config.aiGatewayInjectionPolicy.name,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('AI Gateway 注入策略', 'AI Gateway Injection'),
|
||||
),
|
||||
items: AiGatewayInjectionPolicy.values
|
||||
.map(
|
||||
(policy) => DropdownMenuItem<String>(
|
||||
value: policy.name,
|
||||
child: Text(policy.label),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
_saveMultiAgentConfig(
|
||||
controller,
|
||||
config.copyWith(
|
||||
aiGatewayInjectionPolicy:
|
||||
AiGatewayInjectionPolicyCopy.fromJsonValue(value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(
|
||||
label: appText('托管 Skills', 'Managed Skills'),
|
||||
value: '$managedSkillCount',
|
||||
),
|
||||
_InfoRow(
|
||||
label: appText('托管 MCP', 'Managed MCP'),
|
||||
value: '$managedMcpCount',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...mountTargets.map(
|
||||
(target) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _MountTargetCard(target: target),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('协作流程概览', 'Workflow Overview'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_WorkflowStep(
|
||||
label: '1',
|
||||
emoji: '🎨',
|
||||
title: 'Architect',
|
||||
desc: appText(
|
||||
'分析需求,分解任务',
|
||||
'Analyze requirements, decompose tasks',
|
||||
),
|
||||
),
|
||||
_WorkflowStep(
|
||||
label: '2',
|
||||
emoji: '🔧',
|
||||
title: 'Engineer',
|
||||
desc: appText('接收任务,实现代码', 'Receive tasks, implement code'),
|
||||
),
|
||||
_WorkflowStep(
|
||||
label: '3',
|
||||
emoji: '🔍',
|
||||
title: 'Tester',
|
||||
desc: appText('审阅代码,生成测试', 'Review code, generate tests'),
|
||||
),
|
||||
_WorkflowStep(
|
||||
label: '↻',
|
||||
emoji: '🔄',
|
||||
title: appText('迭代(如需要)', 'Iterate (if needed)'),
|
||||
desc: appText(
|
||||
'Engineer 修复 → Tester 重新审阅',
|
||||
'Engineer fixes → Tester re-reviews',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'所有本地模型通过 Ollama(默认 http://127.0.0.1:11434)驱动,无需 API 密钥即可运行。',
|
||||
'All local models powered by Ollama (default http://127.0.0.1:11434), no API key required.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<String> _getLocalModelOptions(SettingsSnapshot settings) {
|
||||
// 从 ollamaLocal 配置中获取可用模型
|
||||
final defaultModel = settings.ollamaLocal.defaultModel;
|
||||
if (defaultModel.isNotEmpty) {
|
||||
return [
|
||||
defaultModel,
|
||||
'qwen2.5-coder:latest',
|
||||
'gpt-oss:20b',
|
||||
'glm-4.7-flash',
|
||||
];
|
||||
}
|
||||
return const ['qwen2.5-coder:latest', 'gpt-oss:20b', 'glm-4.7-flash'];
|
||||
}
|
||||
|
||||
List<Widget> _buildExperimental(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
@ -1343,6 +1739,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return controller.saveSettings(snapshot);
|
||||
}
|
||||
|
||||
Future<void> _saveMultiAgentConfig(
|
||||
AppController controller,
|
||||
MultiAgentConfig config,
|
||||
) {
|
||||
return controller.saveMultiAgentConfig(config);
|
||||
}
|
||||
|
||||
AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) {
|
||||
return settings.aiGateway.copyWith(
|
||||
name: _aiGatewayNameController.text.trim(),
|
||||
@ -2243,6 +2646,72 @@ class _SwitchRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MountTargetCard extends StatelessWidget {
|
||||
const _MountTargetCard({required this.target});
|
||||
|
||||
final ManagedMountTargetState target;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final statusColor = target.available
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outline;
|
||||
final summary = <String>[
|
||||
'${appText('发现', 'Discovery')}: ${target.discoveryState}',
|
||||
'${appText('同步', 'Sync')}: ${target.syncState}',
|
||||
if (target.supportsSkills)
|
||||
'${appText('技能', 'Skills')}: ${target.discoveredSkillCount}',
|
||||
if (target.supportsMcp)
|
||||
'${appText('MCP', 'MCP')}: ${target.discoveredMcpCount}',
|
||||
if (target.supportsMcp)
|
||||
'${appText('托管', 'Managed')}: ${target.managedMcpCount}',
|
||||
];
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(target.label, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
Text(
|
||||
target.available
|
||||
? appText('可用', 'Available')
|
||||
: appText('未安装', 'Missing'),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(summary.join(' · '), style: theme.textTheme.bodySmall),
|
||||
if (target.detail.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(target.detail, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AiGatewayFeedbackTheme {
|
||||
const _AiGatewayFeedbackTheme({
|
||||
required this.background,
|
||||
@ -2303,3 +2772,187 @@ class _InfoRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent 角色配置卡片
|
||||
class _AgentRoleCard extends StatelessWidget {
|
||||
const _AgentRoleCard({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cliTool,
|
||||
required this.model,
|
||||
required this.enabled,
|
||||
required this.cliOptions,
|
||||
required this.modelOptions,
|
||||
required this.onCliChanged,
|
||||
required this.onModelChanged,
|
||||
required this.onEnabledChanged,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String cliTool;
|
||||
final String model;
|
||||
final bool enabled;
|
||||
final List<String> cliOptions;
|
||||
final List<String> modelOptions;
|
||||
final ValueChanged<String> onCliChanged;
|
||||
final ValueChanged<String> onModelChanged;
|
||||
final ValueChanged<bool> onEnabledChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(description, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (cliOptions.length > 1)
|
||||
_SwitchRow(
|
||||
label: appText('启用', 'Enabled'),
|
||||
value: enabled,
|
||||
onChanged: onEnabledChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('CLI', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: cliOptions.contains(cliTool)
|
||||
? cliTool
|
||||
: cliOptions.first,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
items: cliOptions
|
||||
.map(
|
||||
(t) => DropdownMenuItem(value: t, child: Text(t)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) onCliChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('模型', 'Model'),
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: modelOptions.contains(model)
|
||||
? model
|
||||
: modelOptions.first,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
items: modelOptions
|
||||
.map(
|
||||
(m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text(m, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) onModelChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作流步骤展示
|
||||
class _WorkflowStep extends StatelessWidget {
|
||||
const _WorkflowStep({
|
||||
required this.label,
|
||||
required this.emoji,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String emoji;
|
||||
final String title;
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
),
|
||||
child: Text(label, style: theme.textTheme.labelSmall),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge),
|
||||
Text(desc, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,7 @@ enum SettingsTab {
|
||||
general,
|
||||
workspace,
|
||||
gateway,
|
||||
agents,
|
||||
appearance,
|
||||
diagnostics,
|
||||
experimental,
|
||||
@ -206,6 +207,7 @@ extension SettingsTabCopy on SettingsTab {
|
||||
SettingsTab.general => appText('通用', 'General'),
|
||||
SettingsTab.workspace => appText('工作区', 'Workspace'),
|
||||
SettingsTab.gateway => appText('集成', 'Integrations'),
|
||||
SettingsTab.agents => appText('多 Agent', 'Multi-Agent'),
|
||||
SettingsTab.appearance => appText('外观', 'Appearance'),
|
||||
SettingsTab.diagnostics => appText('诊断', 'Diagnostics'),
|
||||
SettingsTab.experimental => appText('实验特性', 'Experimental'),
|
||||
|
||||
@ -10,6 +10,10 @@ import 'platform_environment.dart';
|
||||
class CodexConfigBridge {
|
||||
static const String _managedBlockStart = '# BEGIN XWORKMATE MANAGED BLOCK';
|
||||
static const String _managedBlockEnd = '# END XWORKMATE MANAGED BLOCK';
|
||||
static const String _managedMcpBlockStart =
|
||||
'# BEGIN XWORKMATE MANAGED MCP BLOCK';
|
||||
static const String _managedMcpBlockEnd =
|
||||
'# END XWORKMATE MANAGED MCP BLOCK';
|
||||
|
||||
final String codexHome;
|
||||
|
||||
@ -118,24 +122,32 @@ class CodexConfigBridge {
|
||||
}
|
||||
|
||||
String _stripManagedBlock(String content) {
|
||||
return _stripBlock(content, _managedBlockStart, _managedBlockEnd);
|
||||
}
|
||||
|
||||
String _stripManagedMcpBlock(String content) {
|
||||
return _stripBlock(content, _managedMcpBlockStart, _managedMcpBlockEnd);
|
||||
}
|
||||
|
||||
String _stripBlock(String content, String startMarker, String endMarker) {
|
||||
if (content.isEmpty) {
|
||||
return content;
|
||||
}
|
||||
|
||||
var remaining = content;
|
||||
while (true) {
|
||||
final start = remaining.indexOf(_managedBlockStart);
|
||||
final start = remaining.indexOf(startMarker);
|
||||
if (start < 0) {
|
||||
break;
|
||||
}
|
||||
final end = remaining.indexOf(_managedBlockEnd, start);
|
||||
final end = remaining.indexOf(endMarker, start);
|
||||
if (end < 0) {
|
||||
remaining = remaining.substring(0, start);
|
||||
break;
|
||||
}
|
||||
remaining =
|
||||
remaining.substring(0, start) +
|
||||
remaining.substring(end + _managedBlockEnd.length);
|
||||
remaining.substring(end + endMarker.length);
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
@ -216,6 +228,55 @@ class CodexConfigBridge {
|
||||
await configFile.writeAsString(buffer.toString());
|
||||
}
|
||||
|
||||
Future<void> configureManagedMcpServers({
|
||||
required List<CodexMcpServer> servers,
|
||||
}) async {
|
||||
final configDir = Directory(codexHome);
|
||||
if (!await configDir.exists()) {
|
||||
await configDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final configFile = File('$codexHome/config.toml');
|
||||
final existingConfig = await configFile.exists()
|
||||
? await configFile.readAsString()
|
||||
: '';
|
||||
final preserved = _stripManagedMcpBlock(existingConfig).trimRight();
|
||||
final managedBlock = _buildManagedMcpBlock(servers);
|
||||
final merged = preserved.isEmpty
|
||||
? '$managedBlock\n'
|
||||
: '$preserved\n\n$managedBlock\n';
|
||||
await configFile.writeAsString(merged);
|
||||
}
|
||||
|
||||
String _buildManagedMcpBlock(List<CodexMcpServer> servers) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln(_managedMcpBlockStart)
|
||||
..writeln('# Generated by XWorkmate - Managed MCP Server Configuration')
|
||||
..writeln('# Last updated: ${DateTime.now().toIso8601String()}')
|
||||
..writeln();
|
||||
|
||||
for (final server in servers) {
|
||||
buffer.writeln('[mcp_servers.${server.name}]');
|
||||
buffer.writeln('command = "${server.command}"');
|
||||
|
||||
if (server.args.isNotEmpty) {
|
||||
buffer.writeln('args = ${_formatTomlArray(server.args)}');
|
||||
}
|
||||
|
||||
if (server.env.isNotEmpty) {
|
||||
buffer.writeln('[mcp_servers.${server.name}.env]');
|
||||
for (final entry in server.env.entries) {
|
||||
buffer.writeln('${entry.key} = "${entry.value}"');
|
||||
}
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
buffer.writeln(_managedMcpBlockEnd);
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
String _formatTomlArray(List<String> items) {
|
||||
if (items.isEmpty) return '[]';
|
||||
if (items.length == 1) return '["${items[0]}"]';
|
||||
|
||||
223
lib/runtime/multi_agent_broker.dart
Normal file
223
lib/runtime/multi_agent_broker.dart
Normal file
@ -0,0 +1,223 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'multi_agent_orchestrator.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
class MultiAgentBrokerServer {
|
||||
MultiAgentBrokerServer(this._orchestrator);
|
||||
|
||||
final MultiAgentOrchestrator _orchestrator;
|
||||
HttpServer? _server;
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
|
||||
Uri? get wsUri => _server == null
|
||||
? null
|
||||
: Uri.parse('ws://127.0.0.1:${_server!.port}/multi-agent-broker');
|
||||
|
||||
Future<void> start() async {
|
||||
if (_server != null) {
|
||||
return;
|
||||
}
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
unawaited(_listen());
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _listen() async {
|
||||
final server = _server;
|
||||
if (server == null) {
|
||||
return;
|
||||
}
|
||||
await for (final request in server) {
|
||||
if (request.uri.path != '/multi-agent-broker' ||
|
||||
!WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
request.response
|
||||
..statusCode = HttpStatus.notFound
|
||||
..close();
|
||||
continue;
|
||||
}
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
unawaited(_handleSocket(socket));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSocket(WebSocket socket) async {
|
||||
await for (final raw in socket) {
|
||||
try {
|
||||
final json = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
final method = json['method'] as String? ?? '';
|
||||
final id = json['id'];
|
||||
if (method != 'run.start') {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': <String, dynamic>{
|
||||
'code': -32601,
|
||||
'message': 'Method not found',
|
||||
},
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final params =
|
||||
(json['params'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
final attachments =
|
||||
((params['attachments'] as List?) ?? const <Object>[])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => CollaborationAttachment(
|
||||
name: item['name']?.toString() ?? '',
|
||||
description: item['description']?.toString() ?? '',
|
||||
path: item['path']?.toString() ?? '',
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final result = await _orchestrator.runCollaboration(
|
||||
taskPrompt: params['taskPrompt'] as String? ?? '',
|
||||
workingDirectory: params['workingDirectory'] as String? ?? '',
|
||||
attachments: attachments,
|
||||
selectedSkills:
|
||||
((params['selectedSkills'] as List?) ?? const <Object>[])
|
||||
.map((item) => item.toString())
|
||||
.toList(growable: false),
|
||||
aiGatewayBaseUrl: params['aiGatewayBaseUrl'] as String? ?? '',
|
||||
aiGatewayApiKey: params['aiGatewayApiKey'] as String? ?? '',
|
||||
onEvent: (event) {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'multi_agent.event',
|
||||
'params': event.toJson(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': result.toJson(),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'error': <String, dynamic>{
|
||||
'code': -32000,
|
||||
'message': error.toString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultiAgentBrokerClient {
|
||||
MultiAgentBrokerClient(this._uri);
|
||||
|
||||
final Uri _uri;
|
||||
|
||||
Stream<MultiAgentRunEvent> runTask({
|
||||
required String taskPrompt,
|
||||
required String workingDirectory,
|
||||
required List<CollaborationAttachment> attachments,
|
||||
required List<String> selectedSkills,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async* {
|
||||
final socket = await WebSocket.connect(_uri.toString());
|
||||
final controller = StreamController<MultiAgentRunEvent>();
|
||||
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
|
||||
socket.listen(
|
||||
(raw) {
|
||||
final json = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
final method = json['method'] as String?;
|
||||
if (method == 'multi_agent.event') {
|
||||
final params =
|
||||
(json['params'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
controller.add(MultiAgentRunEvent.fromJson(params));
|
||||
return;
|
||||
}
|
||||
if (json['id']?.toString() == requestId && json['result'] is Map) {
|
||||
final result = (json['result'] as Map).cast<String, dynamic>();
|
||||
controller.add(
|
||||
MultiAgentRunEvent(
|
||||
type: 'result',
|
||||
title: 'Multi-Agent',
|
||||
message: result['success'] == true
|
||||
? 'Collaboration completed.'
|
||||
: 'Collaboration failed.',
|
||||
pending: false,
|
||||
error: result['success'] != true,
|
||||
data: result,
|
||||
),
|
||||
);
|
||||
unawaited(controller.close());
|
||||
unawaited(socket.close());
|
||||
return;
|
||||
}
|
||||
if (json['error'] is Map) {
|
||||
final error = (json['error'] as Map).cast<String, dynamic>();
|
||||
controller.add(
|
||||
MultiAgentRunEvent(
|
||||
type: 'error',
|
||||
title: 'Multi-Agent',
|
||||
message: error['message']?.toString() ?? 'Broker error',
|
||||
pending: false,
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
unawaited(controller.close());
|
||||
unawaited(socket.close());
|
||||
}
|
||||
},
|
||||
onError: controller.addError,
|
||||
onDone: () {
|
||||
if (!controller.isClosed) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': requestId,
|
||||
'method': 'run.start',
|
||||
'params': <String, dynamic>{
|
||||
'taskPrompt': taskPrompt,
|
||||
'workingDirectory': workingDirectory,
|
||||
'attachments': attachments
|
||||
.map(
|
||||
(item) => <String, dynamic>{
|
||||
'name': item.name,
|
||||
'description': item.description,
|
||||
'path': item.path,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
'selectedSkills': selectedSkills,
|
||||
'aiGatewayBaseUrl': aiGatewayBaseUrl,
|
||||
'aiGatewayApiKey': aiGatewayApiKey,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
yield* controller.stream;
|
||||
}
|
||||
}
|
||||
420
lib/runtime/multi_agent_mounts.dart
Normal file
420
lib/runtime/multi_agent_mounts.dart
Normal file
@ -0,0 +1,420 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'codex_config_bridge.dart';
|
||||
import 'opencode_config_bridge.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
class MultiAgentMountManager {
|
||||
MultiAgentMountManager({
|
||||
CodexConfigBridge? codexConfigBridge,
|
||||
OpencodeConfigBridge? opencodeConfigBridge,
|
||||
}) : _adapters = <CliMountAdapter>[
|
||||
CodexMountAdapter(codexConfigBridge ?? CodexConfigBridge()),
|
||||
ClaudeMountAdapter(),
|
||||
GeminiMountAdapter(),
|
||||
OpencodeMountAdapter(opencodeConfigBridge ?? OpencodeConfigBridge()),
|
||||
OpenClawMountAdapter(),
|
||||
];
|
||||
|
||||
final List<CliMountAdapter> _adapters;
|
||||
|
||||
Future<MultiAgentConfig> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final states = <ManagedMountTargetState>[];
|
||||
for (final adapter in _adapters) {
|
||||
try {
|
||||
states.add(
|
||||
await adapter.reconcile(config: config, aiGatewayUrl: aiGatewayUrl),
|
||||
);
|
||||
} catch (error) {
|
||||
states.add(
|
||||
ManagedMountTargetState.placeholder(
|
||||
targetId: adapter.targetId,
|
||||
label: adapter.label,
|
||||
supportsSkills: adapter.supportsSkills,
|
||||
supportsMcp: adapter.supportsMcp,
|
||||
supportsAiGatewayInjection: adapter.supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: await adapter.isInstalled(),
|
||||
discoveryState: 'error',
|
||||
syncState: 'error',
|
||||
detail: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return config.copyWith(mountTargets: states);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CliMountAdapter {
|
||||
String get targetId;
|
||||
String get label;
|
||||
bool get supportsSkills;
|
||||
bool get supportsMcp;
|
||||
bool get supportsAiGatewayInjection;
|
||||
|
||||
Future<bool> isInstalled();
|
||||
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
});
|
||||
|
||||
Future<String> _runCommand(List<String> command) async {
|
||||
final result = await Process.run(
|
||||
command.first,
|
||||
command.sublist(1),
|
||||
runInShell: true,
|
||||
);
|
||||
final stdout = '${result.stdout}'.trim();
|
||||
final stderr = '${result.stderr}'.trim();
|
||||
return stdout.isNotEmpty ? stdout : stderr;
|
||||
}
|
||||
|
||||
Future<int> _countListedEntries(List<String> command) async {
|
||||
final output = await _runCommand(command);
|
||||
if (output.isEmpty ||
|
||||
output.contains('No MCP servers configured') ||
|
||||
output.contains('No MCP servers configured yet') ||
|
||||
output.contains('No MCP servers configured.')) {
|
||||
return 0;
|
||||
}
|
||||
return output
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.where((item) => !item.startsWith('Usage:'))
|
||||
.where((item) => !item.startsWith('┌'))
|
||||
.where((item) => !item.startsWith('│'))
|
||||
.where((item) => !item.startsWith('└'))
|
||||
.length;
|
||||
}
|
||||
|
||||
Future<bool> _binaryExists(String command) async {
|
||||
final check = await Process.run(
|
||||
Platform.isWindows ? 'where' : 'which',
|
||||
<String>[command],
|
||||
runInShell: true,
|
||||
);
|
||||
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
int countMcpTomlSections(String content) {
|
||||
return RegExp(
|
||||
r'^\[mcp_servers\.[^\]]+\]',
|
||||
multiLine: true,
|
||||
).allMatches(content).length;
|
||||
}
|
||||
}
|
||||
|
||||
class CodexMountAdapter extends CliMountAdapter {
|
||||
CodexMountAdapter(this._bridge);
|
||||
|
||||
final CodexConfigBridge _bridge;
|
||||
|
||||
@override
|
||||
String get targetId => 'codex';
|
||||
|
||||
@override
|
||||
String get label => 'Codex';
|
||||
|
||||
@override
|
||||
bool get supportsSkills => true;
|
||||
|
||||
@override
|
||||
bool get supportsMcp => true;
|
||||
|
||||
@override
|
||||
bool get supportsAiGatewayInjection => true;
|
||||
|
||||
@override
|
||||
Future<bool> isInstalled() => _binaryExists('codex');
|
||||
|
||||
@override
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final available = await isInstalled();
|
||||
final configFile = File('${_bridge.codexHome}/config.toml');
|
||||
final content = await configFile.exists()
|
||||
? await configFile.readAsString()
|
||||
: '';
|
||||
final discoveredMcpCount = countMcpTomlSections(content);
|
||||
final managedMcpServers = config.managedMcpServers
|
||||
.where((item) => item.enabled && item.command.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
|
||||
await _bridge.configureManagedMcpServers(
|
||||
servers: managedMcpServers
|
||||
.map(
|
||||
(item) => CodexMcpServer(
|
||||
name: item.id,
|
||||
command: item.command,
|
||||
args: item.args,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
return ManagedMountTargetState.placeholder(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'missing',
|
||||
syncState: !available
|
||||
? 'missing'
|
||||
: config.autoSync
|
||||
? 'ready'
|
||||
: 'disabled',
|
||||
discoveredMcpCount: discoveredMcpCount,
|
||||
managedMcpCount: managedMcpServers.length,
|
||||
detail: aiGatewayUrl.isNotEmpty
|
||||
? 'AI Gateway uses launch-scoped defaults for collaboration runs.'
|
||||
: 'AI Gateway not configured.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeMountAdapter extends CliMountAdapter {
|
||||
@override
|
||||
String get targetId => 'claude';
|
||||
|
||||
@override
|
||||
String get label => 'Claude';
|
||||
|
||||
@override
|
||||
bool get supportsSkills => true;
|
||||
|
||||
@override
|
||||
bool get supportsMcp => true;
|
||||
|
||||
@override
|
||||
bool get supportsAiGatewayInjection => true;
|
||||
|
||||
@override
|
||||
Future<bool> isInstalled() => _binaryExists('claude');
|
||||
|
||||
@override
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final available = await isInstalled();
|
||||
final discoveredMcpCount = available
|
||||
? await _countListedEntries(<String>['claude', 'mcp', 'list'])
|
||||
: 0;
|
||||
return ManagedMountTargetState.placeholder(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'missing',
|
||||
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
|
||||
discoveredMcpCount: discoveredMcpCount,
|
||||
managedMcpCount: config.managedMcpServers
|
||||
.where((item) => item.enabled)
|
||||
.length,
|
||||
detail:
|
||||
'MCP discovery uses `claude mcp list`; AI Gateway stays launch-scoped.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GeminiMountAdapter extends CliMountAdapter {
|
||||
@override
|
||||
String get targetId => 'gemini';
|
||||
|
||||
@override
|
||||
String get label => 'Gemini';
|
||||
|
||||
@override
|
||||
bool get supportsSkills => true;
|
||||
|
||||
@override
|
||||
bool get supportsMcp => true;
|
||||
|
||||
@override
|
||||
bool get supportsAiGatewayInjection => true;
|
||||
|
||||
@override
|
||||
Future<bool> isInstalled() => _binaryExists('gemini');
|
||||
|
||||
@override
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final available = await isInstalled();
|
||||
final discoveredMcpCount = available
|
||||
? await _countListedEntries(<String>['gemini', 'mcp', 'list'])
|
||||
: 0;
|
||||
return ManagedMountTargetState.placeholder(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'missing',
|
||||
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
|
||||
discoveredMcpCount: discoveredMcpCount,
|
||||
managedMcpCount: config.managedMcpServers
|
||||
.where((item) => item.enabled)
|
||||
.length,
|
||||
detail:
|
||||
'MCP discovery uses `gemini mcp list`; AI Gateway stays launch-scoped.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OpencodeMountAdapter extends CliMountAdapter {
|
||||
OpencodeMountAdapter(this._bridge);
|
||||
|
||||
final OpencodeConfigBridge _bridge;
|
||||
|
||||
@override
|
||||
String get targetId => 'opencode';
|
||||
|
||||
@override
|
||||
String get label => 'OpenCode';
|
||||
|
||||
@override
|
||||
bool get supportsSkills => true;
|
||||
|
||||
@override
|
||||
bool get supportsMcp => true;
|
||||
|
||||
@override
|
||||
bool get supportsAiGatewayInjection => true;
|
||||
|
||||
@override
|
||||
Future<bool> isInstalled() => _binaryExists('opencode');
|
||||
|
||||
@override
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final available = await isInstalled();
|
||||
final content = await _bridge.readConfig();
|
||||
final discoveredMcpCount = countMcpTomlSections(content);
|
||||
final managedMcpServers = config.managedMcpServers
|
||||
.where((item) => item.enabled)
|
||||
.toList(growable: false);
|
||||
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
|
||||
await _bridge.configureManagedMcpServers(
|
||||
servers: managedMcpServers
|
||||
.map(
|
||||
(item) => OpencodeMcpServer(
|
||||
name: item.id,
|
||||
command: item.command,
|
||||
url: item.url,
|
||||
args: item.args,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
return ManagedMountTargetState.placeholder(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'missing',
|
||||
syncState: !available
|
||||
? 'missing'
|
||||
: config.autoSync
|
||||
? 'ready'
|
||||
: 'disabled',
|
||||
discoveredMcpCount: discoveredMcpCount,
|
||||
managedMcpCount: managedMcpServers.length,
|
||||
detail: 'Managed MCP config is preserved in ~/.opencode/config.toml.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OpenClawMountAdapter extends CliMountAdapter {
|
||||
@override
|
||||
String get targetId => 'openclaw';
|
||||
|
||||
@override
|
||||
String get label => 'OpenClaw';
|
||||
|
||||
@override
|
||||
bool get supportsSkills => true;
|
||||
|
||||
@override
|
||||
bool get supportsMcp => false;
|
||||
|
||||
@override
|
||||
bool get supportsAiGatewayInjection => true;
|
||||
|
||||
@override
|
||||
Future<bool> isInstalled() => _binaryExists('openclaw');
|
||||
|
||||
@override
|
||||
Future<ManagedMountTargetState> reconcile({
|
||||
required MultiAgentConfig config,
|
||||
required String aiGatewayUrl,
|
||||
}) async {
|
||||
final available = await isInstalled();
|
||||
final configFile = File(
|
||||
'${Platform.environment['HOME'] ?? ''}/.openclaw/openclaw.json',
|
||||
);
|
||||
var discoveredSkillCount = 0;
|
||||
var detail = 'OpenClaw acts as the host/control plane mount.';
|
||||
if (await configFile.exists()) {
|
||||
try {
|
||||
final decoded = jsonDecode(await configFile.readAsString());
|
||||
final agents =
|
||||
(decoded is Map<String, dynamic> &&
|
||||
decoded['agents'] is Map<String, dynamic> &&
|
||||
(decoded['agents'] as Map<String, dynamic>)['list'] is List)
|
||||
? ((decoded['agents'] as Map<String, dynamic>)['list'] as List)
|
||||
.length
|
||||
: 0;
|
||||
final skillsDir = Directory(
|
||||
'${Platform.environment['HOME'] ?? ''}/.openclaw/skills',
|
||||
);
|
||||
if (await skillsDir.exists()) {
|
||||
discoveredSkillCount = await skillsDir
|
||||
.list()
|
||||
.where((entity) => entity is File || entity is Directory)
|
||||
.length;
|
||||
}
|
||||
detail = 'agents: $agents · skills: $discoveredSkillCount';
|
||||
} catch (_) {
|
||||
detail = 'OpenClaw config detected but could not be fully parsed.';
|
||||
}
|
||||
}
|
||||
return ManagedMountTargetState.placeholder(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
).copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'missing',
|
||||
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
|
||||
discoveredSkillCount: discoveredSkillCount,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
1059
lib/runtime/multi_agent_orchestrator.dart
Normal file
1059
lib/runtime/multi_agent_orchestrator.dart
Normal file
File diff suppressed because it is too large
Load Diff
118
lib/runtime/opencode_config_bridge.dart
Normal file
118
lib/runtime/opencode_config_bridge.dart
Normal file
@ -0,0 +1,118 @@
|
||||
import 'dart:io';
|
||||
|
||||
class OpencodeConfigBridge {
|
||||
OpencodeConfigBridge({String? opencodeHome})
|
||||
: opencodeHome =
|
||||
opencodeHome ?? '${Platform.environment['HOME'] ?? ''}/.opencode';
|
||||
|
||||
static const String _managedMcpBlockStart =
|
||||
'# BEGIN XWORKMATE MANAGED MCP BLOCK';
|
||||
static const String _managedMcpBlockEnd = '# END XWORKMATE MANAGED MCP BLOCK';
|
||||
|
||||
final String opencodeHome;
|
||||
|
||||
Future<void> configureManagedMcpServers({
|
||||
required List<OpencodeMcpServer> servers,
|
||||
}) async {
|
||||
final configDir = Directory(opencodeHome);
|
||||
if (!await configDir.exists()) {
|
||||
await configDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final configFile = File('$opencodeHome/config.toml');
|
||||
final existingConfig = await configFile.exists()
|
||||
? await configFile.readAsString()
|
||||
: '';
|
||||
final preserved = _stripManagedMcpBlock(existingConfig).trimRight();
|
||||
final managedBlock = _buildManagedMcpBlock(servers);
|
||||
final merged = preserved.isEmpty
|
||||
? '$managedBlock\n'
|
||||
: '$preserved\n\n$managedBlock\n';
|
||||
await configFile.writeAsString(merged);
|
||||
}
|
||||
|
||||
Future<String> readConfig() async {
|
||||
final configFile = File('$opencodeHome/config.toml');
|
||||
if (!await configFile.exists()) {
|
||||
return '';
|
||||
}
|
||||
return configFile.readAsString();
|
||||
}
|
||||
|
||||
String _stripManagedMcpBlock(String content) {
|
||||
if (content.isEmpty) {
|
||||
return content;
|
||||
}
|
||||
|
||||
var remaining = content;
|
||||
while (true) {
|
||||
final start = remaining.indexOf(_managedMcpBlockStart);
|
||||
if (start < 0) {
|
||||
break;
|
||||
}
|
||||
final end = remaining.indexOf(_managedMcpBlockEnd, start);
|
||||
if (end < 0) {
|
||||
remaining = remaining.substring(0, start);
|
||||
break;
|
||||
}
|
||||
remaining =
|
||||
remaining.substring(0, start) +
|
||||
remaining.substring(end + _managedMcpBlockEnd.length);
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
String _buildManagedMcpBlock(List<OpencodeMcpServer> servers) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln(_managedMcpBlockStart)
|
||||
..writeln('# Generated by XWorkmate - Managed MCP Server Configuration')
|
||||
..writeln('# Last updated: ${DateTime.now().toIso8601String()}')
|
||||
..writeln();
|
||||
|
||||
for (final server in servers) {
|
||||
buffer.writeln('[mcp_servers.${server.name}]');
|
||||
if (server.url.trim().isNotEmpty) {
|
||||
buffer.writeln('url = "${server.url.trim()}"');
|
||||
} else {
|
||||
buffer.writeln('type = "stdio"');
|
||||
buffer.writeln('command = "${server.command}"');
|
||||
if (server.args.isNotEmpty) {
|
||||
buffer.writeln('args = ${_formatTomlArray(server.args)}');
|
||||
}
|
||||
}
|
||||
if (server.env.isNotEmpty) {
|
||||
final entries = server.env.entries
|
||||
.map((entry) => '${entry.key} = "${entry.value}"')
|
||||
.join(', ');
|
||||
buffer.writeln('env = { $entries }');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
buffer.writeln(_managedMcpBlockEnd);
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
String _formatTomlArray(List<String> items) {
|
||||
if (items.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
return '[${items.map((item) => '"$item"').join(', ')}]';
|
||||
}
|
||||
}
|
||||
|
||||
class OpencodeMcpServer {
|
||||
const OpencodeMcpServer({
|
||||
required this.name,
|
||||
this.command = '',
|
||||
this.url = '',
|
||||
this.args = const <String>[],
|
||||
this.env = const <String, String>{},
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String command;
|
||||
final String url;
|
||||
final List<String> args;
|
||||
final Map<String, String> env;
|
||||
}
|
||||
@ -886,6 +886,7 @@ class SettingsSnapshot {
|
||||
required this.ollamaCloud,
|
||||
required this.vault,
|
||||
required this.aiGateway,
|
||||
required this.multiAgent,
|
||||
required this.experimentalCanvas,
|
||||
required this.experimentalBridge,
|
||||
required this.experimentalDebug,
|
||||
@ -915,6 +916,7 @@ class SettingsSnapshot {
|
||||
final OllamaCloudConfig ollamaCloud;
|
||||
final VaultConfig vault;
|
||||
final AiGatewayProfile aiGateway;
|
||||
final MultiAgentConfig multiAgent;
|
||||
final bool experimentalCanvas;
|
||||
final bool experimentalBridge;
|
||||
final bool experimentalDebug;
|
||||
@ -945,6 +947,7 @@ class SettingsSnapshot {
|
||||
ollamaCloud: OllamaCloudConfig.defaults(),
|
||||
vault: VaultConfig.defaults(),
|
||||
aiGateway: AiGatewayProfile.defaults(),
|
||||
multiAgent: MultiAgentConfig.defaults(),
|
||||
experimentalCanvas: false,
|
||||
experimentalBridge: false,
|
||||
experimentalDebug: false,
|
||||
@ -976,6 +979,7 @@ class SettingsSnapshot {
|
||||
OllamaCloudConfig? ollamaCloud,
|
||||
VaultConfig? vault,
|
||||
AiGatewayProfile? aiGateway,
|
||||
MultiAgentConfig? multiAgent,
|
||||
bool? experimentalCanvas,
|
||||
bool? experimentalBridge,
|
||||
bool? experimentalDebug,
|
||||
@ -1005,6 +1009,7 @@ class SettingsSnapshot {
|
||||
ollamaCloud: ollamaCloud ?? this.ollamaCloud,
|
||||
vault: vault ?? this.vault,
|
||||
aiGateway: aiGateway ?? this.aiGateway,
|
||||
multiAgent: multiAgent ?? this.multiAgent,
|
||||
experimentalCanvas: experimentalCanvas ?? this.experimentalCanvas,
|
||||
experimentalBridge: experimentalBridge ?? this.experimentalBridge,
|
||||
experimentalDebug: experimentalDebug ?? this.experimentalDebug,
|
||||
@ -1041,6 +1046,7 @@ class SettingsSnapshot {
|
||||
'ollamaCloud': ollamaCloud.toJson(),
|
||||
'vault': vault.toJson(),
|
||||
'aiGateway': aiGateway.toJson(),
|
||||
'multiAgent': multiAgent.toJson(),
|
||||
'experimentalCanvas': experimentalCanvas,
|
||||
'experimentalBridge': experimentalBridge,
|
||||
'experimentalDebug': experimentalDebug,
|
||||
@ -1115,6 +1121,9 @@ class SettingsSnapshot {
|
||||
(json['apisix'] as Map?)?.cast<String, dynamic>() ??
|
||||
const {},
|
||||
),
|
||||
multiAgent: MultiAgentConfig.fromJson(
|
||||
(json['multiAgent'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
experimentalCanvas: json['experimentalCanvas'] as bool? ?? false,
|
||||
experimentalBridge: json['experimentalBridge'] as bool? ?? false,
|
||||
experimentalDebug: json['experimentalDebug'] as bool? ?? false,
|
||||
@ -1835,3 +1844,736 @@ class LocalDeviceIdentity {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 多 Agent 协作角色
|
||||
enum MultiAgentRole {
|
||||
architect, // 调度者/架构师:任务分解、流程编排
|
||||
engineer, // 工程师:代码实现
|
||||
testerDoc, // 测试/评审:测试生成、代码审阅
|
||||
}
|
||||
|
||||
extension MultiAgentRoleCopy on MultiAgentRole {
|
||||
String get label => switch (this) {
|
||||
MultiAgentRole.architect => 'Architect(调度者)',
|
||||
MultiAgentRole.engineer => 'Engineer(工程师)',
|
||||
MultiAgentRole.testerDoc => 'Tester/Doc(评审)',
|
||||
};
|
||||
|
||||
String get description => switch (this) {
|
||||
MultiAgentRole.architect => '负责任务分解、流程设计、宏观规划',
|
||||
MultiAgentRole.engineer => '负责代码实现、重构、调试',
|
||||
MultiAgentRole.testerDoc => '负责测试用例生成、代码审阅、文档撰写',
|
||||
};
|
||||
}
|
||||
|
||||
enum AiGatewayInjectionPolicy { disabled, launchScoped, appManagedDefault }
|
||||
|
||||
extension AiGatewayInjectionPolicyCopy on AiGatewayInjectionPolicy {
|
||||
String get label => switch (this) {
|
||||
AiGatewayInjectionPolicy.disabled => appText('禁用', 'Disabled'),
|
||||
AiGatewayInjectionPolicy.launchScoped => appText(
|
||||
'仅当前协作运行',
|
||||
'Launch scoped',
|
||||
),
|
||||
AiGatewayInjectionPolicy.appManagedDefault => appText(
|
||||
'XWorkmate 默认',
|
||||
'XWorkmate default',
|
||||
),
|
||||
};
|
||||
|
||||
static AiGatewayInjectionPolicy fromJsonValue(String? value) {
|
||||
return AiGatewayInjectionPolicy.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () => AiGatewayInjectionPolicy.appManagedDefault,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 Agent Worker 配置
|
||||
class AgentWorkerConfig {
|
||||
const AgentWorkerConfig({
|
||||
required this.role,
|
||||
required this.cliTool,
|
||||
required this.model,
|
||||
required this.enabled,
|
||||
this.maxRetries = 2,
|
||||
});
|
||||
|
||||
final MultiAgentRole role;
|
||||
final String cliTool; // 'claude' | 'codex' | 'gemini'
|
||||
final String model;
|
||||
final bool enabled;
|
||||
final int maxRetries;
|
||||
|
||||
AgentWorkerConfig copyWith({
|
||||
MultiAgentRole? role,
|
||||
String? cliTool,
|
||||
String? model,
|
||||
bool? enabled,
|
||||
int? maxRetries,
|
||||
}) {
|
||||
return AgentWorkerConfig(
|
||||
role: role ?? this.role,
|
||||
cliTool: cliTool ?? this.cliTool,
|
||||
model: model ?? this.model,
|
||||
enabled: enabled ?? this.enabled,
|
||||
maxRetries: maxRetries ?? this.maxRetries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedSkillEntry {
|
||||
const ManagedSkillEntry({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.source,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final String source;
|
||||
final bool selected;
|
||||
|
||||
ManagedSkillEntry copyWith({
|
||||
String? key,
|
||||
String? label,
|
||||
String? source,
|
||||
bool? selected,
|
||||
}) {
|
||||
return ManagedSkillEntry(
|
||||
key: key ?? this.key,
|
||||
label: label ?? this.label,
|
||||
source: source ?? this.source,
|
||||
selected: selected ?? this.selected,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'key': key, 'label': label, 'source': source, 'selected': selected};
|
||||
}
|
||||
|
||||
factory ManagedSkillEntry.fromJson(Map<String, dynamic> json) {
|
||||
return ManagedSkillEntry(
|
||||
key: json['key'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
selected: json['selected'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedMcpServerEntry {
|
||||
const ManagedMcpServerEntry({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.transport,
|
||||
required this.command,
|
||||
required this.url,
|
||||
required this.args,
|
||||
required this.envKeys,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String transport;
|
||||
final String command;
|
||||
final String url;
|
||||
final List<String> args;
|
||||
final List<String> envKeys;
|
||||
final bool enabled;
|
||||
|
||||
ManagedMcpServerEntry copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? transport,
|
||||
String? command,
|
||||
String? url,
|
||||
List<String>? args,
|
||||
List<String>? envKeys,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return ManagedMcpServerEntry(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
transport: transport ?? this.transport,
|
||||
command: command ?? this.command,
|
||||
url: url ?? this.url,
|
||||
args: args ?? this.args,
|
||||
envKeys: envKeys ?? this.envKeys,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'transport': transport,
|
||||
'command': command,
|
||||
'url': url,
|
||||
'args': args,
|
||||
'envKeys': envKeys,
|
||||
'enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
factory ManagedMcpServerEntry.fromJson(Map<String, dynamic> json) {
|
||||
final rawArgs = json['args'];
|
||||
final rawEnvKeys = json['envKeys'];
|
||||
return ManagedMcpServerEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
transport: json['transport'] as String? ?? 'stdio',
|
||||
command: json['command'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
args: rawArgs is List
|
||||
? rawArgs.map((item) => item.toString()).toList(growable: false)
|
||||
: const <String>[],
|
||||
envKeys: rawEnvKeys is List
|
||||
? rawEnvKeys.map((item) => item.toString()).toList(growable: false)
|
||||
: const <String>[],
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedMountTargetState {
|
||||
const ManagedMountTargetState({
|
||||
required this.targetId,
|
||||
required this.label,
|
||||
required this.available,
|
||||
required this.supportsSkills,
|
||||
required this.supportsMcp,
|
||||
required this.supportsAiGatewayInjection,
|
||||
required this.discoveryState,
|
||||
required this.syncState,
|
||||
required this.discoveredSkillCount,
|
||||
required this.discoveredMcpCount,
|
||||
required this.managedMcpCount,
|
||||
required this.detail,
|
||||
});
|
||||
|
||||
final String targetId;
|
||||
final String label;
|
||||
final bool available;
|
||||
final bool supportsSkills;
|
||||
final bool supportsMcp;
|
||||
final bool supportsAiGatewayInjection;
|
||||
final String discoveryState;
|
||||
final String syncState;
|
||||
final int discoveredSkillCount;
|
||||
final int discoveredMcpCount;
|
||||
final int managedMcpCount;
|
||||
final String detail;
|
||||
|
||||
ManagedMountTargetState copyWith({
|
||||
String? targetId,
|
||||
String? label,
|
||||
bool? available,
|
||||
bool? supportsSkills,
|
||||
bool? supportsMcp,
|
||||
bool? supportsAiGatewayInjection,
|
||||
String? discoveryState,
|
||||
String? syncState,
|
||||
int? discoveredSkillCount,
|
||||
int? discoveredMcpCount,
|
||||
int? managedMcpCount,
|
||||
String? detail,
|
||||
}) {
|
||||
return ManagedMountTargetState(
|
||||
targetId: targetId ?? this.targetId,
|
||||
label: label ?? this.label,
|
||||
available: available ?? this.available,
|
||||
supportsSkills: supportsSkills ?? this.supportsSkills,
|
||||
supportsMcp: supportsMcp ?? this.supportsMcp,
|
||||
supportsAiGatewayInjection:
|
||||
supportsAiGatewayInjection ?? this.supportsAiGatewayInjection,
|
||||
discoveryState: discoveryState ?? this.discoveryState,
|
||||
syncState: syncState ?? this.syncState,
|
||||
discoveredSkillCount: discoveredSkillCount ?? this.discoveredSkillCount,
|
||||
discoveredMcpCount: discoveredMcpCount ?? this.discoveredMcpCount,
|
||||
managedMcpCount: managedMcpCount ?? this.managedMcpCount,
|
||||
detail: detail ?? this.detail,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'targetId': targetId,
|
||||
'label': label,
|
||||
'available': available,
|
||||
'supportsSkills': supportsSkills,
|
||||
'supportsMcp': supportsMcp,
|
||||
'supportsAiGatewayInjection': supportsAiGatewayInjection,
|
||||
'discoveryState': discoveryState,
|
||||
'syncState': syncState,
|
||||
'discoveredSkillCount': discoveredSkillCount,
|
||||
'discoveredMcpCount': discoveredMcpCount,
|
||||
'managedMcpCount': managedMcpCount,
|
||||
'detail': detail,
|
||||
};
|
||||
}
|
||||
|
||||
factory ManagedMountTargetState.fromJson(Map<String, dynamic> json) {
|
||||
return ManagedMountTargetState(
|
||||
targetId: json['targetId'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? false,
|
||||
supportsSkills: json['supportsSkills'] as bool? ?? false,
|
||||
supportsMcp: json['supportsMcp'] as bool? ?? false,
|
||||
supportsAiGatewayInjection:
|
||||
json['supportsAiGatewayInjection'] as bool? ?? false,
|
||||
discoveryState: json['discoveryState'] as String? ?? 'idle',
|
||||
syncState: json['syncState'] as String? ?? 'idle',
|
||||
discoveredSkillCount: json['discoveredSkillCount'] as int? ?? 0,
|
||||
discoveredMcpCount: json['discoveredMcpCount'] as int? ?? 0,
|
||||
managedMcpCount: json['managedMcpCount'] as int? ?? 0,
|
||||
detail: json['detail'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
factory ManagedMountTargetState.placeholder({
|
||||
required String targetId,
|
||||
required String label,
|
||||
required bool supportsSkills,
|
||||
required bool supportsMcp,
|
||||
required bool supportsAiGatewayInjection,
|
||||
}) {
|
||||
return ManagedMountTargetState(
|
||||
targetId: targetId,
|
||||
label: label,
|
||||
available: false,
|
||||
supportsSkills: supportsSkills,
|
||||
supportsMcp: supportsMcp,
|
||||
supportsAiGatewayInjection: supportsAiGatewayInjection,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
);
|
||||
}
|
||||
|
||||
static List<ManagedMountTargetState> defaults() {
|
||||
return const <ManagedMountTargetState>[
|
||||
ManagedMountTargetState(
|
||||
targetId: 'codex',
|
||||
label: 'Codex',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'claude',
|
||||
label: 'Claude',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'gemini',
|
||||
label: 'Gemini',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'openclaw',
|
||||
label: 'OpenClaw',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: false,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 多 Agent 协作配置
|
||||
class MultiAgentConfig {
|
||||
const MultiAgentConfig({
|
||||
required this.enabled,
|
||||
required this.autoSync,
|
||||
required this.architect,
|
||||
required this.engineer,
|
||||
required this.tester,
|
||||
required this.ollamaEndpoint,
|
||||
required this.maxIterations,
|
||||
required this.minAcceptableScore,
|
||||
required this.timeoutSeconds,
|
||||
required this.aiGatewayInjectionPolicy,
|
||||
required this.managedSkills,
|
||||
required this.managedMcpServers,
|
||||
required this.mountTargets,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool autoSync;
|
||||
final AgentWorkerConfig architect;
|
||||
final AgentWorkerConfig engineer;
|
||||
final AgentWorkerConfig tester;
|
||||
final String ollamaEndpoint;
|
||||
final int maxIterations;
|
||||
final int minAcceptableScore;
|
||||
final int timeoutSeconds;
|
||||
final AiGatewayInjectionPolicy aiGatewayInjectionPolicy;
|
||||
final List<ManagedSkillEntry> managedSkills;
|
||||
final List<ManagedMcpServerEntry> managedMcpServers;
|
||||
final List<ManagedMountTargetState> mountTargets;
|
||||
|
||||
/// Architect 配置的便捷访问
|
||||
bool get architectEnabled => architect.enabled;
|
||||
String get architectTool => architect.cliTool;
|
||||
String get architectModel => architect.model;
|
||||
|
||||
/// Engineer 配置的便捷访问
|
||||
String get engineerTool => engineer.cliTool;
|
||||
String get engineerModel => engineer.model;
|
||||
|
||||
/// Tester 配置的便捷访问
|
||||
String get testerTool => tester.cliTool;
|
||||
String get testerModel => tester.model;
|
||||
|
||||
factory MultiAgentConfig.defaults() {
|
||||
return MultiAgentConfig(
|
||||
enabled: false,
|
||||
autoSync: true,
|
||||
architect: const AgentWorkerConfig(
|
||||
role: MultiAgentRole.architect,
|
||||
cliTool: 'gemini',
|
||||
model: 'gemini-2.0-flash',
|
||||
enabled: true,
|
||||
),
|
||||
engineer: const AgentWorkerConfig(
|
||||
role: MultiAgentRole.engineer,
|
||||
cliTool: 'claude',
|
||||
model: 'qwen2.5-coder:latest',
|
||||
enabled: true,
|
||||
),
|
||||
tester: const AgentWorkerConfig(
|
||||
role: MultiAgentRole.testerDoc,
|
||||
cliTool: 'codex',
|
||||
model: 'gpt-oss:20b',
|
||||
enabled: true,
|
||||
),
|
||||
ollamaEndpoint: 'http://127.0.0.1:11434',
|
||||
maxIterations: 3,
|
||||
minAcceptableScore: 7,
|
||||
timeoutSeconds: 120,
|
||||
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.appManagedDefault,
|
||||
managedSkills: const <ManagedSkillEntry>[],
|
||||
managedMcpServers: const <ManagedMcpServerEntry>[],
|
||||
mountTargets: const <ManagedMountTargetState>[
|
||||
ManagedMountTargetState(
|
||||
targetId: 'codex',
|
||||
label: 'Codex',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'claude',
|
||||
label: 'Claude',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'gemini',
|
||||
label: 'Gemini',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: true,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
ManagedMountTargetState(
|
||||
targetId: 'openclaw',
|
||||
label: 'OpenClaw',
|
||||
available: false,
|
||||
supportsSkills: true,
|
||||
supportsMcp: false,
|
||||
supportsAiGatewayInjection: true,
|
||||
discoveryState: 'idle',
|
||||
syncState: 'idle',
|
||||
discoveredSkillCount: 0,
|
||||
discoveredMcpCount: 0,
|
||||
managedMcpCount: 0,
|
||||
detail: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
MultiAgentConfig copyWith({
|
||||
bool? enabled,
|
||||
bool? autoSync,
|
||||
AgentWorkerConfig? architect,
|
||||
AgentWorkerConfig? engineer,
|
||||
AgentWorkerConfig? tester,
|
||||
String? ollamaEndpoint,
|
||||
int? maxIterations,
|
||||
int? minAcceptableScore,
|
||||
int? timeoutSeconds,
|
||||
AiGatewayInjectionPolicy? aiGatewayInjectionPolicy,
|
||||
List<ManagedSkillEntry>? managedSkills,
|
||||
List<ManagedMcpServerEntry>? managedMcpServers,
|
||||
List<ManagedMountTargetState>? mountTargets,
|
||||
}) {
|
||||
return MultiAgentConfig(
|
||||
enabled: enabled ?? this.enabled,
|
||||
autoSync: autoSync ?? this.autoSync,
|
||||
architect: architect ?? this.architect,
|
||||
engineer: engineer ?? this.engineer,
|
||||
tester: tester ?? this.tester,
|
||||
ollamaEndpoint: ollamaEndpoint ?? this.ollamaEndpoint,
|
||||
maxIterations: maxIterations ?? this.maxIterations,
|
||||
minAcceptableScore: minAcceptableScore ?? this.minAcceptableScore,
|
||||
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
|
||||
aiGatewayInjectionPolicy:
|
||||
aiGatewayInjectionPolicy ?? this.aiGatewayInjectionPolicy,
|
||||
managedSkills: managedSkills ?? this.managedSkills,
|
||||
managedMcpServers: managedMcpServers ?? this.managedMcpServers,
|
||||
mountTargets: mountTargets ?? this.mountTargets,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'autoSync': autoSync,
|
||||
'architect': {
|
||||
'role': architect.role.name,
|
||||
'cliTool': architect.cliTool,
|
||||
'model': architect.model,
|
||||
'enabled': architect.enabled,
|
||||
'maxRetries': architect.maxRetries,
|
||||
},
|
||||
'engineer': {
|
||||
'role': engineer.role.name,
|
||||
'cliTool': engineer.cliTool,
|
||||
'model': engineer.model,
|
||||
'enabled': engineer.enabled,
|
||||
'maxRetries': engineer.maxRetries,
|
||||
},
|
||||
'tester': {
|
||||
'role': tester.role.name,
|
||||
'cliTool': tester.cliTool,
|
||||
'model': tester.model,
|
||||
'enabled': tester.enabled,
|
||||
'maxRetries': tester.maxRetries,
|
||||
},
|
||||
'ollamaEndpoint': ollamaEndpoint,
|
||||
'maxIterations': maxIterations,
|
||||
'minAcceptableScore': minAcceptableScore,
|
||||
'timeoutSeconds': timeoutSeconds,
|
||||
'aiGatewayInjectionPolicy': aiGatewayInjectionPolicy.name,
|
||||
'managedSkills': managedSkills.map((item) => item.toJson()).toList(),
|
||||
'managedMcpServers': managedMcpServers
|
||||
.map((item) => item.toJson())
|
||||
.toList(),
|
||||
'mountTargets': mountTargets.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory MultiAgentConfig.fromJson(Map<String, dynamic> json) {
|
||||
final defaults = MultiAgentConfig.defaults();
|
||||
final architectJson = json['architect'] as Map<String, dynamic>? ?? {};
|
||||
final engineerJson = json['engineer'] as Map<String, dynamic>? ?? {};
|
||||
final testerJson = json['tester'] as Map<String, dynamic>? ?? {};
|
||||
final rawManagedSkills = json['managedSkills'];
|
||||
final rawManagedMcpServers = json['managedMcpServers'];
|
||||
final rawMountTargets = json['mountTargets'];
|
||||
|
||||
AgentWorkerConfig parseWorker(
|
||||
Map<String, dynamic> m,
|
||||
MultiAgentRole role,
|
||||
String defaultTool,
|
||||
) {
|
||||
return AgentWorkerConfig(
|
||||
role: role,
|
||||
cliTool: m['cliTool'] as String? ?? defaultTool,
|
||||
model: m['model'] as String? ?? '',
|
||||
enabled: m['enabled'] as bool? ?? true,
|
||||
maxRetries: m['maxRetries'] as int? ?? 2,
|
||||
);
|
||||
}
|
||||
|
||||
return MultiAgentConfig(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
autoSync: json['autoSync'] as bool? ?? defaults.autoSync,
|
||||
architect: parseWorker(architectJson, MultiAgentRole.architect, 'gemini'),
|
||||
engineer: parseWorker(engineerJson, MultiAgentRole.engineer, 'claude'),
|
||||
tester: parseWorker(testerJson, MultiAgentRole.testerDoc, 'codex'),
|
||||
ollamaEndpoint:
|
||||
json['ollamaEndpoint'] as String? ?? defaults.ollamaEndpoint,
|
||||
maxIterations: json['maxIterations'] as int? ?? defaults.maxIterations,
|
||||
minAcceptableScore:
|
||||
json['minAcceptableScore'] as int? ?? defaults.minAcceptableScore,
|
||||
timeoutSeconds: json['timeoutSeconds'] as int? ?? defaults.timeoutSeconds,
|
||||
aiGatewayInjectionPolicy: AiGatewayInjectionPolicyCopy.fromJsonValue(
|
||||
json['aiGatewayInjectionPolicy'] as String?,
|
||||
),
|
||||
managedSkills: rawManagedSkills is List
|
||||
? rawManagedSkills
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
ManagedSkillEntry.fromJson(item.cast<String, dynamic>()),
|
||||
)
|
||||
.toList(growable: false)
|
||||
: defaults.managedSkills,
|
||||
managedMcpServers: rawManagedMcpServers is List
|
||||
? rawManagedMcpServers
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => ManagedMcpServerEntry.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList(growable: false)
|
||||
: defaults.managedMcpServers,
|
||||
mountTargets: rawMountTargets is List
|
||||
? rawMountTargets
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => ManagedMountTargetState.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList(growable: false)
|
||||
: defaults.mountTargets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiAgentRunEvent {
|
||||
const MultiAgentRunEvent({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.pending,
|
||||
required this.error,
|
||||
this.role,
|
||||
this.iteration,
|
||||
this.score,
|
||||
this.data = const <String, dynamic>{},
|
||||
});
|
||||
|
||||
final String type;
|
||||
final String title;
|
||||
final String message;
|
||||
final bool pending;
|
||||
final bool error;
|
||||
final String? role;
|
||||
final int? iteration;
|
||||
final int? score;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type,
|
||||
'title': title,
|
||||
'message': message,
|
||||
'pending': pending,
|
||||
'error': error,
|
||||
if (role != null) 'role': role,
|
||||
if (iteration != null) 'iteration': iteration,
|
||||
if (score != null) 'score': score,
|
||||
'data': data,
|
||||
};
|
||||
}
|
||||
|
||||
factory MultiAgentRunEvent.fromJson(Map<String, dynamic> json) {
|
||||
return MultiAgentRunEvent(
|
||||
type: json['type'] as String? ?? 'status',
|
||||
title: json['title'] as String? ?? '',
|
||||
message: json['message'] as String? ?? '',
|
||||
pending: json['pending'] as bool? ?? false,
|
||||
error: json['error'] as bool? ?? false,
|
||||
role: json['role'] as String?,
|
||||
iteration: (json['iteration'] as num?)?.toInt(),
|
||||
score: (json['score'] as num?)?.toInt(),
|
||||
data:
|
||||
(json['data'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +118,45 @@ void main() {
|
||||
expect(content, contains('TEST = "value"'));
|
||||
});
|
||||
|
||||
test('configureManagedMcpServers preserves user MCP entries', () async {
|
||||
final configFile = File('${tempDir.path}/config.toml');
|
||||
await configFile.writeAsString('''
|
||||
[mcp_servers.user_server]
|
||||
command = "user-mcp"
|
||||
args = ["--stdio"]
|
||||
''');
|
||||
|
||||
await bridge.configureManagedMcpServers(
|
||||
servers: const <CodexMcpServer>[
|
||||
CodexMcpServer(
|
||||
name: 'xworkmate_server',
|
||||
command: 'xworkmate-mcp',
|
||||
args: <String>['--port', '7777'],
|
||||
),
|
||||
],
|
||||
);
|
||||
await bridge.configureManagedMcpServers(
|
||||
servers: const <CodexMcpServer>[
|
||||
CodexMcpServer(
|
||||
name: 'xworkmate_server',
|
||||
command: 'xworkmate-mcp',
|
||||
args: <String>['--port', '8888'],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final content = await configFile.readAsString();
|
||||
expect(content, contains('[mcp_servers.user_server]'));
|
||||
expect(content, contains('command = "user-mcp"'));
|
||||
expect(content, contains('[mcp_servers.xworkmate_server]'));
|
||||
expect(content, contains('"8888"'));
|
||||
expect(
|
||||
'# BEGIN XWORKMATE MANAGED MCP BLOCK'.allMatches(content).length,
|
||||
1,
|
||||
);
|
||||
expect(content, isNot(contains('"7777"')));
|
||||
});
|
||||
|
||||
test('hasConfig returns correct value', () async {
|
||||
expect(await bridge.hasConfig(), isFalse);
|
||||
|
||||
|
||||
85
test/runtime/opencode_config_bridge_test.dart
Normal file
85
test/runtime/opencode_config_bridge_test.dart
Normal file
@ -0,0 +1,85 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/opencode_config_bridge.dart';
|
||||
|
||||
void main() {
|
||||
group('OpencodeConfigBridge', () {
|
||||
late Directory tempDir;
|
||||
late OpencodeConfigBridge bridge;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp(
|
||||
'opencode-config-bridge-',
|
||||
);
|
||||
bridge = OpencodeConfigBridge(opencodeHome: tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('configureManagedMcpServers preserves user config', () async {
|
||||
final configFile = File('${tempDir.path}/config.toml');
|
||||
await configFile.writeAsString('''
|
||||
[model]
|
||||
name = "user-default"
|
||||
|
||||
[mcp_servers.user_server]
|
||||
type = "stdio"
|
||||
command = "user-mcp"
|
||||
''');
|
||||
|
||||
await bridge.configureManagedMcpServers(
|
||||
servers: const <OpencodeMcpServer>[
|
||||
OpencodeMcpServer(
|
||||
name: 'xworkmate_server',
|
||||
command: 'xworkmate-mcp',
|
||||
args: <String>['--stdio'],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final content = await configFile.readAsString();
|
||||
expect(content, contains('[model]'));
|
||||
expect(content, contains('name = "user-default"'));
|
||||
expect(content, contains('[mcp_servers.user_server]'));
|
||||
expect(content, contains('[mcp_servers.xworkmate_server]'));
|
||||
expect(content, contains('# BEGIN XWORKMATE MANAGED MCP BLOCK'));
|
||||
});
|
||||
|
||||
test(
|
||||
'configureManagedMcpServers updates managed block without duplication',
|
||||
() async {
|
||||
await bridge.configureManagedMcpServers(
|
||||
servers: const <OpencodeMcpServer>[
|
||||
OpencodeMcpServer(
|
||||
name: 'xworkmate_server',
|
||||
command: 'xworkmate-mcp',
|
||||
args: <String>['--port', '3000'],
|
||||
),
|
||||
],
|
||||
);
|
||||
await bridge.configureManagedMcpServers(
|
||||
servers: const <OpencodeMcpServer>[
|
||||
OpencodeMcpServer(
|
||||
name: 'xworkmate_server',
|
||||
command: 'xworkmate-mcp',
|
||||
args: <String>['--port', '3001'],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final content = await bridge.readConfig();
|
||||
expect(
|
||||
'# BEGIN XWORKMATE MANAGED MCP BLOCK'.allMatches(content).length,
|
||||
1,
|
||||
);
|
||||
expect(content, contains('"3001"'));
|
||||
expect(content, isNot(contains('"3000"')));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -128,6 +128,77 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore persists multi-agent settings without secrets in snapshot json',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-config-store-multi-agent-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
multiAgent: MultiAgentConfig.defaults().copyWith(
|
||||
enabled: true,
|
||||
autoSync: false,
|
||||
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.launchScoped,
|
||||
architect: const AgentWorkerConfig(
|
||||
role: MultiAgentRole.architect,
|
||||
cliTool: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
enabled: true,
|
||||
),
|
||||
managedSkills: const <ManagedSkillEntry>[
|
||||
ManagedSkillEntry(
|
||||
key: 'calm_compact_workspace_system',
|
||||
label: 'Calm Compact Workspace System',
|
||||
source: '/Users/test/.codex/skills/calm_compact_workspace_system',
|
||||
selected: true,
|
||||
),
|
||||
],
|
||||
managedMcpServers: const <ManagedMcpServerEntry>[
|
||||
ManagedMcpServerEntry(
|
||||
id: 'xworkmate/gateway',
|
||||
name: 'XWorkmate Gateway',
|
||||
transport: 'stdio',
|
||||
command: 'xworkmate-mcp',
|
||||
url: '',
|
||||
args: <String>['--stdio'],
|
||||
envKeys: <String>[],
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final encoded = loadedSnapshot.toJsonString();
|
||||
|
||||
expect(loadedSnapshot.multiAgent.enabled, isTrue);
|
||||
expect(loadedSnapshot.multiAgent.autoSync, isFalse);
|
||||
expect(
|
||||
loadedSnapshot.multiAgent.aiGatewayInjectionPolicy,
|
||||
AiGatewayInjectionPolicy.launchScoped,
|
||||
);
|
||||
expect(loadedSnapshot.multiAgent.architect.model, 'gemini-2.5-pro');
|
||||
expect(loadedSnapshot.multiAgent.managedSkills, hasLength(1));
|
||||
expect(loadedSnapshot.multiAgent.managedMcpServers, hasLength(1));
|
||||
expect(encoded, contains('"multiAgent"'));
|
||||
expect(encoded, isNot(contains('ai-gateway-secret')));
|
||||
expect(encoded, isNot(contains('gateway_token')));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore dispose closes sqlite handle and allows reopening the same database path',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user