51 KiB
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-screenshotsappName==='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)
- 微信主窗口 title 是中文
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_tasktool 调用时附 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 策略:
- 首版:要求 task.target.conversation 已经被用户手动定位为当前对话(简化版,先把核心闭环跑通)
- 后续: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) 内部:
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.runPreparedExecution 调 agentRunner 时,继承 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.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 单进程是否够用。
做法:
- 写一个临时 builtin tool
_debug_pid:execute 时返回process.pid+process.argv - 启动后端,新建 reply agent(toolset=
crew+interaction)和 desktop agent(toolset=_debug_pid+interaction) - 在 chat 界面让 reply agent 调
delegate_task({mode:'preset', agentId:<desktop>, goal:'run _debug_pid'}) - 看 subagent 调
_debug_pid返回的 pid 是否等于 backend main process pid- 等于 → tool 在 parent process(IPC proxy)→ DesktopMutex 单实例足够 ✅
- 不等于 → tool 在 subagent process → 需要把 mutex 提到跨 process(file lock 等),改 Task 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 已是门禁