# 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: `,不重试 | | 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(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(['weixin']); private dangerousKeys = new Set([ 'delete', 'win+r', 'alt+f4', 'win+l', 'ctrl+alt+delete', 'ctrl+shift+esc', ]); private dangerousActionTypes = new Set(['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 { 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`,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(); private readonly workers = new Map>(); private readonly aborters = new Map(); 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; preFlightCheck(task: DesktopTask, ctx: AdapterContext): Promise; buildSteps(task: DesktopTask): Promise; verifyResult(task: DesktopTask, ctx: AdapterContext): Promise; 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: , 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 = ↓ 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: , task: "请在群 (channelId=)里发送以下文字: " }) 不要直接输出 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:, 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 已是门禁