GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-14-neta-desktop-op-design.md
2026-05-20 21:39:12 +08:00

51 KiB
Raw Permalink Blame History

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=truedesktopAgentId 未配置,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.tsrunAndWait 方法(同步等待)

3.3 ReAct 拓扑(v3 简化:adapter 主导)

DesktopOpRuntime.runTask(task, abort) 内部:

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:

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 升级)

@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 新增)

@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)

@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 回调)

@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)

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)

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 自动透传:

// 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 加:

runtime: {
  sessionCwd: ...,
  workspaceRoots: ...,
  bizContext: { channelId: channel.id, roomName },
  currentAgent: { id: replyAgent.id, modelChannelId: replyAgent.modelChannelId, ... },
}

注入点 2:NetaClawSubagentService.runPreparedExecutionagentRunner 时,继承 parent 的 bizContext 同时替换 currentAgent 为 subagent 自己:

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(★ 改名 + 通用化)

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)

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 接口

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)

@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.tsexport 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

  • Placeholder 扫描:无 TBD
  • 内部一致性:接口 §5 与服务 §3.3/§3.7 一致
  • Scope:11 项 out-of-scope + 11 项 Layer 2 留白
  • 决策有理由:§10
  • 风险有兜底:§11
  • v2 review 14 条 + v3 review 9 条全覆盖(见 §0)
  • Phase 0 PoC 已通过,实测发现的坑全部反映到 §10/§7.4
  • 与现有架构衔接:weixin-archive sync 不动 / model_channel 复用 / agent_channel.routeInbound 不动
  • 旧 spec:weixin-uia OBSOLETE
  • 通用化定位明确:模块/API/锁/安全/配置全按通用桌面 Agent 设计,WeixinAdapter 是第一个实现
  • 模型可配置:全局 desktop_op_config + channel 上 enabled
  • 风险前置:Phase 0 PoC 已是门禁