dsh-native-uia — DeepSeek Harness 原生桌面操控插件
给官方桌面端(@deepseek-ai/dsh-desktop,本包在 0.2.0-rc.2 上验证)加 computer-use 能力。
主观测通道是 Windows UI Automation 控件树,不是截图:纯文本模型就能操控桌面,
截图只是 Canvas / 自绘界面的兜底支路。
为什么不是"配了多模态模型才给用"
| 通道 | 每步成本 | 失效方式 |
|---|---|---|
| 像素回环(Anthropic CU / Operator / Copilot Studio) | ~4.8k token/步,2–5 s | 主题、字号、窗口移动、布局重排 |
| 控件树回环(本插件) | 一次 snapshot 约 1.4k token,与屏幕分辨率无关 | 只在看不到树的应用上失效(Canvas、游戏、部分 Electron) |
所以多模态是支路而不是闸门。截图片落盘成 PNG 并返回路径,由宿主自带的
read_image 决定是否进模型——它已经有正确的路由能力判定(不在支持 image 的路由上
拒绝写入历史),本插件不复制这套判定,也就不承担把图片塞进纯文本历史的后果。
安装 / 卸载
拿到这个包的人怎么装
解压到一个稳定的目录,不要放进 DeepSeek Harness 的安装目录(
resources\app.asar旁边)——应用更新或重装会把那里清掉。推荐:- Windows:
C:\Users\<你>\.dsh\plugins\dsh-native-uia - 或任意路径,如
D:\plugins\dsh-native-uia
- Windows:
在该目录下执行:
node scripts/install.mjs install这个脚本只改 profile
package.json的两个键(dsh.profile.bundles与dependencies), 写入的是link:<本目录绝对路径>,并在改动前留一份.bak-<时间戳>备份。 它从脚本自身位置推导安装路径,因此放在哪都能装,不依赖用户名或盘符。重启 DeepSeek Harness。重启后
127.0.0.1:3099起控制页服务,会话视图出现「电脑操控」标签。
需要 node(≥18)。若 DSH_PROFILE 指向非默认 profile,可显式指定:
node scripts/install.mjs install # 默认 ~/.dsh/profiles/desktop
DSH_PROFILE=my-profile node scripts/install.mjs install # 只写 profile 名也可以
本机维护
node scripts/install.mjs install # 写入 profile 的 bundles + dependencies,自动备份
node scripts/install.mjs status
node scripts/install.mjs remove # 只删自己那两条,其它条目不动
安装后重启 DeepSeek Harness。重启后插件在 127.0.0.1:3099 起一个控制页服务,
会话视图里出现「电脑操控」标签(iframe 嵌本地服务,与 LSE 插件同构)。
端口被占用时插件拒绝激活(这个绑定同时是本机单实例锁);宿主日志会给出失败原因。
模型看到的工具
| 工具 | 作用 |
|---|---|
computer_windows |
列出可见顶层窗口,拿 windowId |
computer_snapshot |
读控件树 → 短 ref(e1…)+ revision;新快照作废旧 ref |
computer_find_text |
按文本找元素,不必拉整棵树:命中项带 ref + 可点中心坐标 + by 描述符,按"能不能点"排序 |
computer_act |
单动作:invoke / set_value / toggle / select / expand / scroll / focus |
computer_run |
一次调用批量执行,逐步本地校验,失败即停并报 performedCount |
computer_wait |
等条件而不是猜 sleep |
computer_resolve_window |
按 pid/title/class/titleRegex 解析或等待窗口,拿当前 windowId(窗口重开、改标题后的重新定位) |
computer_capture |
PNG 截图:scope="window"(默认,PrintWindow + PW_RENDERFULLCONTENT)或 scope="screen"(整个桌面,含所有显示器) |
computer_launch / computer_close |
启动并按需关闭本插件自己开的窗口 |
computer_replay |
回放成功批次的配方;元素按描述符重绑定,布局漂移后仍能跑 |
find_text 的排序:按"能不能点"
2026-10-18 真机实测(打开 DSH 设置→花费并截图)暴露:搜"设置"时,agent 自己的对话正文
(Text 节点,全部 offscreen)抢占了前 5 名,真正的 MenuItem "设置" 排第 6;搜"花费"时,
侧栏会话标题(TreeItem)压在 Button "花费" 前面。原因是旧的排序只区分"容器/非容器"。
现在按固定优先级排(同一档内保持扫描顺序):
| 档 | 条件 | 理由 |
|---|---|---|
| 0 | Button / MenuItem / Tab / CheckBox … 且有可用 pattern | 真正的命令控件 |
| 1 | 其它带可操作 pattern 的类型 | 少见但能点 |
| 2 | 其余(如纯 Text) | 可读,不可点 |
| 3 | TreeItem / ListItem / DataItem | 侧栏会话行:是导航,不是目标 |
| 4 | Pane / Document / Window / Group | 点它只是聚焦窗口 |
| 5 | isOffscreen: true |
不滚动就点不到,绝不能排在可见元素前面 |
Text 不算可操作——它带 Text pattern,若算作可操作,聊天记录里每一段都会和真控件抢位。
同时每个命中都签发 ref:此前 find_text 只给坐标和 by 描述符,而 computer_act 只收 ref,
导致搜索结果只能退化成物理 computer_click(移动真实鼠标)。现在 computer_act ref=eN 可直接接。
截图:单窗口 vs 整屏
早先只能截单个窗口,屏幕上的其它东西(第二个应用、别的进程的对话框、任务栏、副屏)在模型眼里 根本不存在——这就是"设备屏幕截不全"。现在两条路:
| scope | 截什么 | 实现 |
|---|---|---|
window(默认) |
一扇窗口,可在后台、不被遮挡 | PrintWindow(PW_RENDERFULLCONTENT),失败退 PrintWindow(0),再退屏幕 BitBlt |
screen |
整个虚拟桌面(多显示器、负原点都算) | StretchBlt 从虚拟屏原点整块拷贝,HALFTONE 缩放着色让缩小后的文字仍可读 |
两者都会回传 coordinateMap(screenX = <原点> + imgX * <scale>),可直接喂 computer_click。
maxDim 是"长边上限",不是目标宽度。它按比例同时缩宽高:1941×1243 的窗口传
maxDim=1600 得到 1600×1024(scale 1.2131),比例不变、不裁剪。默认值:
| scope | 默认 maxDim |
结果 |
|---|---|---|
window |
0(原生 1:1) | 窗口真实像素,scale = 1,坐标直接等于屏幕坐标 |
screen |
0(原生 1:1) | 桌面真实像素(本机 2560×1600),坐标可直接点 |
两个 scope 都默认原生:缩小后的截图会静默损害可读性(小字糊掉)并引入缩放系数——"看起来被裁掉"
往往就是缩放造成的错觉。要省 token 时显式传 maxDim(如 1600)。
整屏截图会临时隐藏御屏自己的浮层
指示层是always-on-top 的 WPF 窗口,所以桌面 GDI 抓屏会正确地把御屏自己录进去:顶部 "御屏·正在操控"胶囊 + 四周紫色光晕。这既是污染证据,在截图预览打开时还会递归拍到"截图的截图"。
现在 scope="screen" 走 ActivityWindow.withoutIndicator():先把指示层切到 hidden,等一个
WPF 调度轮次(50 ms)让 HWND 真正不可见,再采样桌面,最后立刻恢复原来的 phase(acting
或 stopped)。实测同一块屏,有浮层 1,068,169 字节 / 无浮层 966,504 字节,像素确有差异,
恢复后 visible=true,整个隐藏→采样→恢复耗时约 330 ms。
scope="window" 不做这个切换:PrintWindow(hwnd) 只渲染该窗口,本来就不含浮层,为它隐藏
只会让人看到一次无谓的闪烁。
屏幕尺寸一律实测,绝不硬编码:GetSystemMetrics(SM_XVIRTUALSCREEN..SM_CYVIRTUALSCREEN) 取虚拟屏
原点与尺寸,失败退主屏 metrics,再失败退 Screen.PrimaryScreen.Bounds,三者都测不到就报
screen_unknown 而不是猜一个 1920x1080——猜错会让每次坐标归一化都静默偏到别处。capture_screen
的返回里带上实测的 screenRect,模型能直接看到真实分辨率。
速度与准确性(2026-10-17 实测调优)
先说结论:坐标是准的,错在"选了谁"
同一台机器上实测坐标链路误差 0 像素:SendInput 绝对坐标请求 (530,392),光标读回
(530,392);五个测试点(含 2400,1500 屏幕角落)全部 0 偏差。150% DPI 下 worker 走
PerMonitorV2 + MOUSEEVENTF_ABSOLUTE|VIRTUALDESK 归一化,这条换算是对的。
所以"agent 老是点不准"的真因在语义:模型拿到的是对的坐标,但选错了元素。三个具体陷阱:
| 陷阱 | 现象 | 修法 |
|---|---|---|
| 容器排第一 | 搜"插件"时,窗口自身的 Pane/Document(1941x1243)先命中,点它只是聚焦窗口 |
find_text 按可操作性排序,容器排最后并标注 ←容器,click 只会聚焦窗口 |
| 同名不可分 | 窗口里有 2 个 Button "新建会话",by 只带 name+type,FindFirst 永远给第一个 |
by 携带 nth(第几个)/nearPoint(离哪最近),同名元素各自可寻址 |
| 静默无效 | invoke 一个没有 InvokePattern 的 Pane 返回成功,模型以为点中了 |
before===after 时明确回话"没有观察到任何状态变化",并指向 find_text |
nth 实测有效:同为 by={name:"新建会话",controlType:"Button"},nth=1 解析到
_2H3hWW_brand(侧栏顶部),nth=2 解析到 _2H3hWW_newSession(新建按钮),是两个不同控件。
速度
snapshot 是热路径,成本几乎全在跨进程 COM 往返上,不在数据量:一个节点的
$el.Current 之后每个属性访问仍是一次往返,加上 GetSupportedPatterns 一次,实测
13.6 ms/节点。四处改动把它降到 7.4–8.1 ms/节点(同窗口、同 200 节点):
- 属性只读一次——
Name/ClassName/ControlType/IsOffscreen/AutomationId/IsEnabled先落到局部变量再组节点;旧代码把AutomationId/IsEnabled/IsOffscreen各读了两次。 - 模式集合按签名缓存——同一控件类型的模式集合高度重复,命中后跳过字符串正则与去重。
- 默认节点预算 300 → 200——尾部多半是模型从不操作的窗口装饰;截断时会明说,并指出
三条出路(收紧过滤、用
find_text、提高maxNodes)。 - 渲染只留可操作模式——
ScrollItem,Transform,ItemContainer这类对"能不能点"无影响, 从渲染里去掉让树小约 28%(300 节点:7075 → 5107 token)。
准确性上多了一行可操作摘要:快照头部先列 ref "名字"(实测 200 节点里 66 个),模型常常
直接挑中目标去 computer_act,不必在整棵树里扫。compact: true 进一步压缩名字与值。
顺带修掉一个既存崩溃:Evict-ForReservation 与 PatternsOf 会用 $null 去索引 Hashtable,
抛 Key cannot be null,表现是整个 snapshot 偶发 internal_error——只在触发淘汰或遇到
"无名字也无 Id 的模式"时出现,所以随手跑测试永远看不见。两处都改成 ContainsKey 先行。
架构
cordis apply() ──► src/tools.js 模型面工具 + defineTool
src/guard.js 唯一的策略判定点(worker 不含策略)
src/worker-client.js stdio JSON-lines,ASCII 转义
src/dashboard.js 127.0.0.1 控制页 API/SSE
worker/uia-worker.ps1 PowerShell 5.1 + System.Windows.Automation
worker 是独立进程:UIA 调用能阻塞,进程内跑会拖死宿主事件循环;官方
dsh-host-directory-picker-native 出于同一理由也把 Win32/COM 调用放进子进程。
零新增依赖(无 koffi、无 npm 安装、无编译),所以不需要 allowBuilds 审批。
批量动作走两阶段:prepare_batch 只解析描述符不做任何事,Node 侧逐条过策略,
全绿才 commit_batch(带 revision,不匹配就 0 动作拒收)。控制页的 PAUSE/KILL
不靠约定,卡在 worker.call() 这个所有输入的必经点上。
屏幕指示层(顶部居中胶囊)
指示层由 worker/indicator-server.ps1 绘制,src/activity-window.js 驱动,只有一个所有者
(index.js;工具层不碰它——这条不变量由 verify.mjs 的闸门守着)。只有两个可见状态:
| 相位 | 什么时候 | 胶囊文字 |
|---|---|---|
acting |
一个会改动桌面的动作开始 | 御屏 · 正在操控(淡紫) |
stopped |
控制页终止 / Esc / computer_done |
御屏 · 已停止(红),1200 ms 后收起(DSH_INDICATOR_STOPPED_MS 可调) |
曾经有一个"暂停"态(
waiting:御屏 · 等待确认,琥珀)——控制页的 PAUSE 按钮早已移除, 后端调用点也随之不存在,于是整个暂停能力被删掉了,包括store.paused、pause/resume两个命令和渲染端的等待相位。停手由终止和 Esc 承担:二者都把killed闩住,下一个 试图落盘的动作会在worker.call()咽喉点被拦下并回话(这一次拦截同时清除闩锁); 再下一个动作会自动重启 worker (这是既有设计,见verify.mjs的相应闸门)。要彻底关掉,用宿主"插件"页的组件开关。
指示层进程崩溃不需要人工干预:Node 侧懒启动会在下一次指示层命令(下一次动作的
claim、心跳或收尾)自动重新派生进程,退出事件以 warn 级写进面板日志。(实测过一次
WPF 合成线程 COM 崩溃:WER 1001,DUCE.Channel.SyncFlush,Node 只看到 exit 2。)
租约与心跳分工不同(各由 verify.mjs 闸门钉住):租约(默认 300 秒,DSH_NATIVE_UIA_LEASE_MS
可覆盖)只被真实工具调用续——动作 claim、只读观测(snapshot/windows/capture/parse/
find_text)observe;它到期就是"模型忘了调 computer_done"的自动收尾兜底:静默的 run 必在
租约内以红色「已停止」熄灭,不会永生。曾有的设计让 30 秒心跳顺手续租,胶囊就再也停不下来了。
心跳只喂渲染层(防 think gap 期间指示层的 idleHideMs 看门狗先收 capsule),不续租;一个命令
真正在飞时(busy)过期自动推迟而不是硬收。只读会话本身不会点亮指示层:observe() 只对
已经在跑的 run 生效。
空闲自熄(默认 20 秒,DSH_NATIVE_UIA_IDLE_MS 可覆盖)是租约之内更快的一层:18 个工具全部
经 registerTools 里同一个包装器 touch(),所以"模型还在不在动桌面"只判定一次。模型停止调用
工具后倒计时开始,到点即以 finishRun 收尾——这正是"agent 用完御屏不会主动关闭"的修复。它
不续租(与心跳同理,由闸门钉住),并且只在 store.running === true 时武装,只读会话不会
为一个不存在的 run 安排收尾。显式 computer_done 会解除计时器并把 target 清成 null,所以
"完成"之后面板不会再挂着上一轮的目标窗口。
Esc 由指示层进程感知(它常驻且空闲,毫秒级应答),worker 只在指示层不可用时兜底。两个通道
都读 GetAsyncKeyState 的低位("自上次查询以来按过")而不只是高位("此刻是否按住"):
轮询间隔 40 ms 时,一次短于间隔的轻点也不会被漏掉——这是"点 ESC 停止很慢"的修复。
策略边界(按"开发自用"设定)
硬拒、且不可批准:系统关机/重启/注销、退出程序、破坏性删除。
被拒的步骤在 commit_batch 之前就被拦下,performedCount=0。
computer_close 只允许关闭本插件启动的窗口。
窗口的关闭按钮(关闭 / Close / ✕ / ×)与 Alt+F4 同级:invoke 它等于结束整个应用,
所以归入 exit 一并拦住。这条是补上的漏洞——早先的模式只认"退出程序/关闭程序"这种长短语,而真实
UIA 树里窗口关闭按钮就叫"关闭",于是最顺手的绕过方式一直躺在策略边界之内。不误伤 取消、确定、
最小化/最大化 以及"关闭对话框"这类正常操作(闸门同时钉住"必须拦住"和"不得误伤"两侧)。
已知未覆盖的口子,不假装:模型仍可让目标应用自己触发删除(例如选中后按键盘),
也可通过 launch 起一个能关机的程序再用输入操作它——shutdown.exe/logoff/
taskkill 这几个名字已进拒收表,但这是名字表,不是能力沙箱。要真边界请把它
当"防手滑"而不是"防对抗"。
验证
node scripts/verify.mjs # 静态闸门:解析、策略、契约、两态、心跳不续租、Esc 走指示层、工具与 worker 能力对齐、SSE 扇出防护
node scripts/check-lease.mjs # 指示层行为:租约/心跳/观察续租/终止重启/探针静默/静默自熄(不需要桌面)
node scripts/smoke.mjs # 只读真机:spawn worker、树、find_text、指示层 escape_pressed、截图、负测
node scripts/verify-live.mjs # 重启宿主后:向运行中的插件进程要 /api/state 并断言(含「契约横幅」= 加载的是哪一代代码)
D:\1\2\probe\act-e2e.mjs 通过真实工具定义跑输入路径。离线跑用的是 defineTool 的结构化桩,
因此参数 DSL 的真校验只有在宿主里加载插件才算数。
发布包
npm pack # → dsh-native-uia-0.2.0.tgz(package/ 为根)
包内即 files 列表 + README.md + LICENSE:src/、worker/(含 bin/ocr.exe)、web/、client/、scripts/、locale/、icon.svg、cordis.patch.yml。
源码仓库:https://github.com/xiaobaga281/dsh-native-uia
变更记录
0.2.0
围绕"点不准 / 截不全"的一轮实测修复(同机实测数据见各节):
find_text结果按可点击性排序:命令控件 → 其它可操作 → 文本 → 侧栏导航行 → 容器 → 屏外。 之前搜"设置"会被 agent 自己的对话正文(Text、屏外)淹没,真正的MenuItem排到第 6。find_text每个命中签发ref:此前只给坐标和 by 描述符,而computer_act只收 ref, 搜索结果只能退化成物理点击(移动真实鼠标)。- 同名元素可单独寻址:
by支持nth(第几个)与nearPoint(离哪最近)。窗口里两个Button "新建会话"之前永远只能命中第一个。 computer_act的无效动作判定改为能力表:以前用before === after,而按钮没有 Value, 导致每个正常按钮都被误报"没有状态变化"。- 整屏截图
scope="screen":此前只能截单窗口,桌面其余部分对模型不存在。 - 整屏截图时临时隐藏御屏浮层:指示层是 always-on-top,GDI 抓屏会把它自己录进去 (顶部胶囊 + 紫色光晕),预览打开时还会递归拍到"截图的截图"。现在隐藏 → 采样 → 立即恢复。
- 截图默认原生 1:1:
maxDim是"长边上限"而非目标宽度;默认不再静默缩放。 prepare_batch校验动作名:fly之前能通过 dry run,直到 commit 才报错;大小写也统一。computer_wait缺目标时立即报错:以前要空等整个超时,再回一个误导性的element_gone。- 修既存崩溃:淘汰与模式缓存用
$null索引 Hashtable,会让整个 snapshot 偶发internal_error。 - 安装脚本可移植:安装路径从脚本自身位置推导,不再写死某个用户目录;
DSH_PROFILE支持 profile 名或绝对路径两种写法。
闸门:node scripts/check-lease.mjs / node scripts/verify.mjs(53 项)/ node scripts/smoke.mjs(11 项)全绿。
0.1.0
首个可用版本:UIA 控件树为主通道、后台投递动作、批量执行与逐步校验、配方回放、 截图兜底、ESC 中断、静默自动收尾。
No comments yet. Be the first to write one.