1030 lines
51 KiB
Markdown
1030 lines
51 KiB
Markdown
|
|
# Neta Desktop Op — 通用桌面 GUI Agent(weixin-uia 替代 + Layer 2 基础)
|
|||
|
|
|
|||
|
|
> Status: Draft **v4** 2026-05-14(双 Agent 架构:Reply Agent + Desktop Agent 显式分离,桌面操作以 Tool 形式暴露)
|
|||
|
|
> Owner: 与 lixin 共识
|
|||
|
|
> 取代:`2026-05-09-wechat-uia-channel-design.md`(UIA 在微信 4.1.9.54 验证彻底无效)
|
|||
|
|
> 关联:`2026-05-13-weixin-archive-sync-design.md`(微信消息读路径已落地,本 spec 不动)
|
|||
|
|
> Phase 0 PoC 报告:`docs/superpowers/followups/2026-05-14-visual-agent-poc-raw.json`
|
|||
|
|
|
|||
|
|
## 0. 版本演进
|
|||
|
|
|
|||
|
|
| 版本 | 日期 | 变更要点 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| v1 | 上午 | 首版 spec,UIA 路线被放弃,改纯视觉,内嵌 netaclaw |
|
|||
|
|
| v2 | 下午 | 架构师 review 14 条修订(短回路 ReAct / fire-and-forget / model_channel 集成 / WeixinChannelMutex / final_text 入库 / Phase 0 PoC 风险前置等) |
|
|||
|
|
| v3 | 晚 | Phase 0 PoC 已通过(100% 1 次,核心链路验证)。架构师再 review 后通用化:从"微信回复工具"重定位为"通用桌面 GUI Agent",WeixinAdapter 是第一个 application adapter |
|
|||
|
|
| **v4** | 当晚 | **双 Agent 显式分离 + 桌面操作以 Tool 形式暴露**。架构师再 review:reply agent 不再"自动发"finalContent,改为通过 `delegate_task` 委托 desktop agent;desktop agent 是普通 NetaClaw Agent,toolset=`weixin_desktop`;tool 同步等待 DesktopOpService 执行完成;删除 `weixin_db.replyToGroup` 占位与 `agent_channel.ts` 自动发送块。 |
|
|||
|
|
|
|||
|
|
### v3 → v4 主要变更(8 项,来自双 agent 架构决议)
|
|||
|
|
|
|||
|
|
| # | 变更 | 解决问题 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **H1** | **频道绑定 2 个 NetaClaw Agent**:`channel.agentId`(对话 reply agent,沿用)+ `channel.config.weixinReply.desktopAgentId`(★ 新,桌面操作 desktop agent) | reply agent 不应承担"如何操作桌面"的细节;未来不同应用可有专属 agent(微信操作 agent / Excel 操作 agent / 浏览器操作 agent...) |
|
|||
|
|
| **H2** | **桌面操作以 Tool 形式暴露**(`weixin_send_text` 等),注册到 toolset=`weixin_desktop`;desktop agent 通过该 toolset 启用 | 复用 NetaClaw 现有 tool 治理 / 配置体系,不引入新抽象;每个 application 对应一个 toolset,扩展自然 |
|
|||
|
|
| **H3** | **删除 `weixin_db.replyToGroup` 占位方法** + **删除 `agent_channel.ts` 的 weixin-db 自动发送分支(行 585-608)** | v3 之前 reply agent 出 finalContent 后,系统层自动调 `replyToGroup` 发到群里(占位 throw not-implemented)。v4 reply agent 必须主动 `delegate_task` 给 desktop agent;weixin_db 只剩读路径职责(WalWatcher / IncrementalReader / health),写路径不属于它 |
|
|||
|
|
| **H4** | **Tool 同步等待**:`DesktopOpService.runAndWait(task, timeoutMs=60000)` 新接口,tool 内部 enqueue + 等待执行完 + 返回 `{ok, error?}` | desktop agent 能拿到真实发送结果,失败可重试 / 降级;符合 ReAct 闭环 |
|
|||
|
|
| **H5** | **不引入 `WeixinReplyHelper`**(v3 设计的 helper 是替换 replyToGroup 的桥,v4 chain 不经过它):`weixin_send_text` tool execute **直接组装 DesktopTask + 调 `DesktopOpService.runAndWait`** | 多余错误代码;少一层抽象,职责更清晰 |
|
|||
|
|
| **H6** | **Desktop Agent 必须配置 multimodal model + `weixin_desktop` toolset + 操作类 prompt**,系统不自动创建"隐式 agent";若 `weixinReply.enabled=true` 但 `desktopAgentId` 未配置,channel 校验时报错 | desktop agent 是普通 NetaClaw Agent,管理员后台可改 prompt / tool / model;未来 ExcelAgent / BrowserAgent 同模式 |
|
|||
|
|
| **H7** | **防 Loop**:Desktop Agent 的 toolset **不含 `delegate_task`**(默认 `weixin_desktop` + `interaction`),从 tool 层面阻断"desktop agent 委托回 reply agent"的环 | 双 agent 委托链单向,避免无限递归 |
|
|||
|
|
| **H8** | **`channel.config.weixinReply.modelChannelId` 字段移除** —— VLM 模型不再在 channel 层配,而是从 desktop agent 自己的 `agent.modelChannelId` 取(因为 desktop agent 已经是普通 NetaClaw Agent,本来就有 modelChannelId) | 避免双重配置;符合"desktop agent 是普通 agent"的定位 |
|
|||
|
|
|
|||
|
|
### v2 → v3 主要变更(9 项,均来自架构师再 review)
|
|||
|
|
|
|||
|
|
| # | 变更 | 解决问题 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **G1** | 模块从 `modules/netaclaw/runtime/visual_agent/` 迁出到 **`modules/desktop_op/`**,与 netaclaw 平级 | 通用桌面 Agent 不应是 AI 引擎子模块 |
|
|||
|
|
| **G2** | TaskContext 通用化:`target` 改成 `{ appId, conversation/file/url/etc }` 由 adapter 解释 | roomName 微信化 |
|
|||
|
|
| **G3** | **AppAdapter 注册式**:接口存在,MVP 只实现 `WeixinAdapter`,Layer 2 加 ExcelAdapter / BrowserAdapter | 不同应用前置准备/验证逻辑各异 |
|
|||
|
|
| **G4** | 留口:Layer 2 把 desktop_op 注册成 NetaClaw agent 的 tool(本 spec 不实现) | AI agent 能主动调用桌面操作 |
|
|||
|
|
| **G5** | 全局 `desktop_op_config` 表(单行)放共享配置;channel 上仅留 `enabled` + 引用 | 配置层级错乱 |
|
|||
|
|
| **G6** | 命名 `desktop_op`(目录 / 表 / 类前缀全改) | "visual agent" 与 NetaClaw "agent" 概念冲突 |
|
|||
|
|
| **G7** | **SafetyGuard**:应用白名单 + 危险动作硬黑名单(Delete / Win+R / Alt+F4 / shell 命令) | 通用化后能误删文件 / 关机等 |
|
|||
|
|
| **G8** | `WeixinChannelMutex` 升级为 **全局 `DesktopMutex`**(系统只有一块屏幕,任意时刻一个 task 占键鼠) | per-channel 锁不够 |
|
|||
|
|
| **G9** | 留口:Layer 2 暴露 admin HTTP `POST /admin/desktop_op/run-task` 任意触发 | 通用化后需要外部 pull |
|
|||
|
|
|
|||
|
|
### Phase 0 PoC 已经验证(2026-05-14 实跑)
|
|||
|
|
|
|||
|
|
- **模型**: `doubao-seed-2-0-pro-260215`(火山引擎 model_channel id=2)
|
|||
|
|
- **链路**: node-screenshots(captureImageSync)→ Seed 2.0 Pro vision JSON → koffi+nut.js+clip.exe → 文件传输助手实际收到 `[probe-1-1778742201823]`
|
|||
|
|
- **2 次 VLM 调用 / 条**(verify-in-chat + verify-sent),JSON action schema(不用 UI-TARS DSL)
|
|||
|
|
- **单次耗时 ~23s,token ~3800 ≈ ¥0.014/条**
|
|||
|
|
- **关键发现**:
|
|||
|
|
- 微信主窗口 title 是中文 `微信`(hwnd=135372),英文 `Weixin` 是子窗口 → 找窗口要用 node-screenshots `appName==='Weixin'` 取 isMinimized=false 中面积最大的
|
|||
|
|
- `node-screenshots` 的 Image 对象会缓存,**截两次必须重新 enumerate 取新 Window 实例**
|
|||
|
|
- 微信全局搜索 Ctrl+F 第一项是"公众号"不是文件传输助手 → **导航不能直接 Enter**,要 adapter preFlight 处理(MVP 先要求用户手动定位到目标对话)
|
|||
|
|
- clipboardy v5 是 ESM,不能 require → 改用 `child_process` 调 Windows 自带 `clip.exe`(中文要 UTF-16 LE BOM)
|
|||
|
|
|
|||
|
|
PoC 脚本和 raw 报告已 commit 到仓库。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 背景与目标
|
|||
|
|
|
|||
|
|
### 1.1 UIA 路线为什么必须放弃
|
|||
|
|
|
|||
|
|
详见 `2026-05-09-wechat-uia-channel-design.md` 顶部 OBSOLETE 标记和 `tools/uia_probe/` PoC。要点:微信 4.1.9.54 全 Qt 自绘 + `MMUIRenderSubWindowHW` 硬件加速,UIA 树只有 3 个节点 0 个交互控件,讲述人 / 注册表 / 事件订阅全部无效。
|
|||
|
|
|
|||
|
|
### 1.2 新方案:Desktop Op 通用桌面 Agent
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[Windows 桌面应用] (微信 / Office / 浏览器 / ERP 都行,通用)
|
|||
|
|
▲
|
|||
|
|
│ ① Adapter.findWindow() + activate() (各应用各自实现)
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[node-screenshots] 截屏(每次重新 enumerate, 避缓存)
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[VLM via model_channel] (默认 doubao-seed-2-0-pro,可换 UI-TARS / Sonnet)
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[Parser] (UI-TARS DSL / 通用 JSON / Claude tool format) 注册式按 provider
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[SafetyGuard] 应用白名单 + 危险动作黑名单 校验
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[DesktopMutex] 全局键鼠锁(任意时刻只一个 task 操作)
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[InputController] nut.js 鼠标 + clip.exe 中文剪贴板
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
[Adapter.verifyResult()] 截屏 + VLM 验证(adapter 自定义)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 本 spec 目标(★ v4 重写)
|
|||
|
|
|
|||
|
|
打通 **"双 Agent 桌面任务"** 端到端最小可用路径:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[群消息 db 增量] → onInbound → reply agent (channel.agentId) ReAct
|
|||
|
|
↓ 主动调 delegate_task
|
|||
|
|
desktop agent (channel.config.weixinReply.desktopAgentId) ReAct
|
|||
|
|
↓ 主动调 weixin_send_text tool
|
|||
|
|
DesktopOpService.runAndWait(task)
|
|||
|
|
↓
|
|||
|
|
DesktopOpRuntime + WeixinAdapter + DesktopMutex
|
|||
|
|
↓ 截屏 + VLM(走 desktop agent 的 modelChannel) + 鼠键 + verify
|
|||
|
|
返回 { ok, error? }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
MVP 唯一实现 `WeixinAdapter` + `weixin_send_text` tool;**删除 `weixin_db.replyToGroup` 占位** + **删除 `agent_channel.ts` 自动发送块**(reply agent 必须主动 delegate)。Layer 2 其他 Adapter / Toolset 零拆架构追加。
|
|||
|
|
|
|||
|
|
### 1.4 双 Agent 模型(★ v4 新增,核心设计)
|
|||
|
|
|
|||
|
|
#### 1.4.1 职责分工
|
|||
|
|
|
|||
|
|
| Agent | 配置位置 | 职责 | 典型 prompt | 典型 toolset | 典型 model |
|
|||
|
|
|---|---|---|---|---|---|
|
|||
|
|
| **Reply Agent**(对话 agent)| `channel.agentId`(沿用) | 理解群消息 + 决定怎么回复 + 决定要不要 delegate 给 desktop agent | "你是 XX 客服,看到群消息后判断..." | `base` + `interaction` + `crew`(含 `delegate_task`) | 普通文本 LLM(可不带 vision) |
|
|||
|
|
| **Desktop Agent**(桌面操作 agent)| `channel.config.weixinReply.desktopAgentId`(★ 新) | 接受 reply agent 的委托任务,通过 `weixin_send_text` 等 tool 完成"在群里发消息" | "你是微信桌面操作助手,接到任务后用 weixin_* 工具完成发送..." | `weixin_desktop` + `interaction`(★ 不给 `delegate_task` 防 loop) | multimodal VLM(火山 Seed-2.0-pro,因为 tool 内部 VLM 验证用同一 modelChannel) |
|
|||
|
|
|
|||
|
|
#### 1.4.2 为什么是两个独立 Agent 而不是 Tool + 单 Agent
|
|||
|
|
|
|||
|
|
- **上下文隔离**:reply agent 关心"客户在问什么",desktop agent 关心"如何点鼠标",两份 context 不应互相污染
|
|||
|
|
- **可独立调优**:微信操作出 bug 时只改 desktop agent prompt 不影响 reply 逻辑
|
|||
|
|
- **可扩展性**:Layer 2 加 Excel 自动化时新增 `excelAgent`,channel 编辑页选择即可,reply agent 一行代码不用动
|
|||
|
|
- **可观测性**:两个 agent 各自有 session 历史,审计独立
|
|||
|
|
|
|||
|
|
#### 1.4.3 防 Loop 设计
|
|||
|
|
|
|||
|
|
- Desktop agent 的 toolset **必须不含 `delegate_task`/`delegate_parallel`**(管理后台保存 desktop agent 时校验)
|
|||
|
|
- Reply agent → desktop agent 是单向委托链,desktop agent 不能再 delegate 任何其他 agent
|
|||
|
|
- `delegate_task` tool 调用时附 metadata `{delegationDepth: N}`,N > 1 直接拒绝(双保险)
|
|||
|
|
|
|||
|
|
### 1.5 显式 out-of-scope (MVP)
|
|||
|
|
|
|||
|
|
- ❌ 其他 Application Adapter(Excel / Browser / ERP)— 接口留口,MVP 只 Weixin
|
|||
|
|
- ❌ NetaClaw agent 注册成 tool(G4)— 留口,MVP 不实现
|
|||
|
|
- ❌ admin HTTP `/run-task` 外部触发(G9)— 留口,MVP 内部调用
|
|||
|
|
- ❌ 自托管 vLLM
|
|||
|
|
- ❌ 多模型 ensemble / fallback chain
|
|||
|
|
- ❌ C# 独立桥进程(纯 Node 内嵌)
|
|||
|
|
- ❌ 图片/视频/语音/文件发送
|
|||
|
|
- ❌ @某人 / 引用回复(ActionStep schema 留口 `{type:'mention'}`)
|
|||
|
|
- ❌ 危险动作二次确认 UI(MVP 仅硬黑名单)
|
|||
|
|
- ❌ 录像审计回放
|
|||
|
|
- ❌ 贝塞尔曲线鼠标轨迹
|
|||
|
|
|
|||
|
|
## 2. 用户视角
|
|||
|
|
|
|||
|
|
### 2.1 微信场景接入(★ v4 重写)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
1. db 增量监听:WalWatcher(weixin_db.ts 读路径,不动)
|
|||
|
|
↓ onInbound(channelId, pseudo)
|
|||
|
|
2. agent_channel.routeInboundMessage(基本不动,但 weixin-db 分支删除自动发送块)
|
|||
|
|
↓
|
|||
|
|
3. reply agent ReAct(channel.agentId,沿用现有 agent_executor)
|
|||
|
|
↓ 主动决策:
|
|||
|
|
↓ - 这条消息要不要回复?(不回复直接结束 ReAct,finalContent 不发任何地方)
|
|||
|
|
↓ - 要回复的话,内容是什么?
|
|||
|
|
↓ - 调 delegate_task tool
|
|||
|
|
4. delegate_task({ agentId: channel.config.weixinReply.desktopAgentId, task: "在群 ABC 发送文字: 你好" })
|
|||
|
|
↓
|
|||
|
|
5. desktop agent ReAct(普通 NetaClaw Agent,toolset='weixin_desktop')
|
|||
|
|
↓ 主动决策:
|
|||
|
|
↓ - 用哪个 tool?(MVP 只有 weixin_send_text)
|
|||
|
|
↓ - 调 weixin_send_text({ roomName: 'ABC', text: '你好' })
|
|||
|
|
6. weixin_send_text tool execute
|
|||
|
|
↓ 组装 DesktopTask
|
|||
|
|
↓ await DesktopOpService.runAndWait(task, 60000)
|
|||
|
|
↓ 返回 { ok, error? } 给 desktop agent
|
|||
|
|
7. desktop agent 拿到 tool 结果决定:
|
|||
|
|
- 成功 → 结束 ReAct,返回 "已发送" 给 reply agent
|
|||
|
|
- 失败 → 选择 retry / clarify / abort,返回相应内容
|
|||
|
|
8. reply agent 拿到 delegate_task 返回值继续 ReAct(通常直接结束)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
整个链路 reply agent 同步 await delegate,delegate 内部 await tool,tool 内部 await runAndWait,**端到端同步**,但桌面操作内部用 mutex 串行不阻塞读路径。
|
|||
|
|
|
|||
|
|
senderQueue 不再参与微信回复链路 — v3/v2 的 "fire-and-forget 进 senderQueue" 决策只适用于 reply agent 也是 fire-and-forget 的旧设计,v4 不需要。
|
|||
|
|
|
|||
|
|
### 2.2 用户感知
|
|||
|
|
|
|||
|
|
对最终用户**完全透明** — 群消息进来 → AI 处理 → 自动回复出现在群里(约 5-40s 延迟,2 agent + VLM 调用相加)。
|
|||
|
|
|
|||
|
|
管理后台:
|
|||
|
|
- **频道编辑页**(微信场景)新增 "自动回复" 区块:
|
|||
|
|
- 启用开关
|
|||
|
|
- **对话 Agent 下拉**(就是 `channel.agentId`,沿用)
|
|||
|
|
- **桌面操作 Agent 下拉**(★ 新,`channel.config.weixinReply.desktopAgentId`,列表过滤 `toolset.includes('weixin_desktop')` 的 agent)
|
|||
|
|
- 风控 / 水印 / 限频
|
|||
|
|
- 风险提示文案
|
|||
|
|
- **NetaClaw Agent 管理**(沿用):管理员可创建"桌面操作 Agent"型 agent:挑 toolset = `weixin_desktop` + `interaction`、挑 multimodal modelChannel(Seed-2.0-pro)、写 prompt(MVP 提供默认模板)
|
|||
|
|
- **系统设置**(新增,Layer 2 完善):全局 `desktop_op_config` 表 — 应用白名单、危险按键、全局限频默认值
|
|||
|
|
- **审计页面**(后续):`desktop_op_action_log` 列表
|
|||
|
|
|
|||
|
|
### 2.3 Phase 0 PoC 暴露的"导航问题"(★ v4 强调影响)
|
|||
|
|
|
|||
|
|
PoC 验证发现:微信全局搜索 Ctrl+F 第一项常是"公众号"而非用户搜的目标 → **MVP WeixinAdapter 不依赖 Ctrl+F**。
|
|||
|
|
|
|||
|
|
WeixinAdapter 的 preFlightCheck 策略:
|
|||
|
|
1. **首版**:**要求 task.target.conversation 已经被用户手动定位为当前对话**(简化版,先把核心闭环跑通)
|
|||
|
|
2. **后续**:adapter 内部用方向键 + Enter / 视觉点击列表项导航,VLM 验证位置
|
|||
|
|
|
|||
|
|
**★ MVP 严重限制(必须文档化告知用户)**:
|
|||
|
|
- **单对话假设**:MVP 期间,微信必须保持在"目标群对话"页面,desktop agent 不会自动切换对话
|
|||
|
|
- **多群消息并发到达**:若 2 个监听中的群同时有消息进来:
|
|||
|
|
- 全局 DesktopMutex 串行 → 第二条任务排队等第一条完成
|
|||
|
|
- 第二条任务执行时,微信当前对话页面**仍是第一条目标群**(因为没人切对话)
|
|||
|
|
- WeixinAdapter.preFlightCheck VLM 验证"当前对话是不是第二条目标群?" → **失败,抛 precondition-failed**
|
|||
|
|
- desktop agent 收到失败 → 按 prompt 决定不重试 → reply agent 收到失败 → 第二条消息**实际无法发送**
|
|||
|
|
- **运维建议**:MVP 阶段建议每个 backend 实例**只监听一个微信群**(在 channel 上配 group 白名单只填 1 个);多群场景留给 Layer 2 用 adapter 自动切换对话能力解决
|
|||
|
|
|
|||
|
|
错误反馈表(MVP):
|
|||
|
|
|
|||
|
|
| 失败场景 | 行为 |
|
|||
|
|
|---|---|
|
|||
|
|
| Adapter findWindow 返回 null | 重试 ≤ 3 次(每次 activate),失败记 `window-not-found` |
|
|||
|
|
| Adapter preFlightCheck 失败(对话不对) | 记 `precondition-failed: <reason>`,不重试 |
|
|||
|
|
| SafetyGuard 拒绝 appId | 记 `app-not-allowed`,不重试 |
|
|||
|
|
| SafetyGuard 拒绝危险动作 | 记 `dangerous-action-blocked`,中断当前 task |
|
|||
|
|
| 截屏失败 | 重试 ≤ 3 次 + 重 enumerate Window |
|
|||
|
|
| VLM API 限速/超时 | 退避重试 ≤ 3 次,记 `model-failed` |
|
|||
|
|
| 模型坐标超截图范围 | 记 `model-hallucinated`,丢弃 |
|
|||
|
|
| Adapter verifyResult 失败 | 重试 1 次,仍失败记 `verify-failed` |
|
|||
|
|
| 用户在用电脑(前台非目标 app) | 让位 sleep 5s × 3 次 |
|
|||
|
|
| 用户后台禁用 enabled | AbortSignal 中断 |
|
|||
|
|
| pending queue 全局 > 50 / per-app > 20 | 丢弃最老 + 记 `queue-overflow` |
|
|||
|
|
|
|||
|
|
## 3. 整体架构
|
|||
|
|
|
|||
|
|
### 3.1 进程模型
|
|||
|
|
|
|||
|
|
不变(v2 决策):Node backend 进程内嵌,无 C# 桥。bootstrap 入口调 `ensureDpiAware()` 声明 Per-Monitor v2。
|
|||
|
|
|
|||
|
|
### 3.2 模块分层(★ v4 重组,删除 helper,新增 weixin_desktop toolset)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
modules/
|
|||
|
|
├── desktop_op/ ← 新增,通用桌面 Agent runtime
|
|||
|
|
│ ├── runtime/
|
|||
|
|
│ │ ├── types.ts 通用 DesktopTask / ActionStep / TaskResult / AdapterContext
|
|||
|
|
│ │ ├── dpi.ts
|
|||
|
|
│ │ ├── screenshot.ts node-screenshots 包装(每次 enumerate)
|
|||
|
|
│ │ ├── input.ts nut.js + clip.exe(child_process)
|
|||
|
|
│ │ ├── window_locator.ts 通用窗口工具(adapter 共用)
|
|||
|
|
│ │ ├── desktop_mutex.ts ★ 全局键鼠锁
|
|||
|
|
│ │ ├── safety_guard.ts ★ 应用白名单 + 危险动作黑名单
|
|||
|
|
│ │ ├── rate_limiter.ts per-app / per-target / daily
|
|||
|
|
│ │ ├── parser/
|
|||
|
|
│ │ │ ├── parser.ts interface
|
|||
|
|
│ │ │ ├── json_action_parser.ts MVP 默认(Seed 2.0 Pro JSON 输出)
|
|||
|
|
│ │ │ └── registry.ts 按 modelChannel.providerType 选
|
|||
|
|
│ │ ├── adapters/ ★ AppAdapter 注册式
|
|||
|
|
│ │ │ ├── adapter.ts interface AppAdapter
|
|||
|
|
│ │ │ ├── weixin_adapter.ts MVP 唯一
|
|||
|
|
│ │ │ └── registry.ts 按 task.appId 选
|
|||
|
|
│ │ ├── vlm_client.ts OpenAI 兼容多模态(走 model_channel,从 desktop agent 传入 modelChannelId)
|
|||
|
|
│ │ ├── action_executor.ts
|
|||
|
|
│ │ └── runtime.ts DesktopOpRuntime: runTask(task, abort)
|
|||
|
|
│ ├── service/
|
|||
|
|
│ │ └── desktop_op.ts DesktopOpService: enqueue / runAndWait / per-app worker / abortByFilter
|
|||
|
|
│ ├── entity/
|
|||
|
|
│ │ ├── desktop_op_action_log.ts
|
|||
|
|
│ │ └── desktop_op_config.ts 全局配置表(单行,不含 default_model_channel_id 因为 model 走 agent)
|
|||
|
|
│ └── controller/admin/
|
|||
|
|
│ ├── desktop_op_action_log.ts /list /info
|
|||
|
|
│ └── desktop_op_config.ts get / update
|
|||
|
|
│
|
|||
|
|
└── netaclaw/ ← 既有
|
|||
|
|
├── tools/builtin/
|
|||
|
|
│ └── weixin_send_text.ts ★ v4 新增 — toolset='weixin_desktop',execute 内部调 DesktopOpService.runAndWait
|
|||
|
|
├── tools/catalog.ts ← 修改 import 加 weixin_send_text + 加 TOOLSET_WEIXIN_DESKTOP 常量
|
|||
|
|
└── service/
|
|||
|
|
├── weixin_db.ts ← 修改:删除 replyToGroup 方法(行 166-171)+ 修改类注释
|
|||
|
|
└── agent_channel.ts ← 修改:删除 weixin-db 分支自动发送块(行 585-608)+ delete cascade abort
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**v3 → v4 文件变更摘要**:
|
|||
|
|
- ❌ 删除 plan v3 的 `modules/netaclaw/service/weixin_reply_helper.ts` 计划文件(不创建)
|
|||
|
|
- ❌ 删除 `weixin_db.replyToGroup` 方法(占位)
|
|||
|
|
- ❌ 删除 `agent_channel.ts:585-608` 自动发送块
|
|||
|
|
- ✅ 新增 `modules/netaclaw/tools/builtin/weixin_send_text.ts`(toolset='weixin_desktop' 的 tool)
|
|||
|
|
- ✅ 修改 `modules/netaclaw/tools/catalog.ts` 注册新 toolset
|
|||
|
|
- ⚙ 修改 `desktop_op/service/desktop_op.ts` 加 `runAndWait` 方法(同步等待)
|
|||
|
|
|
|||
|
|
### 3.3 ReAct 拓扑(v3 简化:adapter 主导)
|
|||
|
|
|
|||
|
|
`DesktopOpRuntime.runTask(task, abort)` 内部:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
async runTask(task, abort) {
|
|||
|
|
abort.throwIfAborted();
|
|||
|
|
this.safety.validateAppId(task.appId);
|
|||
|
|
this.safety.validateTaskShape(task);
|
|||
|
|
const adapter = this.adapterRegistry.get(task.appId);
|
|||
|
|
|
|||
|
|
const win = await adapter.findWindow(task.target);
|
|||
|
|
if (!win) throw new Error('window-not-found');
|
|||
|
|
await this.windowLocator.activate(win);
|
|||
|
|
await this.delay(500);
|
|||
|
|
abort.throwIfAborted();
|
|||
|
|
|
|||
|
|
const ctx = this.buildAdapterContext(task, win);
|
|||
|
|
await adapter.preFlightCheck(task, ctx);
|
|||
|
|
|
|||
|
|
const steps = await adapter.buildSteps(task);
|
|||
|
|
for (const step of steps) {
|
|||
|
|
abort.throwIfAborted();
|
|||
|
|
this.safety.validateAction(step);
|
|||
|
|
await this.actionExecutor.execute(step);
|
|||
|
|
await this.delay(jitter(200, 800));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ok = await adapter.verifyResult(task, ctx);
|
|||
|
|
if (!ok) throw new Error('verify-failed');
|
|||
|
|
|
|||
|
|
return { ok: true, modelCalls: ctx.modelCalls, steps: steps.length, durationMs: ... };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`adapter.preFlightCheck` / `verifyResult` 可调 VLM(adapter 在 ctx 上能访问 vlmClient + parserRegistry),也可以纯规则。WeixinAdapter MVP:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
class WeixinAdapter implements AppAdapter {
|
|||
|
|
appId = 'weixin';
|
|||
|
|
supportedActions = ['send-text'];
|
|||
|
|
|
|||
|
|
async findWindow(target) {
|
|||
|
|
return windowLocator.findByAppName('Weixin', { skipMinimized: true, largest: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async preFlightCheck(task, ctx) {
|
|||
|
|
const shot = await ctx.screenshot.capture(ctx.window);
|
|||
|
|
const ok = await ctx.vlm.verifyState(
|
|||
|
|
ctx, `当前微信打开的聊天顶部标题是不是 "${task.target.conversation}"?`, shot,
|
|||
|
|
);
|
|||
|
|
if (!ok) throw new Error('precondition-failed: not in target chat. Please open the chat manually first (MVP limitation).');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async buildSteps(task) {
|
|||
|
|
return [
|
|||
|
|
{ type: 'clipboard-write', text: task.params.text },
|
|||
|
|
{ type: 'hotkey', key: 'ctrl+v' },
|
|||
|
|
{ type: 'wait', ms: 400 },
|
|||
|
|
{ type: 'hotkey', key: 'enter' },
|
|||
|
|
{ type: 'wait', ms: 800 },
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async verifyResult(task, ctx) {
|
|||
|
|
const shot = await ctx.screenshot.capture(ctx.window);
|
|||
|
|
return ctx.vlm.verifyState(
|
|||
|
|
ctx, `右下角最新一条己方消息是否包含 "${task.params.text.slice(0, 50)}" ?`, shot,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
queueKey(target) { return target.conversation ?? 'default'; }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.4 中文输入(沿用 v2 + PoC 验证修订)
|
|||
|
|
|
|||
|
|
**clipboardy v5 是 ESM,Node CJS 调用失败** — 改用 `child_process.spawnSync('clip.exe', { input: utf16leBomBuffer })`(PoC 已验)。
|
|||
|
|
|
|||
|
|
后续可上 `koffi` 调 Win32 `OpenClipboard / SetClipboardData(CF_UNICODETEXT, ...)`,避免 spawn 开销 — 留作优化。
|
|||
|
|
|
|||
|
|
### 3.5 全局 DesktopMutex(★ v3 升级)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@Provide() @Scope(ScopeEnum.Singleton)
|
|||
|
|
export class DesktopMutex {
|
|||
|
|
private busy: { taskId: string; appId: string } | null = null;
|
|||
|
|
private waiters: Array<{ resolve: () => void; taskId: string; appId: string }> = [];
|
|||
|
|
|
|||
|
|
async acquire(taskId: string, appId: string): Promise<() => void> {
|
|||
|
|
if (this.busy) {
|
|||
|
|
await new Promise<void>(r => this.waiters.push({ resolve: r, taskId, appId }));
|
|||
|
|
}
|
|||
|
|
this.busy = { taskId, appId };
|
|||
|
|
return () => {
|
|||
|
|
this.busy = null;
|
|||
|
|
const next = this.waiters.shift();
|
|||
|
|
next?.resolve();
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
理由:系统只有一块屏幕、一对键鼠,任意时刻只能一个 task 操作前台。`weixin-archive sync` **不需要这把锁**(只读 SQLite 不占前台),其内部的 `WeixinChannelMutex` 保留(防同 channel 并发同步)。
|
|||
|
|
|
|||
|
|
### 3.6 SafetyGuard(★ v3 新增)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@Provide() @Scope(ScopeEnum.Singleton)
|
|||
|
|
export class SafetyGuard {
|
|||
|
|
private allowedApps = new Set<string>(['weixin']);
|
|||
|
|
private dangerousKeys = new Set<string>([
|
|||
|
|
'delete', 'win+r', 'alt+f4', 'win+l',
|
|||
|
|
'ctrl+alt+delete', 'ctrl+shift+esc',
|
|||
|
|
]);
|
|||
|
|
private dangerousActionTypes = new Set<string>(['shell-command', 'kill-process']);
|
|||
|
|
|
|||
|
|
validateAppId(appId: string): void {
|
|||
|
|
if (!this.allowedApps.has(appId)) throw new Error('app-not-allowed: ' + appId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
validateAction(step: ActionStep): void {
|
|||
|
|
if (step.type === 'hotkey' && this.dangerousKeys.has(step.key.toLowerCase())) {
|
|||
|
|
throw new Error('dangerous-key-blocked: ' + step.key);
|
|||
|
|
}
|
|||
|
|
if (this.dangerousActionTypes.has(step.type as any)) {
|
|||
|
|
throw new Error('dangerous-action-blocked: ' + step.type);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async loadConfig(cfg: DesktopOpConfig): Promise<void> {
|
|||
|
|
this.allowedApps = new Set(cfg.allowedApps ?? ['weixin']);
|
|||
|
|
if (cfg.extraDangerousKeys) for (const k of cfg.extraDangerousKeys) this.dangerousKeys.add(k);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.7 DesktopOpService.runAndWait(★ v4 新增,同步等待 API)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@Provide() @Scope(ScopeEnum.Singleton)
|
|||
|
|
export class DesktopOpService {
|
|||
|
|
/**
|
|||
|
|
* 同步等待版本 — tool execute 调用入口。
|
|||
|
|
* 内部:enqueue + 等 worker 真正执行完 + 返回结果。
|
|||
|
|
* 超时(默认 60s)抛 task-timeout。失败抛 status 对应的 Error。
|
|||
|
|
*/
|
|||
|
|
async runAndWait(task: DesktopTask, timeoutMs = 60000): Promise<{ ok: true; taskId: string; modelCalls: number; steps: number; durationMs: number }>;
|
|||
|
|
|
|||
|
|
/** fire-and-forget(Layer 2 留口) — 不等结果 */
|
|||
|
|
enqueue(task: DesktopTask): { taskId: string; queuePosition: number };
|
|||
|
|
|
|||
|
|
/** cascade 取消 */
|
|||
|
|
abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
实现:`runAndWait` 内部维护 `Map<taskId, { resolve, reject, timer }>`,workerLoop 完成时根据 status 调用 `resolve({ok:true, ...})` 或 `reject(new Error(status))`;timer 超时调 `reject(task-timeout)` + 取消该 task。
|
|||
|
|
|
|||
|
|
### 3.8 后台 worker + per-app 队列(★ v3 设计沿用,v4 加 runAndWait 回调)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@Provide() @Scope(ScopeEnum.Singleton)
|
|||
|
|
export class DesktopOpService {
|
|||
|
|
// queue key = appId + adapter.queueKey(target), 同 conversation/file 串行
|
|||
|
|
private readonly queues = new Map<string, DesktopTask[]>();
|
|||
|
|
private readonly workers = new Map<string, Promise<void>>();
|
|||
|
|
private readonly aborters = new Map<string, AbortController>();
|
|||
|
|
|
|||
|
|
enqueue(task: DesktopTask): { taskId: string; queuePosition: number } {
|
|||
|
|
const key = this.queueKey(task);
|
|||
|
|
const q = this.queues.get(key) ?? [];
|
|||
|
|
if (q.length >= 20) {
|
|||
|
|
const dropped = q.shift()!;
|
|||
|
|
this.logRepo.save({ ...dropped, status: 'queue-overflow' });
|
|||
|
|
}
|
|||
|
|
q.push(task);
|
|||
|
|
this.queues.set(key, q);
|
|||
|
|
this.ensureWorker(key);
|
|||
|
|
return { taskId: task.id, queuePosition: q.length };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async workerLoop(key: string) {
|
|||
|
|
while (true) {
|
|||
|
|
const q = this.queues.get(key);
|
|||
|
|
if (!q || q.length === 0) return;
|
|||
|
|
const task = q.shift()!;
|
|||
|
|
const abort = new AbortController();
|
|||
|
|
this.aborters.set(task.id, abort);
|
|||
|
|
try {
|
|||
|
|
const release = await this.desktopMutex.acquire(task.id, task.appId);
|
|||
|
|
try {
|
|||
|
|
const result = await this.runtime.runTask(task, abort.signal);
|
|||
|
|
this.logRepo.save({ ...task, ...result, status: 'success' });
|
|||
|
|
} finally { release(); }
|
|||
|
|
} catch (err: any) {
|
|||
|
|
this.logRepo.save({ ...task, status: errStatus(err), error: err.message,
|
|||
|
|
abortedReason: abort.signal.aborted ? abort.signal.reason : null });
|
|||
|
|
} finally {
|
|||
|
|
this.aborters.delete(task.id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void {
|
|||
|
|
for (const [key, q] of this.queues.entries()) {
|
|||
|
|
const remaining = q.filter(t => {
|
|||
|
|
if (!filter(t)) return true;
|
|||
|
|
this.logRepo.save({ ...t, status: 'aborted', abortedReason: reason });
|
|||
|
|
return false;
|
|||
|
|
});
|
|||
|
|
this.queues.set(key, remaining);
|
|||
|
|
}
|
|||
|
|
// 正在跑的也取消(简化:全 abort)
|
|||
|
|
for (const abort of this.aborters.values()) abort.abort(reason);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private queueKey(task: DesktopTask): string {
|
|||
|
|
const adapter = this.adapterRegistry.get(task.appId);
|
|||
|
|
return `${task.appId}:${adapter.queueKey(task.target)}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
理由:**per-app+target 队列**(同对话串行,不同对话/不同 app 物理上仍被全局 mutex 串行,但 queue 层 allow 并发让短任务不被长任务堵)。
|
|||
|
|
|
|||
|
|
### 3.8 故障域(沿用 v2)
|
|||
|
|
|
|||
|
|
(略,通用化措辞)
|
|||
|
|
|
|||
|
|
## 4. 数据模型
|
|||
|
|
|
|||
|
|
### 4.1 `desktop_op_config`(全局,单行,★ v4 移除 default_model_channel_id)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE desktop_op_config (
|
|||
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|||
|
|
allowed_apps JSON NOT NULL, -- ['weixin', 'excel', ...]
|
|||
|
|
extra_dangerous_keys JSON,
|
|||
|
|
global_per_min INT NOT NULL DEFAULT 30,
|
|||
|
|
global_per_day INT NOT NULL DEFAULT 1000,
|
|||
|
|
default_watermark VARCHAR(32) DEFAULT 'suffix',
|
|||
|
|
updated_at DATETIME NOT NULL
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**v3 → v4 字段变更**:
|
|||
|
|
- ❌ 移除 `default_model_channel_id` — model 走 desktop agent 的 `agent.modelChannelId`,全局默认无用
|
|||
|
|
|
|||
|
|
### 4.2 `channel.config.weixinReply`(★ v4 加 desktopAgentId,移除 modelChannelId)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
config: {
|
|||
|
|
weixinReply?: {
|
|||
|
|
enabled: boolean; // 默认 false
|
|||
|
|
desktopAgentId: number; // ★ v4 必填:桌面操作 NetaClaw Agent ID(toolset 须含 'weixin_desktop')
|
|||
|
|
perGroupPerMinute?: number; // 默认 3
|
|||
|
|
perChannelPerMinute?: number; // 默认 10
|
|||
|
|
safeMode?: boolean; // 默认 true
|
|||
|
|
dailyLimit?: number; // 默认 100
|
|||
|
|
watermark?: 'none' | 'suffix' | 'zero-width';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**v3 → v4 字段变更**:
|
|||
|
|
- ❌ 移除 `modelChannelId` — VLM 模型从 desktop agent 自己的 `agent.modelChannelId` 取(desktop agent 已经是普通 NetaClaw Agent,本来就配 model)
|
|||
|
|
- ✅ 新增 `desktopAgentId` — 必填(`enabled=true` 时校验,后端在 `agent_channel.update` 时验)
|
|||
|
|
- ✅ 校验规则:
|
|||
|
|
- `desktopAgentId` 对应的 agent 必须存在
|
|||
|
|
- 该 agent 的 toolset 必须含 `weixin_desktop`(防误配)
|
|||
|
|
- 该 agent 的 toolset 不能含 `delegate_task` / `delegate_parallel`(防 loop)
|
|||
|
|
|
|||
|
|
### 4.2.1 Reply Agent / Desktop Agent 配置约束(★ v4,实际字段名以 `entity/agent.ts` 为准)
|
|||
|
|
|
|||
|
|
**实际 entity 字段**(见 `modules/netaclaw/entity/agent.ts:50-78`):
|
|||
|
|
- `agent.toolsets: string[]` — **复数**,数组(我们要求 数组**包含** 某个 toolset)
|
|||
|
|
- `agent.tools.perTool[toolName].allowInSubagent: boolean` — tool 在 subagent context 能否调用
|
|||
|
|
- `agent.tools.perTool[toolName].workerRoutingStrategy: 'auto' | 'force-local' | 'force-main-process-proxy' | 'force-disabled'` — tool 在 subprocess subagent 模式下的路由策略
|
|||
|
|
- `agent.subagentConfig.allowedPresetAgentIds: number[]` — 该 agent 作为 supervisor 时能 delegate 到哪些 preset agent
|
|||
|
|
|
|||
|
|
**精确校验规则**(`agent_channel.update` 时,Task 15.5):
|
|||
|
|
|
|||
|
|
- Reply agent (`channel.agentId`):
|
|||
|
|
- `agent.toolsets` 数组必须包含 **`'crew'`**(`delegate_task` 注册在 toolset='crew',见 `tools/builtin/delegate_task.ts:115`)
|
|||
|
|
- 若 channel `weixinReply.enabled=true` 但 reply agent 的 `toolsets` 不含 `'crew'` → 校验失败,提示 "对话 Agent 必须启用 crew toolset 以委托给桌面 Agent"
|
|||
|
|
- **推荐配置**(MVP 不强制,但前端 hint):`agent.subagentConfig.allowedPresetAgentIds = [desktopAgentId]`,限定 reply agent 只能 delegate 到约定的 desktop agent
|
|||
|
|
|
|||
|
|
- Desktop agent (`channel.config.weixinReply.desktopAgentId`):
|
|||
|
|
- `agent.toolsets` 数组必须包含 **`'weixin_desktop'`**
|
|||
|
|
- `agent.toolsets` 数组 **不**能包含 `'crew'`(防 loop:desktop agent 不能再 delegate)
|
|||
|
|
- **必须自动配置**(`agent_channel.update` 保存时若未配置则自动补齐 + 前端校验):
|
|||
|
|
- `agent.tools.perTool['weixin_send_text'].allowInSubagent = true`(否则 subagent 跑不了这个 tool)
|
|||
|
|
- `agent.tools.perTool['weixin_send_text'].workerRoutingStrategy = 'force-main-process-proxy'`(关键!即使 subagent 模式切到 subprocess,tool 也 proxy 回 main process 执行 → DesktopMutex 单 process 实例完全有效)
|
|||
|
|
- 校验失败时报错:"桌面操作 Agent 必须仅含 weixin_desktop 且不含 crew"
|
|||
|
|
- 该 agent 的 `modelChannelId` 对应 model_channel 应是 multimodal 模型(前端给提示,后端不强制 — 因为 prompt 写得好普通 LLM 也能跑)
|
|||
|
|
- **`buildRestrictedPrompt`**(`service/subagent.ts:166-186`)已经在 subagent prompt 自动注入英文 "You cannot delegate further subagents" — 这是 prompt 层防 loop 双保险
|
|||
|
|
|
|||
|
|
### 4.2.2 bizContext 透传机制(★ v4 新增,含 currentAgent 注入)
|
|||
|
|
|
|||
|
|
桌面工具需要 `channelId` / `roomName` 业务上下文,也需要"当前调用 tool 的 agent"的 `modelChannelId` 给 DesktopOp 内部 VLM 调用。MVP 采用扩展 `NetaToolRuntimeContext` 自动透传:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// modules/netaclaw/tools/runtime_context.ts(扩展)
|
|||
|
|
export interface NetaToolRuntimeBizContext {
|
|||
|
|
channelId?: number;
|
|||
|
|
roomName?: string;
|
|||
|
|
// 限制只能是 JSON-safe 类型(primitive / array / plain object)
|
|||
|
|
// inject 函数运行时校验 JSON.stringify(value) 必须成功
|
|||
|
|
[k: string]: string | number | boolean | null | undefined | object | any[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface NetaToolRuntimeCurrentAgent {
|
|||
|
|
id: number;
|
|||
|
|
name: string;
|
|||
|
|
modelChannelId: number | null;
|
|||
|
|
toolsets: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface NetaToolRuntimeContext {
|
|||
|
|
sessionCwd?: string | null;
|
|||
|
|
workspaceRoots?: string[];
|
|||
|
|
bizContext?: NetaToolRuntimeBizContext; // ★ v4 新增
|
|||
|
|
currentAgent?: NetaToolRuntimeCurrentAgent; // ★ v4 新增(替代 D1 中的 currentAgentModelChannelId)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**注入点 1**:`agent_channel.handleInboundMessage` 调用 reply agent 前,在 `AgentRunParams.runtime` 加:
|
|||
|
|
```ts
|
|||
|
|
runtime: {
|
|||
|
|
sessionCwd: ...,
|
|||
|
|
workspaceRoots: ...,
|
|||
|
|
bizContext: { channelId: channel.id, roomName },
|
|||
|
|
currentAgent: { id: replyAgent.id, modelChannelId: replyAgent.modelChannelId, ... },
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**注入点 2**:`NetaClawSubagentService.runPreparedExecution` 调 `agentRunner` 时,**继承 parent 的 bizContext** 同时**替换 currentAgent 为 subagent 自己**:
|
|||
|
|
```ts
|
|||
|
|
runtime: {
|
|||
|
|
...parentRuntime, // 含 bizContext(channelId/roomName 不变)
|
|||
|
|
currentAgent: { id: subagentEntity.id, modelChannelId: subagentEntity.modelChannelId, ... }, // 替换
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Subprocess 模式继承**:`process_runner.ts` 的 IPC envelope 把 runtime JSON 序列化随 task 发到 worker process,worker 在内部 `agent_executor` 里照样 `injectToolRuntimeContext` 到 tool args(校验 JSON-safe 失败则报 'biz-context-not-serializable')。
|
|||
|
|
|
|||
|
|
**Tool 端读取**:`weixin_send_text` execute 时通过 `readToolRuntimeContext(params)`:
|
|||
|
|
- `bizContext.channelId / bizContext.roomName` — 业务上下文,优先用;fallback 用 params 显式传入的(LLM 在 goal 里写的兜底)
|
|||
|
|
- `currentAgent.modelChannelId` — DesktopTask 的 modelChannelId 字段从这里取,**这就是 desktop agent 自己配置的 model**
|
|||
|
|
|
|||
|
|
这样既保证默认行为正确(LLM 不传 channelId 也能跑),又允许显式覆盖,且解决了 "tool 如何拿到当前调用 agent 的 modelChannelId" 的问题。
|
|||
|
|
|
|||
|
|
### 4.3 `desktop_op_action_log`(★ 改名 + 通用化)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE desktop_op_action_log (
|
|||
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|||
|
|
task_id VARCHAR(64) NOT NULL,
|
|||
|
|
app_id VARCHAR(32) NOT NULL,
|
|||
|
|
target_json JSON, -- target 完整 JSON
|
|||
|
|
action_type VARCHAR(32) NOT NULL,
|
|||
|
|
params_preview VARCHAR(200),
|
|||
|
|
final_text TEXT,
|
|||
|
|
channel_id BIGINT, -- 微信场景关联
|
|||
|
|
room_name VARCHAR(128),
|
|||
|
|
model_channel_id BIGINT,
|
|||
|
|
model_calls INT NOT NULL DEFAULT 0,
|
|||
|
|
steps INT NOT NULL DEFAULT 0,
|
|||
|
|
duration_ms INT NOT NULL DEFAULT 0,
|
|||
|
|
status VARCHAR(32) NOT NULL,
|
|||
|
|
error TEXT,
|
|||
|
|
aborted_reason VARCHAR(64),
|
|||
|
|
created_at DATETIME NOT NULL,
|
|||
|
|
INDEX idx_app_time (app_id, created_at DESC),
|
|||
|
|
INDEX idx_channel_time (channel_id, created_at DESC),
|
|||
|
|
INDEX idx_status_time (status, created_at DESC)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
截图默认不入库;`process.env.NETA_DESKTOP_OP_DEBUG=1` 时落盘。
|
|||
|
|
|
|||
|
|
## 5. 接口契约
|
|||
|
|
|
|||
|
|
### 5.1 类型(modules/desktop_op/runtime/types.ts)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
export interface DesktopTask {
|
|||
|
|
id: string;
|
|||
|
|
appId: string;
|
|||
|
|
target: any; // adapter 自定义结构
|
|||
|
|
actionType: string;
|
|||
|
|
params: any;
|
|||
|
|
modelChannelId?: number;
|
|||
|
|
maxSteps?: number;
|
|||
|
|
enqueuedAt: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ActionStep =
|
|||
|
|
| { type: 'click'; x: number; y: number; thought?: string }
|
|||
|
|
| { type: 'hotkey'; key: string; thought?: string }
|
|||
|
|
| { type: 'clipboard-write'; text: string }
|
|||
|
|
| { type: 'type'; text: string; thought?: string }
|
|||
|
|
| { type: 'wait'; ms: number }
|
|||
|
|
| { type: 'mention'; wxid: string }
|
|||
|
|
| { type: 'finished' }
|
|||
|
|
| { type: 'failed'; reason: string };
|
|||
|
|
|
|||
|
|
export interface TaskResult { ok: boolean; modelCalls: number; steps: number; durationMs: number; }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 AppAdapter 接口
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
export interface AdapterContext {
|
|||
|
|
window: WindowHandle;
|
|||
|
|
screenshot: Screenshooter;
|
|||
|
|
input: InputController;
|
|||
|
|
vlm: VlmClient;
|
|||
|
|
parser: Parser;
|
|||
|
|
logger: ILogger;
|
|||
|
|
task: DesktopTask;
|
|||
|
|
modelCalls: number; // runtime 维护
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AppAdapter {
|
|||
|
|
appId: string;
|
|||
|
|
supportedActions: string[];
|
|||
|
|
findWindow(target: any): Promise<WindowHandle | null>;
|
|||
|
|
preFlightCheck(task: DesktopTask, ctx: AdapterContext): Promise<void>;
|
|||
|
|
buildSteps(task: DesktopTask): Promise<ActionStep[]>;
|
|||
|
|
verifyResult(task: DesktopTask, ctx: AdapterContext): Promise<boolean>;
|
|||
|
|
queueKey(target: any): string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 主服务(★ v4 加 runAndWait)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@Provide() @Scope(ScopeEnum.Singleton)
|
|||
|
|
export class DesktopOpService {
|
|||
|
|
/** ★ v4 新增:同步等待入口,tool execute 调用 */
|
|||
|
|
async runAndWait(task: DesktopTask, timeoutMs = 60000):
|
|||
|
|
Promise<{ ok: true; taskId: string; modelCalls: number; steps: number; durationMs: number }>;
|
|||
|
|
|
|||
|
|
/** fire-and-forget(Layer 2 / 后台任务用)*/
|
|||
|
|
enqueue(task: DesktopTask): { taskId: string; queuePosition: number };
|
|||
|
|
|
|||
|
|
/** cascade 取消 */
|
|||
|
|
abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.4 weixin_desktop Toolset(★ v4 新增,取代 v3 WeixinReplyHelper)
|
|||
|
|
|
|||
|
|
**Toolset 常量**:在 `modules/netaclaw/tools/catalog.ts` 加 `export const TOOLSET_WEIXIN_DESKTOP = 'weixin_desktop' as const;`
|
|||
|
|
|
|||
|
|
**Tool 列表(MVP)**:
|
|||
|
|
|
|||
|
|
| Tool 名 | 参数 schema | 实现要点 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `weixin_send_text` | `{ roomName: string, text: string, channelId: number }` | execute:校验 `text.length > 0 && text.length < 2000`;查 channel(用 channelId)拿 `weixinReply.watermark` 加水印;组装 DesktopTask `{appId:'weixin', target:{conversation:roomName, channelId, roomName}, actionType:'send-text', params:{text:finalText, originalText:text}, modelChannelId: <agent.modelChannelId>, enqueuedAt: Date.now(), id: randomUUID()}`;`await desktopOpService.runAndWait(task, 60000)`;成功返回 `textResult('已在群 X 发送: Y')`,失败 throw |
|
|||
|
|
|
|||
|
|
**实现位置**:`modules/netaclaw/tools/builtin/weixin_send_text.ts`
|
|||
|
|
|
|||
|
|
**注册**:`tools/catalog.ts` 增加 `import './builtin/weixin_send_text.js';`
|
|||
|
|
|
|||
|
|
**modelChannelId 获取**:tool execute 需要从"当前调用 tool 的 agent"取 modelChannelId — 现有 tool 调用上下文里通过 `ctx.agentId` / `ctx.modelChannelId` 注入(具体实现参见 `tool_resolver.ts` 现有约定)。
|
|||
|
|
|
|||
|
|
**channelId 来源**:reply agent delegate_task 时把 `channelId` 写到任务上下文 / 输入参数,desktop agent 在调用 `weixin_send_text` 时把它从 prompt 上下文取出传入。MVP 简化:reply agent 的 prompt 模板里说明"请把 channelId 包含在任务描述里"。Layer 2 改成通过 NetaClaw 的 sessionContext 自动透传。
|
|||
|
|
|
|||
|
|
### 5.5 已删除项(★ v4)
|
|||
|
|
|
|||
|
|
- ❌ `WeixinReplyHelper`(v3 设计)— 不创建
|
|||
|
|
- ❌ `weixin_db.replyToGroup`(占位 + v3 委托设计)— 删除整个方法
|
|||
|
|
- ❌ `agent_channel.ts:585-608` 自动发送块 — 删除整个分支
|
|||
|
|
|
|||
|
|
## 6. 前端
|
|||
|
|
|
|||
|
|
### 6.1 频道编辑页(微信场景,★ v4 重写:双 Agent 选择)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[微信自动回复]
|
|||
|
|
自动回复: ( ) 禁用 ( ) 启用
|
|||
|
|
|
|||
|
|
对话 Agent: [ 下拉: agent.list({}) ] ← channel.agentId
|
|||
|
|
说明: 负责理解群消息+决定如何委托给桌面 agent
|
|||
|
|
⚠️ 该 agent 的 toolset 必须含 'delegate_task'
|
|||
|
|
|
|||
|
|
桌面操作 Agent: [ 下拉: agent.list({tooledWith: 'weixin_desktop'}) ] ← channel.config.weixinReply.desktopAgentId
|
|||
|
|
说明: 负责接受委托并通过微信桌面工具发送消息
|
|||
|
|
⚠️ 该 agent 的 toolset 必须含 'weixin_desktop' 且不含 'delegate_task'
|
|||
|
|
|
|||
|
|
小号安全模式: [×]
|
|||
|
|
每天上限: [ 100 ]
|
|||
|
|
每群每分钟: [ 3 ]
|
|||
|
|
消息水印: ( ) 不加 (•) 后缀 ' —AI' ( ) 零宽空格
|
|||
|
|
风险提示: "..."
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**前端校验逻辑**:
|
|||
|
|
- enabled=true 且 desktopAgentId 未选 → 表单错误
|
|||
|
|
- 对话 Agent 与桌面操作 Agent **不能是同一个**(虽然技术上可行,但语义上不对,前端提醒)
|
|||
|
|
- 提交后端校验:reply agent toolset 含 `delegate_task`、desktop agent toolset 含 `weixin_desktop` 且不含 `delegate_task`(后端 channel.update 时校验)
|
|||
|
|
|
|||
|
|
### 6.2 NetaClaw Agent 管理(沿用,新增"桌面操作 Agent"模板)
|
|||
|
|
|
|||
|
|
管理员创建 desktop agent 时:
|
|||
|
|
- toolset 选 `weixin_desktop` + `interaction`(不选 `crew`/`delegate_*`)
|
|||
|
|
- modelChannel 选 multimodal(火山 Seed-2.0-pro)
|
|||
|
|
- prompt 用默认模板(参见 §7.2.2)
|
|||
|
|
|
|||
|
|
### 6.3 系统设置 → Desktop Op(MVP 用默认值,完整 UI 留 Layer 2)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[Desktop Op 全局设置]
|
|||
|
|
应用白名单: [ chip 列表: weixin (+) ]
|
|||
|
|
额外危险按键: [ chip 列表 ]
|
|||
|
|
全局每分钟上限: [ 30 ]
|
|||
|
|
全局每天上限: [ 1000 ]
|
|||
|
|
默认水印: [ none / suffix / zero-width ]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 7. 模型与外部依赖
|
|||
|
|
|
|||
|
|
### 7.1 model 走 model_channel(★ v4 model 来自 desktop agent)
|
|||
|
|
|
|||
|
|
`vlm_client` 根据 `task.modelChannelId` 查 model_channel 拿凭据,根据 providerType 用 parserRegistry 选 Parser。
|
|||
|
|
|
|||
|
|
**modelChannelId 来源链(v4)**:
|
|||
|
|
```
|
|||
|
|
weixin_send_text tool execute(从 tool 调用上下文取 desktop agent 的 modelChannelId)
|
|||
|
|
↓
|
|||
|
|
组装 DesktopTask.modelChannelId = <desktop agent.modelChannelId>
|
|||
|
|
↓
|
|||
|
|
DesktopOpService.runAndWait(task) → DesktopOpRuntime.runTask
|
|||
|
|
↓
|
|||
|
|
VlmClient 用 task.modelChannelId 取凭据
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**MVP 默认 Parser**: `JsonActionParser`(不是 UI-TARS DSL)。Phase 0 PoC 已验证 Seed 2.0 Pro 输出 JSON action 可解析。Layer 2 加 `UITarsParser`。
|
|||
|
|
|
|||
|
|
### 7.2 prompt 模板
|
|||
|
|
|
|||
|
|
#### 7.2.1 Parser System Prompt(adapter ctx 注入)
|
|||
|
|
|
|||
|
|
`JsonActionParser.buildSystemPrompt(task, adapterCtx)` 由 adapter 协助拼接(不同 app 上下文不同)。仅用于 adapter.preFlightCheck / verifyResult 的 VLM 调用,因为 MVP 的 buildSteps 是硬编码,不需要 VLM 推理 next action。
|
|||
|
|
|
|||
|
|
#### 7.2.2 Desktop Agent 默认 Prompt 模板(★ v4 新增)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
你是"微信桌面操作助手 Agent",你的工作是接受其他 Agent 委托,使用桌面工具在微信窗口中完成具体操作。
|
|||
|
|
|
|||
|
|
你可用的工具:
|
|||
|
|
- weixin_send_text({roomName, text, channelId?}):在指定群里发送一段文字
|
|||
|
|
- channelId 可不填,系统会自动从 bizContext 注入当前对话的 channelId
|
|||
|
|
|
|||
|
|
操作原则:
|
|||
|
|
1. 任务描述必须包含目标群名(roomName)和要发的文本(text);channelId 一般不需要你显式传(自动透传)
|
|||
|
|
2. 调用 weixin_send_text 前先确认 text 不为空、不超 2000 字
|
|||
|
|
3. tool 调用成功 → 简明回复"已发送";失败 → 分析 error 决定:
|
|||
|
|
- "window-not-found" → 报错"微信未开,无法发送",不重试
|
|||
|
|
- "precondition-failed" → 报错"当前微信对话不是目标群,请手动定位后重试",不重试(MVP 不自动切对话)
|
|||
|
|
- "verify-failed" → 重试 1 次,仍失败报错
|
|||
|
|
- "model-failed" → 重试 1 次,仍失败报错
|
|||
|
|
- "task-timeout" → 报错"桌面操作超时(60s)",不重试
|
|||
|
|
- "dangerous-action-blocked" / "app-not-allowed" → 报错让人介入,不重试
|
|||
|
|
- 其他 → 直接报错
|
|||
|
|
|
|||
|
|
注意:
|
|||
|
|
- 你不主动写营销/客服内容,只机械执行 reply agent 委托的任务
|
|||
|
|
- 你不调用 delegate_* 工具(toolset 没给你这个权限)
|
|||
|
|
- 一次任务通常只调用 1 次 weixin_send_text 即完成
|
|||
|
|
- 你看不到 reply agent 的对话历史(subagent context 隔离),所有上下文都在 goal/context 字段里
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 7.2.3 Reply Agent Prompt 增量提示(管理员自己写,模板示例)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
当你需要在微信群里回复时,使用 delegate_task 工具委托给"桌面操作 Agent":
|
|||
|
|
delegate_task({
|
|||
|
|
agentId: <weixinReply.desktopAgentId>,
|
|||
|
|
task: "请在群 <roomName>(channelId=<id>)里发送以下文字: <text>"
|
|||
|
|
})
|
|||
|
|
不要直接输出 finalContent 期望系统自动发送 — finalContent 仅用于内部记录。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 成本估算(沿用 v2,PoC 实测校验)
|
|||
|
|
|
|||
|
|
PoC 实测:Seed 2.0 Pro 单条 ~3800 tokens ≈ ¥0.014。100 群 × 10 条/天 ≈ ¥14/天。
|
|||
|
|
|
|||
|
|
### 7.4 Node.js 依赖
|
|||
|
|
|
|||
|
|
| 包 | 用途 | 备注 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `node-screenshots` | 截屏 | Rust napi-rs;**每次重新 enumerate**(PoC 验证有缓存) |
|
|||
|
|
| `@nut-tree-fork/nut-js` | 键鼠 | fork 版,原版商业化 |
|
|||
|
|
| `koffi` | Win32 FFI | DPI / FindWindow / SetForegroundWindow |
|
|||
|
|
| ~~`clipboardy`~~ | ~~剪贴板~~ | **不再用**(v5 是 ESM 与 CJS 不兼容) |
|
|||
|
|
| `child_process.spawnSync('clip.exe')` | 中文剪贴板 | UTF-16 LE BOM,内置无依赖 |
|
|||
|
|
|
|||
|
|
### 7.5 高 DPI
|
|||
|
|
|
|||
|
|
bootstrap 调 `SetProcessDpiAwarenessContext(-4)` (PER_MONITOR_AWARE_V2)。
|
|||
|
|
|
|||
|
|
## 8. 测试策略
|
|||
|
|
|
|||
|
|
### 8.0 Phase 0.5 PoC(★ v4 新增):Subagent IPC 验证(< 30 min)
|
|||
|
|
|
|||
|
|
**目标**:确认 NetaClaw subagent tool 执行在哪个 process,以决定 DesktopMutex 单进程是否够用。
|
|||
|
|
|
|||
|
|
**做法**:
|
|||
|
|
1. 写一个临时 builtin tool `_debug_pid`:execute 时返回 `process.pid` + `process.argv`
|
|||
|
|
2. 启动后端,新建 reply agent(toolset=`crew` + `interaction`)和 desktop agent(toolset=`_debug_pid` + `interaction`)
|
|||
|
|
3. 在 chat 界面让 reply agent 调 `delegate_task({mode:'preset', agentId:<desktop>, goal:'run _debug_pid'})`
|
|||
|
|
4. 看 subagent 调 `_debug_pid` 返回的 pid 是否等于 backend main process pid
|
|||
|
|
- **等于** → tool 在 parent process(IPC proxy)→ DesktopMutex 单实例足够 ✅
|
|||
|
|
- **不等于** → tool 在 subagent process → 需要把 mutex 提到跨 process(file lock 等),改 Task 5
|
|||
|
|
5. 删除 _debug_pid tool 收尾
|
|||
|
|
|
|||
|
|
**门禁**:在 Phase A Task 5(DesktopMutex)开工前必须做完。
|
|||
|
|
|
|||
|
|
### 8.1 Phase 0 PoC ✅ 已通过
|
|||
|
|
|
|||
|
|
详见 `docs/superpowers/followups/2026-05-14-visual-agent-poc-raw.json`。
|
|||
|
|
|
|||
|
|
### 8.2 单元测试(可跨平台)
|
|||
|
|
|
|||
|
|
| 测试 | 覆盖 |
|
|||
|
|
|---|---|
|
|||
|
|
| `parser/json_action_parser.test.ts` | **20+ fixtures** 来自 Phase 0 PoC raw 输出 + 后续稳定性测试 |
|
|||
|
|
| `runtime/rate_limiter.test.ts` | per-app / per-target / daily |
|
|||
|
|
| `runtime/desktop_mutex.test.ts` | 全局串行 |
|
|||
|
|
| `runtime/safety_guard.test.ts` | 白名单 / 黑名单 / 配置加载 |
|
|||
|
|
| `runtime/runtime.test.ts`(mock 全部) | happy / preFlightCheck 失败 / verifyResult 失败 / Abort / SafetyGuard 拦截 |
|
|||
|
|
| `runtime/adapters/weixin_adapter.test.ts`(mock VLM)| preFlightCheck / buildSteps / verifyResult |
|
|||
|
|
| `service/desktop_op.test.ts` | enqueue / 队列 / abortByFilter / queue-overflow / 全局 mutex 等待 |
|
|||
|
|
| `service/weixin_reply_helper.test.ts` | enabled 检查 / model 默认值 / watermark / 入队 |
|
|||
|
|
| `service/weixin_db.test.ts` 修改 | replyToGroup loginStatus + enabled 短路 |
|
|||
|
|
|
|||
|
|
### 8.3 CI 政策(沿用 v2)
|
|||
|
|
|
|||
|
|
单元测试 CI 必过门禁;E2E 不阻塞 CI,合并前 owner 手动跑附 log。
|
|||
|
|
|
|||
|
|
### 8.4 E2E 手工冒烟(plan 末尾 task)
|
|||
|
|
|
|||
|
|
详见 plan v3。
|
|||
|
|
|
|||
|
|
## 9. 里程碑
|
|||
|
|
|
|||
|
|
详见 plan v3:`docs/superpowers/plans/2026-05-14-neta-desktop-op.md`
|
|||
|
|
|
|||
|
|
## 10. 已对齐的关键决策
|
|||
|
|
|
|||
|
|
### 10.1 v3 → v4 关键架构决策
|
|||
|
|
|
|||
|
|
| 决策 | v3 | v4 | 理由 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| **Reply 与 Desktop 职责划分** | reply agent 出 finalContent → 系统自动发 | reply agent 通过 `delegate_task` 主动委托 desktop agent | 上下文隔离、可独立调优、可扩展到 ExcelAgent / BrowserAgent |
|
|||
|
|
| **Desktop Op 暴露形式** | service 直接被 helper 调 | 普通 NetaClaw Tool(`weixin_send_text`),注册到 toolset=`weixin_desktop` | 复用现有 tool 治理 / 配置 |
|
|||
|
|
| **Channel 配置 Agent 数量** | 1 个(channel.agentId) | 2 个(channel.agentId + channel.config.weixinReply.desktopAgentId) | 显式分离两种职责 |
|
|||
|
|
| **Tool 调用语义** | (无,helper 自动 fire-and-forget) | 同步等待 `DesktopOpService.runAndWait(task, 60s)` | desktop agent 拿到真实结果可重试 / 降级 |
|
|||
|
|
| **WeixinReplyHelper** | 新增(v3 plan Task 14) | ❌ 不引入,tool 直接调 service | 多余抽象层 |
|
|||
|
|
| **weixin_db.replyToGroup** | v3 plan Task 15 实现(委托 helper) | ❌ **整方法删除** | 占位 + 多余;weixin_db 只剩读路径职责 |
|
|||
|
|
| **agent_channel.ts 自动发送块** | v3 plan 不动 | ❌ **整块删除**(行 585-608) | reply agent 必须主动 delegate;不再有"系统自动发"路径 |
|
|||
|
|
| **VLM 模型来源** | 全局 default_model_channel_id 或 channel.config.weixinReply.modelChannelId | desktop agent 自己的 `agent.modelChannelId`(因为 desktop agent 已是普通 NetaClaw Agent) | 避免双重配置 |
|
|||
|
|
| **防 Loop** | (无) | desktop agent 不给 `delegate_task` tool + 后端校验 | 单向委托链 |
|
|||
|
|
|
|||
|
|
### 10.2 沿用 v3 决策(不变)
|
|||
|
|
|
|||
|
|
| 决策 | 状态 | 理由 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 模块归属 `modules/desktop_op/` 顶层 | 沿用 | 通用化 |
|
|||
|
|
| 任务 schema `DesktopTask{appId, target, actionType, params}` | 沿用 | 通用化 |
|
|||
|
|
| AppAdapter 注册式 | 沿用 | 多应用零拆架构 |
|
|||
|
|
| Parser:MVP JSON action 主(PoC 验证) | 沿用 | 现有模型决定 |
|
|||
|
|
| 全局 DesktopMutex | 沿用 | 系统单屏 |
|
|||
|
|
| SafetyGuard 白名单+黑名单 | 沿用 | 通用化 |
|
|||
|
|
| 全局 desktop_op_config(★ 移除 default_model_channel_id) | 沿用 | 多 app 共享 |
|
|||
|
|
| 进程模型 Node 内嵌 | 沿用 | 简单可靠 |
|
|||
|
|
| AbortSignal 中断协议 | 沿用 | 取消可控 |
|
|||
|
|
| final_text 全文落审计 | 沿用 | 可回溯 |
|
|||
|
|
| Watermark | 沿用 | 灰度 |
|
|||
|
|
| **archive sync 监听不动** | **沿用** | 用户明确;WeixinChannelMutex 在 archive 内部保留 |
|
|||
|
|
| clip.exe 代替 clipboardy v5 | 沿用 | ESM 不兼容(PoC 验证) |
|
|||
|
|
| 截图每次 enumerate | 沿用 | node-screenshots 缓存(PoC 验证) |
|
|||
|
|
| 找微信窗口用 node-screenshots appName='Weixin' 取最大 | 沿用 | 主窗口 title 中文(PoC 验证) |
|
|||
|
|
| Phase 0 PoC 已完成 | 沿用 | 100% 1 次 |
|
|||
|
|
| CI 政策(E2E 不阻塞) | 沿用 | 微信版本依赖 |
|
|||
|
|
| weixin-uia spec OBSOLETE | 沿用 | UIA 路线放弃 |
|
|||
|
|
|
|||
|
|
## 11. 风险与兜底
|
|||
|
|
|
|||
|
|
| 风险 | 概率 | 兜底(v3) |
|
|||
|
|
|---|---|---|
|
|||
|
|
| ★ MVP 只验单次,5/20 次稳定性未知 | 中 | E2E 必跑 N=20,< 80% 调 prompt / 换 UI-TARS |
|
|||
|
|
| 微信版本升级改 UI | 高 | 视觉天然抗变 + adapter VLM 重看 |
|
|||
|
|
| 火山限速/涨价/下线 | 中 | model_channel 可切 |
|
|||
|
|
| 微信反自动化封号 | 中 | safeMode + 频限 + 间隔 + 队列 + 让位 + watermark + 文档警告 |
|
|||
|
|
| **通用化引入误操作风险** | 中 | SafetyGuard 默认只 weixin,扩 app 显式加,危险按键硬黑名单 |
|
|||
|
|
| **adapter 抽象过早** | 低 | MVP 只 1 adapter,接口按 Weixin 设计,Layer 2 演进 |
|
|||
|
|
| node-screenshots 缓存 | 已知 | 每次 enumerate(已修)|
|
|||
|
|
| nut.js UAC 拒收 | 低 | 检测前台非目标即跳过 |
|
|||
|
|
| 高 DPI 错位 | 中 | bootstrap PER_MONITOR_AWARE_V2 |
|
|||
|
|
| 用户在用电脑 | 中 | 让位 |
|
|||
|
|
| 中文 IME 吞字 | 已知 | clip.exe + Ctrl+V(PoC 验证) |
|
|||
|
|
| 队列积压 | 中 | per-target 上限 20 + 丢最老 |
|
|||
|
|
| VLM JSON 不稳 | 中 | Parser 容错 + 20+ fixture + 解析失败视为 model-failed |
|
|||
|
|
| 客户群出事难回溯 | 中 | final_text 全文 + watermark + 审计 |
|
|||
|
|
| koffi/nut.js/adapter 经验 | 中 | Phase A 拆细 + Phase 0 PoC 已踩主要坑 |
|
|||
|
|
| **多 app 并发被全局锁堵** | 低 | MVP 只 weixin;Layer 2 评估 per-window 锁 |
|
|||
|
|
| 测试小号未养号 | 高 | 立项当天启动 |
|
|||
|
|
|
|||
|
|
## 12. 后续路线(Layer 2)
|
|||
|
|
|
|||
|
|
- 第 2 个 Adapter:Excel / Browser / 钉钉
|
|||
|
|
- agent_executor tool 注册(G4)
|
|||
|
|
- admin HTTP `POST /run-task`(G9)
|
|||
|
|
- 危险动作二次确认 UI(G7 增强)
|
|||
|
|
- Sonnet 4.6 / UI-TARS-1.5 fallback chain
|
|||
|
|
- 自托管 vLLM
|
|||
|
|
- 截图录像审计 + Web 回放
|
|||
|
|
- 贝塞尔曲线鼠标 + 多账号轮换
|
|||
|
|
- 跨平台:Mac (MacAdapter) / Linux
|
|||
|
|
- 导航能力增强:WeixinAdapter 自动定位对话(MVP 要求手动)
|
|||
|
|
- ActionStep 扩展:mention / reply-to / screenshot-region
|
|||
|
|
|
|||
|
|
## 13. Self-Review
|
|||
|
|
|
|||
|
|
- [x] Placeholder 扫描:无 TBD
|
|||
|
|
- [x] 内部一致性:接口 §5 与服务 §3.3/§3.7 一致
|
|||
|
|
- [x] Scope:11 项 out-of-scope + 11 项 Layer 2 留白
|
|||
|
|
- [x] 决策有理由:§10
|
|||
|
|
- [x] 风险有兜底:§11
|
|||
|
|
- [x] **v2 review 14 条 + v3 review 9 条全覆盖**(见 §0)
|
|||
|
|
- [x] **Phase 0 PoC 已通过,实测发现的坑全部反映到 §10/§7.4**
|
|||
|
|
- [x] 与现有架构衔接:weixin-archive sync 不动 / model_channel 复用 / agent_channel.routeInbound 不动
|
|||
|
|
- [x] 旧 spec:weixin-uia OBSOLETE
|
|||
|
|
- [x] **通用化定位明确**:模块/API/锁/安全/配置全按通用桌面 Agent 设计,WeixinAdapter 是第一个实现
|
|||
|
|
- [x] 模型可配置:全局 desktop_op_config + channel 上 enabled
|
|||
|
|
- [x] 风险前置:Phase 0 PoC 已是门禁
|