dsh-copilot-vision 1.1.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -12
- package/package.json +2 -2
- package/src/actions.js +184 -31
- package/src/audit.js +4 -2
- package/src/auth.js +73 -79
- package/src/diff.js +2 -1
- package/src/hotkey.js +87 -23
- package/src/index.js +7 -1
- package/src/keys.js +175 -0
- package/src/plugin.js +330 -85
- package/src/shot-index.js +26 -1
- package/src/summarize.js +131 -0
- package/src/wait.js +19 -1
package/README.md
CHANGED
|
@@ -6,15 +6,25 @@
|
|
|
6
6
|
|
|
7
7
|
### 双轨执行
|
|
8
8
|
|
|
9
|
-
本地轨道经 robot-ts 直连 Windows(屏幕捕获 + 鼠标注入 + 低层键盘钩子);`src/vp-ssh-core.js` 预留远程轨道接口。当前发版只启用本地轨道,远程不注册任何工具——接口在那里,能力按需放开。
|
|
9
|
+
本地轨道经 robot-ts 直连 Windows(屏幕捕获 + 鼠标注入 + 键盘注入 + 低层键盘钩子);`src/vp-ssh-core.js` 预留远程轨道接口。当前发版只启用本地轨道,远程不注册任何工具——接口在那里,能力按需放开。
|
|
10
10
|
|
|
11
11
|
### 动态视觉感知:不传全屏,传差分
|
|
12
12
|
|
|
13
13
|
`vp_screenshot_diff` 用 pixelmatch 逐像素比对,4 邻域连通块合并,小于 100 像素的噪声(光标、闪烁)直接丢弃,把「哪里变了」而不是「整屏是什么」交还给模型。`vp_wait_change` 更进一步:每 100ms 在内存里截屏比对,**轮询期零落盘**,变化出现的瞬间才落一张正式截图。「等页面加载」从反复截图碰运气,变成一次有明确语义的等待。换显示器或改缩放后坐标即不可信——宽高或 scaleFactor 不一致直接报错,绝不重采样硬算。网格放大图不再固定 4 倍:按区域面积自适应倍率——≤50,000 物理像素 4x、≤200,000 2x、更大 1x 只画网格,大区域不再产出巨图;同时以「放大后单边 ≤2,000 像素」兜底,4,344×4 这类细长条不会被放大成 17,376 宽的畸形长图。每个区域以 `gridScale` 字段标明实际倍数。`coverage` 只是变色像素的比例,白底同类元素位移时可能仅 2% 却已改天换地——判断变化要看 `regions` 与 `gridPath` 的实际内容,不能只看覆盖率。上下文策略同样内置在工具 description 里(「长程上下文纪律」):旧图会被清理、历史路径随时可能失效,模型必须以最新一次观察为准,偏离预期就直接重新截图,不翻历史记录。
|
|
14
14
|
|
|
15
|
-
###
|
|
15
|
+
### 人机共驾安全:持续授权 + 物理热键急停
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
V1.3 起授权只有三态:`unauthorized` / `pending-persistent` / `persistent`。铁律:**授权只由人的动作进入和撤销,不由时钟改变**——没有到期时间,没有剩余毫秒,持续授权等再久都不失效(回归测试用 35 秒等待证明这一点)。
|
|
18
|
+
|
|
19
|
+
- **进入授权**:按 Shift+Space 直接进入持续授权;或模型调 `vp_request_auth(mode='persistent')`,你在问答卡片上确认一次。
|
|
20
|
+
- **急停**:任何时候再按一次 Shift+Space,立即回到 `unauthorized`。这是物理热键,不经过模型、不依赖宿主 UI。
|
|
21
|
+
- **热键失效即收权(Fail-Closed)**:热键是急停的唯一通道,它一旦失效就不能继续留着持续授权。EventTap 启动失败、监听线程退出、心跳(默认每 2 秒)发现钩子不再活跃,都会立刻把状态强制收回 `unauthorized` 并写一条 `revokeAll` 审计;不会出现「授权还在、却按不动撤销」的状态。
|
|
22
|
+
- **撤销与注入竞态**:每个动作在放行时记下授权代数,真正调用 robot-ts 前再用微任务同步复核一次。人按撤销的瞬间,正在飞的这一下最多落到「已执行的移动」为止,后续注入立即中止;已经排队的其他动作一并作废。
|
|
23
|
+
- **等待可被打断**:`vp_wait_change` 轮询期间一旦授权被撤销,立刻抛「授权已被撤销,等待中断」,不会空等到超时。
|
|
24
|
+
- **未授权即拦截**:`vp_click` / `vp_click_relative` / `vp_type` / `vp_key` 在未授权时直接抛错并提示人类开启持续授权,不会自己弹卡片。
|
|
25
|
+
- **审计**:JSON Lines 记录每次授权迁移,以及每次注入的放行/拒绝(`actionAllowed` / `actionDenied`),只记动作与工具名,不记任何按键内容。
|
|
26
|
+
|
|
27
|
+
为什么取消单次授权:V1.2.2 实测发现,点卡片上的「允许」会把焦点抢回 DSH 窗口,紧接着的注入就落进错误窗口。V1.3 让键盘与鼠标共用持续授权,注入前不再有任何焦点跳转。
|
|
18
28
|
|
|
19
29
|
<!-- 配图位 1:授权问答卡片截图(宿主内 vp_request_auth 弹出的人类确认卡片) -->
|
|
20
30
|
|
|
@@ -31,14 +41,50 @@
|
|
|
31
41
|
| `vp_screenshot` | 截主屏全屏,返回 `shotId`——后续一切操作的句柄。 |
|
|
32
42
|
| `vp_screenshot_diff` | 与历史截图逐像素差分,输出差异区域 + 网格放大图。 |
|
|
33
43
|
| `vp_wait_change` | 高频轮询等待屏幕变化,变化即返回;超时不消耗 shotId。 |
|
|
34
|
-
| `vp_click` |
|
|
35
|
-
| `vp_click_relative` |
|
|
36
|
-
| `
|
|
44
|
+
| `vp_click` | 按绝对物理坐标点击(内部换算逻辑坐标,需持续授权)。 |
|
|
45
|
+
| `vp_click_relative` | 在差分区域的网格图内按相对百分比点击(需持续授权)。 |
|
|
46
|
+
| `vp_type` | 在当前焦点输入一段文本(Unicode 直注,中英文通吃;**绝不自动回车**;需持续授权)。 |
|
|
47
|
+
| `vp_key` | 发送一次按键或组合键(enter / tab / esc / ctrl+c);**回车只能由它触发**;需持续授权。 |
|
|
48
|
+
| `vp_request_auth` | 请求人类开启持续授权(仅 `mode='persistent'`),经宿主问答卡片等待确认。 |
|
|
37
49
|
| `vp_auth_status` | 只读查询授权状态、热键可用性与审计日志路径。 |
|
|
50
|
+
| `vp_summarize` | **收工必调**:归档关键截图、写入总结,清空其余全部临时碎片。 |
|
|
51
|
+
|
|
52
|
+
## 语义级记忆压缩(vp_summarize)
|
|
53
|
+
|
|
54
|
+
临时图会跨会话堆积。`vp_summarize` 让模型在**一个任务周期结束、准备收工或切换任务时**主动做一次收尾:
|
|
55
|
+
|
|
56
|
+
1. `keepShotIds` 里的关键截图(例如「初始页」和「结果页」)被移进 `archives/` 子目录,索引路径同步更新——归档后仍然可以做差分回看;
|
|
57
|
+
2. `summary` 连同本次保留清单追加写入 `archive-summary.txt`;
|
|
58
|
+
3. 当前目录下其余所有 `vp-shot-*`、`vp-diff-*`、`vp-grid-*` 立即删除,返回各类碎片数量。
|
|
59
|
+
|
|
60
|
+
**保护区**:`audit.log`(证据链)、`shot-index.json`(shotId 播种与相对点击依赖)、`archives/`(冷存储,一次归档永久保留)永远不会被删除。被清理掉的 shotId 在索引里保留条目,避免 `nextShotId` 回退导致旧 shotId 静默指向新截图;此时对它们做差分/等待会得到「文件已被 vp_summarize 清理,请重新截图」的明确错误。
|
|
61
|
+
|
|
62
|
+
与 `paths.js` 里的 `cleanupVisualFragments`(启动时删 3 天前的 diff/grid)**不冲突**:那个是按时间的兜底,只碰 diff/grid;本工具是按语义的主清理,目标集合是它的超集。
|
|
63
|
+
|
|
64
|
+
## 键盘注入:与鼠标共用持续授权
|
|
65
|
+
|
|
66
|
+
键盘能力分两个工具:`vp_type` 负责输入文本,`vp_key` 负责发送按键与组合键。V1.3 起键盘不再有独立的逐次确认通道,规则只有三条:
|
|
67
|
+
|
|
68
|
+
1. **键盘与鼠标共用 `checkCanClick()`**——`persistent` 下两者一律直接放行,`unauthorized` 下一律直接拦截。注入前不再弹卡片,因此不会出现「点允许 → 焦点跳回 DSH → 注入落错窗口」。
|
|
69
|
+
2. **`vp_type` 绝不自动发送 Enter**——回车只能由 `vp_key` 单独触发,且必须在 `vp_key` 的 `thought` 里说明意图。
|
|
70
|
+
3. **每个动作工具都要写旁白**——`vp_click` / `vp_click_relative` / `vp_type` / `vp_key` 都带必填的 `thought` 参数,用一句自然语言说明当前动作,原样回显在返回 JSON 里,作为给人看的进度旁白。
|
|
71
|
+
|
|
72
|
+
### 输入法(IME)实测结论
|
|
73
|
+
|
|
74
|
+
robot-ts 有两条注入通道,本插件按键的性质分流:
|
|
75
|
+
|
|
76
|
+
| 通道 | 底层 | 适用 |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Unicode 直注 | `KEYEVENTF_UNICODE` | 文本字符,绕开键盘布局与输入法 |
|
|
79
|
+
| 真实扫描码 | `KEYEVENTF_SCANCODE` | 控制键与组合键,保留真实按键语义 |
|
|
80
|
+
|
|
81
|
+
本机中文输入法(WeType)实测会**吞掉扫描码注入的字母键**:按下不落字,要到下一次 Enter 才吐出幽灵字符;还会把引号改写成中文弯引号。因此 `vp_key` 对「无修饰键的字符键(字母/数字/符号)」改走 Unicode 直注——`vp_key "a"` 会立刻落下 `a`,不会留下幽灵文本。控制键(enter / esc / tab / 方向键 / backspace / delete / f1-f12)、空格、以及任何带 ctrl / alt / meta 的组合键走真实扫描码:实测 Ctrl 修饰键确实送达(Ctrl+V 能粘上内容),Shift+End 能选出整段。返回值里的 `method` 字段会告诉你这次走的是哪条通道。
|
|
82
|
+
|
|
83
|
+
注意:如果用户正在用输入法打字,组合串还挂在输入法里,此时 `vp_key enter` 会把它一起提交出去。
|
|
38
84
|
|
|
39
85
|
## 长程任务工作流(The OSA V Loop)
|
|
40
86
|
|
|
41
|
-
这段协议内嵌在全部
|
|
87
|
+
这段协议内嵌在全部 10 个工具的 description 里,模型每次调用都会被提醒:
|
|
42
88
|
|
|
43
89
|
1. **观察 Observe**——每步开始前,用截图或差分确认当前屏幕状态。
|
|
44
90
|
2. **行动 Act**——点击目标(绝对坐标或相对百分比)。
|
|
@@ -57,33 +103,39 @@ DSH_HOME=<DSH_HOME> dsh plugin --profile web add dsh-copilot-vision
|
|
|
57
103
|
|
|
58
104
|
# 或从本地 tarball 安装(开发调试)
|
|
59
105
|
npm pack
|
|
60
|
-
DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.
|
|
106
|
+
DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.3.2.tgz
|
|
61
107
|
```
|
|
62
108
|
|
|
63
109
|
- **宿主**:web(问答卡片授权)/ headless(无 UI 答者,只能 Shift+Space,需真人在场)/ desktop(待 DSH 市场审核后内置验证)。
|
|
64
|
-
-
|
|
110
|
+
- **授权**:按 Shift+Space 开启或撤销持续授权;模型也可调 `vp_request_auth(mode='persistent')` 请求,你在卡片上确认一次。单次授权已取消。
|
|
65
111
|
- **平台**:仅 Windows、仅本地主屏。pnpm 11 拦截构建脚本时,在 profile 的 `pnpm-workspace.yaml` 中允许 `robot-ts` 构建;native 缺失时插件仍可加载,调用时返回可读错误。
|
|
66
112
|
- **落盘**:截图、索引、审计日志默认在 `os.tmpdir()/vision-pilot-shots/`,`VP_SHOT_DIR` 可整体重定向。
|
|
67
113
|
- **迭代**:tarball 是快照,同版本重新 `add` 会被判 "Already up to date"——改源码后标准流程是 `npm pack` → `remove` → `add`。
|
|
68
114
|
|
|
69
115
|
## 限制与边界
|
|
70
116
|
|
|
71
|
-
-
|
|
117
|
+
- **输入能力边界**:支持键盘输入(`vp_type` 文本 / `vp_key` 按键与组合键);不支持拖拽、滚轮、双击。键盘只作用于**当前焦点控件**,模型无法自己「在地址栏输入」前必须先用鼠标点中输入框。
|
|
118
|
+
- **输入法干扰**:中文输入法会吞掉扫描码注入的字母键(按下不落字,要到下一次 Enter 才吐幽灵字符)。插件已对无修饰键的字符键改走 Unicode 直注绕开它,但用户在输入法里挂着的组合串仍会被 `vp_key enter` 一起提交。
|
|
72
119
|
- **聊天窗口自干扰**:宿主聊天窗口的流式重绘会被当成屏幕变化(实测覆盖率约为变化门限的 14 倍)。等页面加载时,先把聊天窗口移出主屏或最小化。
|
|
73
120
|
- **鼠标移动不可检测**:Windows 屏幕捕获不含硬件光标,挪鼠标不产生任何像素变化。
|
|
74
121
|
- **热键无法区分真人与合成按键**:按键事件流不携带注入标记,审计日志只作事后追溯、不作防伪。真正的护栏在插件之外——宿主安全分类器与操作纪律。
|
|
122
|
+
- **静默摘钩不可观测**:心跳只能读到 robot-ts 暴露的「监听线程是否存活」。Windows 若在消息循环仍在跑的情况下静默卸掉低层钩子,绑定层看不到;此时心跳无法判定失效。插件启动失败与线程退出这两条路径已按 Fail-Closed 处理。
|
|
123
|
+
- **无热键时的残余风险**:EventTap 启动即不可用时,插件不会自动收权(启动状态本就是 `unauthorized`),但人仍可通过 `vp_request_auth` 卡片确认进入 `persistent`——此时没有物理急停通道。是否放行由人在卡片上判断;本轮未禁止该路径。
|
|
75
124
|
- **索引并发**:截图索引整份覆盖写、不做跨进程并发保护,多进程共用 `VP_SHOT_DIR` 会互相覆盖;回归测试一律用独立目录。
|
|
76
|
-
- **临时目录卫生**:超过 3 天的差分/网格切片(`vp-diff-*` / `vp-grid
|
|
125
|
+
- **临时目录卫生**:超过 3 天的差分/网格切片(`vp-diff-*` / `vp-grid-*`)会在插件启动时被自动清理;模型在收工时调用 `vp_summarize` 会把关键截图归档到 `archives/` 并清空其余全部碎片。基准截图(`vp-shot-*`)、审计日志、索引与 `archives/` 永久保留。
|
|
77
126
|
|
|
78
127
|
## 本地验证
|
|
79
128
|
|
|
80
129
|
> 以下脚本为开发期本机验证用,含本机绝对路径,**不随仓库与 npm 包分发**(`.gitignore` 已排除 `test-*.mjs`)。克隆仓库后需自备对应脚本。
|
|
81
130
|
|
|
82
131
|
```bash
|
|
83
|
-
node test-
|
|
132
|
+
node test-hotkey.mjs # 热键 Fail-Closed:触发沿、心跳失效/抛错、启动失败、dispose 幂等(全假 Session)
|
|
133
|
+
node test-mvp.mjs # 三态授权 + 热键急停/接管 + 撤销竞态 + 等待中断 + 无时钟衰减(约 45s,真实点击屏幕中心)
|
|
84
134
|
node test-diff.mjs # 差分与索引(单元层不碰屏幕;--integration 需配合动屏)
|
|
85
135
|
node test-relative.mjs # 网格渲染 + 相对坐标换算 + 未授权闸门(不截图、不注入)
|
|
86
136
|
node test-wait.mjs # 轮询语义(--integration 配合静屏/动屏/假基线)
|
|
137
|
+
node test-keyboard.mjs # 键名解析 + 注入通道分流 + 持续授权闸门(--live 会真的抢一次键盘焦点)
|
|
138
|
+
node test-summarize.mjs # 记忆压缩:5 张截图保留 2 张,校验清理、保护区与幂等(不碰屏幕)
|
|
87
139
|
```
|
|
88
140
|
|
|
89
141
|
---
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-copilot-vision",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Computer Use enhancement plugin for DeepSeek Harness (DSH). 纯 JS
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"description": "Computer Use enhancement plugin for DeepSeek Harness (DSH). 纯 JS 视觉差分、网格位置编码、键盘注入、语义级记忆压缩、长程任务闭环与人机共驾授权。",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "echo \"Error: no test specified\" && exit 1"
|
package/src/actions.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import { mkdir } from 'node:fs/promises'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { physicalToLogical, relativeToPhysical } from './coords.js'
|
|
8
|
+
import { parseKeySpec, resolveUnicodeChar } from './keys.js'
|
|
8
9
|
import { loadRobotTs } from './robot-ts.js'
|
|
9
|
-
import { checkCanClick,
|
|
10
|
+
import { checkCanClick, getAuthEpoch, getStatus } from './auth.js'
|
|
10
11
|
import { logState } from './audit.js'
|
|
11
12
|
import { resolveShotsDir } from './paths.js'
|
|
12
13
|
import { get, nextShotId, record } from './shot-index.js'
|
|
@@ -54,26 +55,67 @@ export async function screenshotAction() {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* 旁白归一化:模型给的 thought 原样回显;没给(或只给空白)时用一句默认描述兜底。
|
|
60
|
+
* 旁白是给人看的进度说明,不参与任何判定,所以缺失不该让操作失败。
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeThought(thought, fallback) {
|
|
63
|
+
if (typeof thought === 'string' && thought.trim() !== '') {
|
|
64
|
+
return thought.trim()
|
|
65
|
+
}
|
|
66
|
+
return fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const AUTH_HINT =
|
|
70
|
+
"请提示用户按 Shift+Space 开启持续授权,或调用 vp_request_auth(mode='persistent') 申请授权。"
|
|
71
|
+
|
|
72
|
+
/** 四种注入共用的拒绝路径:先落审计,再抛一句模型能照着做的错误。 */
|
|
73
|
+
function denyInjection(tool, gate, extra = {}) {
|
|
74
|
+
const state = getStatus().state
|
|
75
|
+
logState('actionDenied', 'tool', state, state, { tool, reason: gate.reason, ...extra })
|
|
76
|
+
throw new Error(gate.reason === '未授权' ? `未获授权。${AUTH_HINT}` : `${gate.reason}。${AUTH_HINT}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 放行注入后记一笔;不改变授权状态,只留「这一次真的注入了」的证据。 */
|
|
80
|
+
function allowInjection(tool, extra = {}, grantedState = getStatus().state) {
|
|
81
|
+
logState('actionAllowed', 'tool', grantedState, getStatus().state, { tool, ...extra })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 放行快照:决策时的授权代数与状态,注入前用它做最后一次同步复核。 */
|
|
85
|
+
function captureGrant(tool) {
|
|
86
|
+
const gate = checkCanClick()
|
|
87
|
+
if (!gate.allowed) denyInjection(tool, gate)
|
|
88
|
+
return { epoch: getAuthEpoch(), state: getStatus().state }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 注入前的最后闸门。动作在放行之后还要建会话、取显示器、换算坐标,这期间人可能已经
|
|
93
|
+
* 按了 Shift+Space。先让出一个微任务,给已排队的撤销回调落地;再做同步复核。
|
|
94
|
+
* 复核通过到 native 调用之间没有 await,JS 单线程保证不会再有授权翻转插进来。
|
|
95
|
+
* 用 epoch 而不是只看 allowed:撤销后又立刻授权也算翻转,旧动作不得继续飞。
|
|
96
|
+
*/
|
|
97
|
+
async function assertGrantStillValid(tool, grant) {
|
|
98
|
+
await Promise.resolve()
|
|
99
|
+
if (checkCanClick().allowed && getAuthEpoch() === grant.epoch) return
|
|
100
|
+
const state = getStatus().state
|
|
101
|
+
logState('actionDenied', 'tool', state, state, { tool, reason: '授权已被撤销' })
|
|
102
|
+
throw new Error(`授权已被撤销,动作已中止(${tool})`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 按物理像素坐标点击一次。x/y 来自 vp_screenshot 的图像坐标系;授权闸门先于坐标校验。
|
|
107
|
+
* 授权只认 persistent:unauthorized 直接抛错,不弹卡片、不消费任何东西。
|
|
108
|
+
*/
|
|
58
109
|
export async function clickAction(args = {}) {
|
|
59
110
|
const { x, y } = args
|
|
60
111
|
const gate = checkCanClick()
|
|
61
112
|
if (!gate.allowed) {
|
|
62
113
|
// 闸门在坐标校验之前,所以这里的 x/y 还没验过;只在确认是数值时才写进审计,
|
|
63
|
-
// 避免把任意入参当成坐标记录。
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
reason: gate.reason,
|
|
67
|
-
...(Number.isFinite(x) && Number.isFinite(y) ? { physical: { x, y } } : {}),
|
|
68
|
-
})
|
|
69
|
-
throw new Error(`vp_click 未获授权:${gate.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`)
|
|
70
|
-
}
|
|
71
|
-
const { Session, MouseButton } = loadRobotTs()
|
|
72
|
-
const buttons = {
|
|
73
|
-
left: MouseButton.Left,
|
|
74
|
-
right: MouseButton.Right,
|
|
75
|
-
middle: MouseButton.Middle,
|
|
114
|
+
// 避免把任意入参当成坐标记录。
|
|
115
|
+
denyInjection('vp_click', gate,
|
|
116
|
+
Number.isFinite(x) && Number.isFinite(y) ? { physical: { x, y } } : {})
|
|
76
117
|
}
|
|
118
|
+
const grant = { epoch: getAuthEpoch(), state: getStatus().state }
|
|
77
119
|
const { button = 'left' } = args
|
|
78
120
|
|
|
79
121
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
@@ -82,6 +124,18 @@ export async function clickAction(args = {}) {
|
|
|
82
124
|
if (x < 0 || y < 0) {
|
|
83
125
|
throw new Error(`vp_click: 坐标不能为负数,收到 x=${x}, y=${y}`)
|
|
84
126
|
}
|
|
127
|
+
|
|
128
|
+
return await injectClick({ x, y, button, thought: args.thought, tool: 'vp_click', grant })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 真实注入的共用实现:参数校验、越界检查、换算逻辑坐标、移动并点击。 */
|
|
132
|
+
async function injectClick({ x, y, button = 'left', thought, tool, grant }) {
|
|
133
|
+
const { Session, MouseButton } = loadRobotTs()
|
|
134
|
+
const buttons = {
|
|
135
|
+
left: MouseButton.Left,
|
|
136
|
+
right: MouseButton.Right,
|
|
137
|
+
middle: MouseButton.Middle,
|
|
138
|
+
}
|
|
85
139
|
if (!Object.hasOwn(buttons, button)) {
|
|
86
140
|
throw new Error(`vp_click: button 必须是 left|right|middle,收到 ${button}`)
|
|
87
141
|
}
|
|
@@ -102,15 +156,15 @@ export async function clickAction(args = {}) {
|
|
|
102
156
|
}
|
|
103
157
|
|
|
104
158
|
const logical = physicalToLogical({ x, y }, primary.scaleFactor)
|
|
105
|
-
|
|
106
|
-
const consumed = consumeOnce()
|
|
107
|
-
if (!consumed.allowed) {
|
|
108
|
-
throw new Error(`vp_click 未获授权:${consumed.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`)
|
|
109
|
-
}
|
|
159
|
+
await assertGrantStillValid(tool, grant)
|
|
110
160
|
await session.mouse.move(logical)
|
|
161
|
+
// 移动本身已是一次注入;点击前再复核一次,撤销发生在 move 期间就停在这里。
|
|
162
|
+
await assertGrantStillValid(tool, grant)
|
|
111
163
|
await session.mouse.click(buttons[button])
|
|
164
|
+
allowInjection(tool, { physical: { x, y }, button }, grant.state)
|
|
112
165
|
|
|
113
166
|
return {
|
|
167
|
+
thought: normalizeThought(thought, `点击屏幕物理坐标 (${x}, ${y})`),
|
|
114
168
|
clicked: true,
|
|
115
169
|
physical: { x, y },
|
|
116
170
|
logical,
|
|
@@ -121,6 +175,104 @@ export async function clickAction(args = {}) {
|
|
|
121
175
|
}
|
|
122
176
|
}
|
|
123
177
|
|
|
178
|
+
/**
|
|
179
|
+
* 在当前焦点位置输入文本。绝对不包含回车:回车只能由 vp_key 单独触发。
|
|
180
|
+
*
|
|
181
|
+
* 授权与鼠标完全一致:persistent 直接放行,unauthorized 直接拦截。
|
|
182
|
+
* V1.3 取消了键盘独立通道——单次确认卡片会把焦点抢回 DSH,注入反而落错窗口。
|
|
183
|
+
*/
|
|
184
|
+
export async function typeAction(args = {}) {
|
|
185
|
+
const grant = captureGrant('vp_type')
|
|
186
|
+
|
|
187
|
+
const { text } = args
|
|
188
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
189
|
+
throw new Error(`vp_type: text 必须是非空字符串,收到 ${typeof text === 'string' ? '空串' : typeof text}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { Session } = loadRobotTs()
|
|
193
|
+
const session = await Session.create()
|
|
194
|
+
try {
|
|
195
|
+
if (!session.capabilities.canInjectKeyboard) {
|
|
196
|
+
throw new Error('当前宿主环境不支持键盘注入(canInjectKeyboard=false)')
|
|
197
|
+
}
|
|
198
|
+
if (!session.capabilities.canTypeUnicode) {
|
|
199
|
+
throw new Error('当前宿主环境不支持 Unicode 文本注入(canTypeUnicode=false)')
|
|
200
|
+
}
|
|
201
|
+
// robot-ts 的 typeText 走 KEYEVENTF_UNICODE 直接注入码点,与键盘布局无关,
|
|
202
|
+
// 中文/emoji 都能原样打出来,不需要剪贴板中转。
|
|
203
|
+
await assertGrantStillValid('vp_type', grant)
|
|
204
|
+
await session.keyboard.typeText(text)
|
|
205
|
+
allowInjection('vp_type', { chars: text.length }, grant.state)
|
|
206
|
+
return {
|
|
207
|
+
typed: text,
|
|
208
|
+
method: 'unicode',
|
|
209
|
+
chars: text.length,
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
await session.dispose()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 只解析按键写法,不碰屏幕、不碰授权。
|
|
218
|
+
* 供宿主层在真正注入前提前报错,键名写错不该走到会话创建。
|
|
219
|
+
*/
|
|
220
|
+
export function describeKeySpec(key) {
|
|
221
|
+
const { Key, Modifier } = loadRobotTs()
|
|
222
|
+
const spec = parseKeySpec(key, Key, Modifier)
|
|
223
|
+
if (!Object.values(Key).includes(spec.code)) {
|
|
224
|
+
throw new Error(`vp_key: 键码 ${spec.code} 不在 robot-ts 的键表里(上游键表可能已变更)`)
|
|
225
|
+
}
|
|
226
|
+
return spec
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 发送一次按键或组合键(如 enter / tab / esc / ctrl+c)。
|
|
231
|
+
* 授权闸门与 vp_type 完全相同:persistent 直接放行,unauthorized 直接拦截。
|
|
232
|
+
*
|
|
233
|
+
* 注入通道按键的性质分流(本机实测):
|
|
234
|
+
* - 无修饰键的字符键(字母/数字/符号)→ Unicode 直注。扫描码通道会经过系统输入法,
|
|
235
|
+
* 中文输入法实测会吞掉字母键(按下不落字,下一次 Enter 才吐幽灵字符)、把引号改成中文弯引号。
|
|
236
|
+
* - 其余(控制键、space、带 ctrl/alt/meta 的组合键)→ 真实扫描码,
|
|
237
|
+
* 这样组合键语义是真的(Ctrl+V 实测能粘上),space 也能激活聚焦的按钮。
|
|
238
|
+
*/
|
|
239
|
+
export async function keyAction(args = {}) {
|
|
240
|
+
const grant = captureGrant('vp_key')
|
|
241
|
+
|
|
242
|
+
const { key } = args
|
|
243
|
+
const { Session, Key, Modifier } = loadRobotTs()
|
|
244
|
+
const spec = parseKeySpec(key, Key, Modifier)
|
|
245
|
+
const unicodeChar = resolveUnicodeChar(spec)
|
|
246
|
+
|
|
247
|
+
const session = await Session.create()
|
|
248
|
+
try {
|
|
249
|
+
if (!session.capabilities.canInjectKeyboard) {
|
|
250
|
+
throw new Error('当前宿主环境不支持键盘注入(canInjectKeyboard=false)')
|
|
251
|
+
}
|
|
252
|
+
if (!Object.values(Key).includes(spec.code)) {
|
|
253
|
+
throw new Error(`vp_key: 键码 ${spec.code} 不在 robot-ts 的键表里(上游键表可能已变更)`)
|
|
254
|
+
}
|
|
255
|
+
if (unicodeChar !== null && !session.capabilities.canTypeUnicode) {
|
|
256
|
+
throw new Error('当前宿主环境不支持 Unicode 文本注入(canTypeUnicode=false)')
|
|
257
|
+
}
|
|
258
|
+
await assertGrantStillValid('vp_key', grant)
|
|
259
|
+
if (unicodeChar !== null) {
|
|
260
|
+
await session.keyboard.typeChar(unicodeChar.codePointAt(0))
|
|
261
|
+
} else {
|
|
262
|
+
await session.keyboard.tap(spec.code, spec.modifiers)
|
|
263
|
+
}
|
|
264
|
+
allowInjection('vp_key', { key: spec.spec }, grant.state)
|
|
265
|
+
return {
|
|
266
|
+
key: spec.spec,
|
|
267
|
+
keyName: spec.keyName,
|
|
268
|
+
modifiers: spec.modifiers,
|
|
269
|
+
method: unicodeChar !== null ? 'unicode' : 'scancode',
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
await session.dispose()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
124
276
|
/**
|
|
125
277
|
* 把「shotId + 1-based region 序号 + 相对百分比」解析成全局物理坐标。
|
|
126
278
|
* 只查索引与做算数,不碰屏幕、不碰授权,所以可以被测试直接调用。
|
|
@@ -152,23 +304,24 @@ export function resolveRelativeTarget({ shotId, index, xPercent, yPercent }) {
|
|
|
152
304
|
}
|
|
153
305
|
|
|
154
306
|
/**
|
|
155
|
-
* 在差分区域内部按相对位置点击。授权闸门与 vp_click
|
|
156
|
-
* 授权 → 参数合法性 → 越界 →
|
|
307
|
+
* 在差分区域内部按相对位置点击。授权闸门与 vp_click 同一顺序:
|
|
308
|
+
* 授权 → 参数合法性 → 越界 → 注入,所以未授权时不会读索引、不会新建 robot-ts 会话。
|
|
157
309
|
*/
|
|
158
310
|
export async function clickRelativeAction(args = {}) {
|
|
159
|
-
const
|
|
160
|
-
if (!gate.allowed) {
|
|
161
|
-
const state = getStatus().state
|
|
162
|
-
logState('clickDenied', 'tool', state, state, { reason: gate.reason, tool: 'vp_click_relative' })
|
|
163
|
-
throw new Error(
|
|
164
|
-
`vp_click_relative 未获授权:${gate.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`,
|
|
165
|
-
)
|
|
166
|
-
}
|
|
311
|
+
const grant = captureGrant('vp_click_relative')
|
|
167
312
|
|
|
168
313
|
const { shotId, index, xPercent, yPercent, button = 'left' } = args
|
|
169
314
|
const { bbox, physical } = resolveRelativeTarget({ shotId, index, xPercent, yPercent })
|
|
170
|
-
const clicked = await
|
|
315
|
+
const clicked = await injectClick({
|
|
316
|
+
x: physical.x,
|
|
317
|
+
y: physical.y,
|
|
318
|
+
button,
|
|
319
|
+
thought: args.thought,
|
|
320
|
+
tool: 'vp_click_relative',
|
|
321
|
+
grant,
|
|
322
|
+
})
|
|
171
323
|
return {
|
|
324
|
+
thought: clicked.thought,
|
|
172
325
|
clicked: true,
|
|
173
326
|
shotId,
|
|
174
327
|
index,
|
package/src/audit.js
CHANGED
|
@@ -8,13 +8,15 @@ import { join } from 'node:path'
|
|
|
8
8
|
import { resolveShotsDir } from './paths.js'
|
|
9
9
|
|
|
10
10
|
// 状态机会记录的动作集合(回归测试据此校验,不要在这里放实现分支)。
|
|
11
|
+
// actionAllowed / actionDenied 不改变授权状态,只记录某次注入被放行或被闸门拦截。
|
|
11
12
|
export const AUDIT_ACTIONS = [
|
|
12
13
|
'requestAuth',
|
|
13
14
|
'confirmPending',
|
|
14
15
|
'cancelPending',
|
|
15
16
|
'togglePersistent',
|
|
16
|
-
'
|
|
17
|
-
'
|
|
17
|
+
'revokeAll',
|
|
18
|
+
'actionAllowed',
|
|
19
|
+
'actionDenied',
|
|
18
20
|
]
|
|
19
21
|
|
|
20
22
|
export function auditLogPath() {
|