dsh-copilot-vision 1.0.0 → 1.3.1

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/src/plugin.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // 因此本地可以用 mock 验证注册结构,index.js 则传入真实宿主对象。
4
4
  // 本文件不直接 import 宿主包。
5
5
 
6
- import { screenshotAction, clickAction, clickRelativeAction } from './actions.js'
6
+ import { screenshotAction, clickAction, clickRelativeAction, typeAction, keyAction, normalizeThought } from './actions.js'
7
7
  import { diffAction } from './diff.js'
8
8
  import { waitChangeAction } from './wait.js'
9
+ import { summarizeAction } from './summarize.js'
9
10
  import { getStatus, requestAuth, confirmPending, cancelPending } from './auth.js'
10
11
  import { auditLogPath } from './audit.js'
11
12
  import { consumeNotice } from './shot-index.js'
@@ -21,6 +22,8 @@ const AGENT_PROTOCOL =
21
22
  '2. 行动(Act):点击目标(绝对坐标或相对坐标)。' +
22
23
  '3. 等待(Wait):点击后必须立刻调用 vp_wait_change,确认界面给出了反馈。' +
23
24
  '4. 验证(Verify):结合 wait_change 的返回结果(变化区域、超时),判断是否达到了本步目标;' +
25
+ '注意 coverage 低不代表界面没变——相同底色的元素位移或扩展可能覆盖率极低但布局已巨变,' +
26
+ '判断要以 regions 与 gridPath 的实际内容为准;' +
24
27
  '若超时未变,重新截图全局(Observe)再规划。' +
25
28
  '你的“工作日志”就是你自己每一次工具调用的原始 JSON 返回,请在每次决策前仔细阅读上一步的返回。'
26
29
 
@@ -28,6 +31,28 @@ const AGENT_PROTOCOL =
28
31
  const BLIND_CLICK_WARNING =
29
32
  '绝对禁止连续两次点击(盲点)。每次点击后,必须立刻调用 vp_wait_change 等待界面响应。'
30
33
 
34
+ // 长程上下文纪律:只追加在三个「视觉产出」工具(screenshot/diff/wait)的 description 末尾,
35
+ // 防止模型反复翻历史截图路径把上下文越滚越长。
36
+ const CONTEXT_DISCIPLINE =
37
+ '【长程上下文纪律】' +
38
+ '1. 你的物理工作目录中,历史的差分图和网格图会被定期自动清理。' +
39
+ '请永远只依赖最近一次调用返回的 shotId 和 path。' +
40
+ '2. 不要去回忆或引用十分钟前的截图路径,如果当前状态偏离预期,请直接重新截图(vp_screenshot),' +
41
+ '不要试图在历史记录里找答案。' +
42
+ '3. 历史步骤的文本结论可以留在你的记忆里,但旧图像文件随时可能失效,以最新观察为准。'
43
+
44
+ // coverage 误导:白底同类元素位移/扩展时,覆盖率可能只有百分之几,但布局已经巨变。
45
+ const COVERAGE_WARNING =
46
+ '【重要:关于 coverage】coverage 只是变色像素的面积比例。' +
47
+ '如果界面是相同背景色(如白底)的元素位移或扩展,coverage 会非常低,但界面布局可能已经发生巨变。' +
48
+ '判断界面是否发生重要改变,不要只看 coverage,要以返回的 regions 和 gridPath 的实际内容为准。'
49
+
50
+ // 旁白要求:模型必须用一句自然语言说明自己在干什么,这句话会展示给用户。
51
+ const THOUGHT_REQUIRED =
52
+ '【必须写旁白】调用本工具时必须通过 thought 参数,用一句自然语言描述你当前正在做什么' +
53
+ '(例如「我点击了搜索框,准备输入文字」)。这句话会作为进度旁白展示给用户,' +
54
+ '也是人判断「这个工具到底在干什么」的唯一依据,不许省略、不许写「执行操作」这类空话。'
55
+
31
56
  const SCREENSHOT_DESCRIPTION =
32
57
  '截取主屏全屏并保存为 PNG 文件,返回文件路径(不是 base64,避免占用 token)。' +
33
58
  '返回的图像尺寸为物理像素,坐标原点为主屏左上角。' +
@@ -36,7 +61,8 @@ const SCREENSHOT_DESCRIPTION =
36
61
  '返回值中的 scaleFactor 用于把图像坐标换算成鼠标注入所需的逻辑坐标;' +
37
62
  '调用 vp_click 时直接传图像上的物理像素坐标即可,换算由 vp_click 内部完成。' +
38
63
  '返回的 shotId 可用于 vp_screenshot_diff 的 baseline 参数。' +
39
- AGENT_PROTOCOL
64
+ AGENT_PROTOCOL +
65
+ CONTEXT_DISCIPLINE
40
66
 
41
67
  const DIFF_DESCRIPTION =
42
68
  '把当前屏幕与一张历史截图做逐像素差分,返回连通差异区域及其局部裁剪图。' +
@@ -45,17 +71,22 @@ const DIFF_DESCRIPTION =
45
71
  'regions[].bbox 与 regions[].offset 都是物理像素,原点为截图左上角,与 vp_click 的坐标系一致:' +
46
72
  '如果在局部图 localPath 上看到目标位于 (lx, ly),要点击它就传全局坐标 (bbox.x + lx, bbox.y + ly)。' +
47
73
  'localPath 是原尺寸裁剪,局部图 1 像素等于全局 1 像素。' +
48
- '每个区域还附带 gridPath:该区域 4 倍最近邻放大的网格图,红色网格横向/纵向每一格 = 20 物理像素' +
49
- '(放大后 80 像素),网格原点就是 bbox 左上角;配合 vp_click_relative 可直接按相对百分比点击,不必自己算全局坐标。' +
74
+ '每个区域还附带 gridPath:该区域的最近邻放大网格图,放大倍数按区域面积自适应' +
75
+ '(≤50000 物理像素 4 倍、≤200000 2 倍、更大 1 倍),且放大后任何单边不超过 2000 像素' +
76
+ '(长边 >500 不做 4 倍、>1000 不做 2 倍),实际倍数见 regions[].gridScale;' +
77
+ '红色网格横向/纵向每一格 = 20 物理像素,网格原点就是 bbox 左上角;' +
78
+ '配合 vp_click_relative 可直接按相对百分比点击,不必自己算全局坐标。' +
50
79
  '找目标时,优先使用此工具生成局部放大网格图(gridPath)。不要在全屏图上瞎猜坐标,尽量使用 vp_click_relative 汇报百分比。' +
51
- '区域超过 250000 物理像素、或排在第 12 个之后时不生成 gridPath,该区域带 gridSkipped 说明原因(bbox 与 localPath 仍可用)。' +
80
+ '排在第 12 个之后的区域不生成 gridPath,该区域带 gridSkipped 说明原因(bbox 与 localPath 仍可用)。' +
52
81
  '小于 100 像素的差异块按噪声(光标、闪烁)丢弃。' +
53
82
  'coverage 是差异像素占整屏像素的比例(0~1);coverage 大于 0.4 时局部图已经没有意义,建议直接看全屏截图。' +
83
+ COVERAGE_WARNING +
54
84
  'changed=false 表示没有超过噪声阈值的差异区域,不等于屏幕完全没变;要确认刚才的操作是否生效,建议看全屏。' +
55
85
  'baseline 与当前截图的宽高或 scaleFactor 不一致时直接报错(多半是换了显示器或改了缩放比例),' +
56
86
  '不做重采样硬算,请重新调用 vp_screenshot 取新的 baseline。' +
57
87
  '每次调用都会真实截一张新屏幕并写入索引,返回的 currentShotId 可以继续当 baseline 用。' +
58
- AGENT_PROTOCOL
88
+ AGENT_PROTOCOL +
89
+ CONTEXT_DISCIPLINE
59
90
 
60
91
  const WAIT_DESCRIPTION =
61
92
  '等待屏幕相对某张历史截图发生变化:工具内部循环截屏并逐像素比对,一旦变化面积超过阈值就立刻返回,' +
@@ -70,24 +101,29 @@ const WAIT_DESCRIPTION =
70
101
  '变化判据:过滤噪声(抗锯齿像素、以及小于 100 像素的连通块)后,差异像素占整屏比例 > 0.001 才算变了。' +
71
102
  '检测到变化返回 {changed:true, waitedMs, currentShotId, regions, coverage},' +
72
103
  '其中 currentShotId 是那张正式截图,可以继续当后续 baseline 用。' +
104
+ COVERAGE_WARNING +
73
105
  '超时未变返回 {changed:false, waitedMs, timeout:true},没有 currentShotId 也没有 regions;' +
74
106
  '说明这段时间屏幕真的没动。如果返回 timeout: true,代表操作可能失败或界面卡死:' +
75
107
  '绝对不要盲目重试,必须先调用 vp_screenshot 获取全局截图、重新规划,再决定下一步(包括是否继续等待)。' +
76
108
  '坐标系与 vp_screenshot_diff 完全一致:regions[].bbox 与 offset 都是物理像素、原点为截图左上角,' +
77
109
  '局部图 localPath 未放大;在局部图上看到目标位于 (lx, ly),要点它就传全局坐标 (bbox.x + lx, bbox.y + ly)。' +
78
- '每个区域还附带 gridPath:4 倍最近邻放大的网格图,每一格 = 20 物理像素(放大后 80 像素),' +
79
- '原点为 bbox 左上角;配合 vp_click_relative 可直接按相对百分比点击。' +
80
- '区域超过 250000 物理像素、或排在第 12 个之后时没有 gridPath,会带 gridSkipped 说明。' +
110
+ '每个区域还附带 gridPath:按面积自适应放大的网格图(≤50000 物理像素 4 倍、≤200000 2 倍、更大 1 倍;' +
111
+ '放大后任何单边不超过 2000 像素,长边 >500 不做 4 倍、>1000 不做 2 倍,实际倍数见 regions[].gridScale),' +
112
+ '每一格 = 20 物理像素,原点为 bbox 左上角;配合 vp_click_relative 可直接按相对百分比点击。' +
113
+ '排在第 12 个之后的区域没有 gridPath,会带 gridSkipped 说明。' +
81
114
  '它只负责「等」,不会替你制造变化:需要交互就先 vp_click,再用本工具等界面响应。' +
82
- AGENT_PROTOCOL
115
+ AGENT_PROTOCOL +
116
+ CONTEXT_DISCIPLINE
83
117
 
84
118
  const CLICK_DESCRIPTION =
85
119
  '在主屏上点击一次鼠标。入参 x/y 为物理像素坐标,原点为主屏左上角,' +
86
120
  '与 vp_screenshot 返回图像的坐标系一致。' +
87
121
  '内部会除以主屏 scaleFactor 换算成逻辑坐标再注入鼠标,调用方不要自己换算。' +
88
122
  '坐标越界会直接抛错,不会静默夹到边界。' +
89
- '调用前必须已获授权:可用 vp_request_auth 请求人类确认,或由用户按 Shift+Space 进入持续授权。' +
90
- 'MVP 阶段仅支持单击:不支持拖拽、滚轮、双击和键盘输入。' +
123
+ '调用前必须处于持续授权(persistent):由用户按 Shift+Space 开启,' +
124
+ "或调用 vp_request_auth(mode='persistent') 请求人类确认。未授权时会直接抛错,不会弹卡片。" +
125
+ 'MVP 阶段仅支持单击:不支持拖拽、滚轮、双击;键盘输入请用 vp_type / vp_key。' +
126
+ THOUGHT_REQUIRED +
91
127
  BLIND_CLICK_WARNING +
92
128
  AGENT_PROTOCOL
93
129
 
@@ -95,19 +131,53 @@ const CLICK_RELATIVE_DESCRIPTION =
95
131
  '在某个差分区域内部按相对位置点击,不需要自己计算全局像素坐标。' +
96
132
  'shotId 用 vp_screenshot_diff / vp_wait_change 返回的 currentShotId;index 是 regions 数组里的 1-based 序号。' +
97
133
  'xPercent/yPercent 是 0.0~1.0 的相对位置:0 表示区域左/上边缘,1 表示右/下边缘。' +
98
- '建议先看该区域的 gridPath:那是 4 倍最近邻放大的网格图,横向/纵向每一小格 = 20 物理像素,' +
134
+ '建议先看该区域的 gridPath:那是按面积自适应放大的网格图(倍数见 regions[].gridScale),横向/纵向每一小格 = 20 物理像素,' +
99
135
  '网格原点为 bbox 左上角;数好格子后,用「格数 × 20 ÷ bbox 宽(或高)」换算成百分比,也可以直接目测。' +
100
136
  '换算由本工具完成:physical.x = bbox.x + round(xPercent × (bbox.width - 1)),y 同理;不要自己算。' +
101
137
  'index 越界、百分比不在 0~1、shotId 没有差分区域时直接抛错。' +
102
138
  '只有 vp_screenshot_diff / vp_wait_change 产生的区域可点;普通 vp_screenshot 的 shotId 没有区域。' +
103
- '调用前同样需要授权:vp_request_auth,或由用户按 Shift+Space 进入持续授权。' +
139
+ '调用前同样必须处于持续授权:由用户按 Shift+Space 开启,' +
140
+ "或调用 vp_request_auth(mode='persistent') 请求。未授权时直接抛错,不会弹卡片。" +
141
+ THOUGHT_REQUIRED +
104
142
  BLIND_CLICK_WARNING +
105
143
  AGENT_PROTOCOL
106
144
 
145
+ const TYPE_DESCRIPTION =
146
+ '在当前有光标的焦点框内输入一段文本(输入框、搜索框、编辑区等)。' +
147
+ '底层直接注入 Unicode 码点,与键盘布局无关,中英文、符号、emoji 都能原样打出来。' +
148
+ '【绝对不含回车】:本工具只负责把文字打进去,绝不自动发送 Enter。' +
149
+ '需要提交时另调 vp_key(key=enter),并且在它的 thought 里说明你要提交。' +
150
+ '【必须先点中输入框】:调用前请确保已经用鼠标点击激活了正确的输入位置,' +
151
+ '否则文字会打到当前焦点所在的任何控件里。' +
152
+ '【授权与鼠标一致】:只要处于持续授权(persistent)就直接注入,不再弹卡片、不再抢焦点;' +
153
+ "未授权时直接抛错,请提示用户按 Shift+Space,或调用 vp_request_auth(mode='persistent')。" +
154
+ THOUGHT_REQUIRED +
155
+ AGENT_PROTOCOL
156
+
157
+ const KEY_DESCRIPTION =
158
+ '发送一次按键或组合键(例如 enter、tab、esc、space、f5、ctrl+c、shift+tab)。' +
159
+ '键名不区分大小写,支持 ctrl / shift / alt / meta 四种修饰键,用加号连接:ctrl+c。' +
160
+ '用途是发送控制键:回车提交、Esc 退出、Tab 切换焦点。' +
161
+ '【高危,请谨慎使用】:Enter 会提交/发送,Ctrl 组合键会复制、粘贴、全选、关闭窗口等,' +
162
+ '发之前必须先想清楚后果,不要为了「试试看」去按。' +
163
+ '【必须先点中目标】:本工具只把按键发给当前焦点控件,焦点不对就作用在错的窗口上。' +
164
+ '【授权与鼠标一致】:只要处于持续授权(persistent)就直接注入,不再弹卡片、不再抢焦点;' +
165
+ "未授权时直接抛错,请提示用户按 Shift+Space,或调用 vp_request_auth(mode='persistent')。" +
166
+ '【回车只能由本工具触发】:vp_type 绝不会自动回车;按回车前必须在 thought 里说清意图。' +
167
+ '【注入通道分两种,返回的 method 会告诉你走的哪条】:' +
168
+ '不带修饰键的字符键(字母/数字/符号)走 Unicode 直注——因为系统输入法会吞掉扫描码字母键' +
169
+ '(中文输入法下实测按下不落字,要到下一次 Enter 才吐出幽灵字符),Unicode 直注绕开输入法;' +
170
+ '控制键(enter/esc/tab/方向键/backspace/delete/f1-f12)、空格、以及任何带 ctrl/alt/meta 的组合键' +
171
+ '走真实扫描码,组合键语义是真的。' +
172
+ '【输入法提示】:如果用户正在用输入法打字,组合串还挂在输入法里,本工具按 Enter 会把它一起提交出去。' +
173
+ THOUGHT_REQUIRED +
174
+ AGENT_PROTOCOL
175
+
107
176
  const AUTH_REQUEST_DESCRIPTION =
108
- '请求人类授权 Vision-Pilot 执行鼠标点击。这是请求,不是授权本身:' +
177
+ '请求人类开启持续授权(persistent)。这是请求,不是授权本身:' +
109
178
  '工具会暂停,并通过宿主的人类问答界面等待用户选择“允许”或“拒绝”。' +
110
- 'mode=once 只放行下一次点击;mode=persistent 放行此后所有点击,直到用户按 Shift+Space 撤销。' +
179
+ '确认后放行此后所有鼠标与键盘操作,直到用户按 Shift+Space 急停撤销。' +
180
+ 'V1.3 起只支持持续授权,没有单次授权——单次卡片会抢走焦点,反而破坏后续注入。' +
111
181
  '用户拒绝、取消或问答超时不会改变授权状态。' +
112
182
  '等待期间用户按了 Shift+Space 会取代这次请求:那时本工具正常返回 confirmed:false 并带 reason 说明' +
113
183
  '(热键接管/热键撤销),不是报错;以返回的 state 为准,不要重试请求。' +
@@ -115,15 +185,101 @@ const AUTH_REQUEST_DESCRIPTION =
115
185
 
116
186
  const AUTH_STATUS_DESCRIPTION =
117
187
  '查看 Vision-Pilot 的授权状态(只读,不改变任何权限)。' +
118
- 'state 有五种:unauthorized(未授权)、pending-once(单次授权待人类确认)、' +
119
- 'authorized-once(单次授权已确认、待下一次点击消费)、pending-persistent(持续授权待确认)、' +
120
- 'persistent(持续授权中)。' +
188
+ 'state 有三种:unauthorized(未授权)、pending-persistent(持续授权待人类确认)、' +
189
+ 'persistent(持续授权中,鼠标与键盘一律放行)。' +
121
190
  '持续授权一直有效,直到用户按 Shift+Space 撤销;没有到期时间,也不返回剩余毫秒。' +
122
191
  '另返回待确认请求 pendingRequest、全局热键(Shift+Space)是否可用 hotkeyAvailable / hotkeyError,' +
123
192
  '以及授权审计日志路径 auditLogPath。' +
124
193
  AGENT_PROTOCOL
125
194
 
126
- const AUTH_STATES = ['unauthorized', 'pending-once', 'authorized-once', 'pending-persistent', 'persistent']
195
+ const SUMMARIZE_DESCRIPTION =
196
+ '清理本任务的视觉记忆:归档关键截图,其余临时碎片一键清空。' +
197
+ '【收工必调】:完成一个完整任务周期、准备收工或切换任务时,必须调用本工具——' +
198
+ '否则临时图会跨会话堆积,换几次会话就留一堆垃圾。' +
199
+ 'keepShotIds 填你要长期保留的关键截图 shotId(例如「初始页」和「结果页」),' +
200
+ '这些全屏截图会被移动进 archives 子目录,索引也会同步到新路径,之后仍可做差分;' +
201
+ '其余所有 vp-shot-* 全屏截图、vp-diff-* 差分切片、vp-grid-* 网格图会被立即彻底删除。' +
202
+ 'summary 是一段文字总结,会和本次保留清单一起追加写入 archive-summary.txt。' +
203
+ '审计日志 audit.log、截图索引 shot-index.json、archives 目录永远不被删除。' +
204
+ '【删除不可撤销】:调用前请确认 keepShotIds 已包含所有还需要回看的截图。' +
205
+ AGENT_PROTOCOL
206
+
207
+ const AUTH_STATES = ['unauthorized', 'pending-persistent', 'persistent']
208
+
209
+ /**
210
+ * 弹持续授权卡片并等人类回答。V1.3 只有这一种授权卡片;
211
+ * 键盘与鼠标共用持续授权,不再各自弹卡。
212
+ *
213
+ * 卡片和热键是两个都能改 pendingRequest 的人,谁先落地谁说话:热键一按,
214
+ * signal 就 abort,这里把它变成一次 reject,和 ask() 赛跑,而不是让答案撞进
215
+ * confirmPending() 变成「没有待确认的授权请求」。
216
+ */
217
+ async function askAuthCard(ctx, exec, { question, allowDescription, rejectDescription }) {
218
+ const request = requestAuth('persistent')
219
+ const cancelled = new Promise((_resolve, reject) => {
220
+ const abort = () => reject(request.signal.reason)
221
+ if (request.signal.aborted) abort()
222
+ else request.signal.addEventListener('abort', abort, { once: true })
223
+ })
224
+ // ask() 还没建立就可能同步抛错(宿主没有 userQuestions),那时没人接住这个 reject。
225
+ cancelled.catch(() => {})
226
+ let answer
227
+ try {
228
+ if (ctx?.userQuestions === undefined) {
229
+ throw new Error('宿主未提供 userQuestions 服务')
230
+ }
231
+ const asking = ctx.userQuestions.ask({
232
+ questions: [{
233
+ id: 'vp-auth',
234
+ header: 'Vision-Pilot 持续授权',
235
+ question,
236
+ options: [
237
+ { label: '允许', description: allowDescription },
238
+ { label: '拒绝', description: rejectDescription ?? '不改变授权状态' },
239
+ ],
240
+ }],
241
+ ...(exec?.agent !== undefined ? { agent: exec.agent } : {}),
242
+ ...(exec?.signal !== undefined ? { signal: exec.signal } : {}),
243
+ })
244
+ // 本工具返回后那张卡片还在人面前,被关掉时宿主可能 reject;不能让悬空 rejection 冒出去。
245
+ asking.catch(() => {})
246
+ answer = await Promise.race([asking, cancelled])
247
+ } catch (error) {
248
+ if (error?.code === 'AUTH_CANCELLED') {
249
+ // 不算失败:请求被更明确的人的动作取代了,把终态如实报回去。
250
+ return {
251
+ requestId: request.requestId,
252
+ state: getStatus().state,
253
+ confirmed: false,
254
+ reason: typeof error.reason === 'string' ? error.reason : '本次请求已被取消',
255
+ }
256
+ }
257
+ cancelPending()
258
+ throw new Error(
259
+ `授权确认失败:${error instanceof Error ? error.message : String(error)}`,
260
+ )
261
+ }
262
+
263
+ const selected = answer?.answers?.[0]?.selected ?? []
264
+ if (!selected.includes('允许')) {
265
+ cancelPending()
266
+ return {
267
+ requestId: request.requestId,
268
+ state: getStatus().state,
269
+ confirmed: false,
270
+ }
271
+ }
272
+
273
+ const status = confirmPending(request.requestId)
274
+ return {
275
+ requestId: request.requestId,
276
+ state: status.state,
277
+ confirmed: status.confirmed,
278
+ ...(status.confirmed
279
+ ? {}
280
+ : { reason: '答案到达时这次请求已被热键接管或已取消,按丢弃处理' }),
281
+ }
282
+ }
127
283
 
128
284
  /** 索引损坏等只说一次的告知;render 每个结果调用一次,所以用户会看到但它不会刷屏。 */
129
285
  function noticeLine() {
@@ -216,12 +372,18 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
216
372
  default: 'left',
217
373
  description: '鼠标按键,默认 left',
218
374
  },
375
+ thought: {
376
+ type: 'string',
377
+ required: true,
378
+ description: '一句话说明你现在要做什么(例如「我点击了地址栏,准备输入搜索词」),会作为进度旁白展示给用户',
379
+ },
219
380
  },
220
381
  output: {
221
382
  schema: {
222
383
  type: 'object',
223
384
  additionalProperties: false,
224
385
  properties: {
386
+ thought: { type: 'string', required: true },
225
387
  clicked: { type: 'boolean', required: true },
226
388
  physical: {
227
389
  type: 'object',
@@ -247,6 +409,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
247
409
  render: (_args, value) => [{
248
410
  type: 'text',
249
411
  text:
412
+ `旁白:${value.thought}\n` +
250
413
  `点击完成:物理 (${value.physical.x}, ${value.physical.y}) → ` +
251
414
  `逻辑 (${value.logical.x}, ${value.logical.y}),按键 ${value.button}\n` +
252
415
  JSON.stringify(value),
@@ -287,12 +450,18 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
287
450
  default: 'left',
288
451
  description: '鼠标按键,默认 left',
289
452
  },
453
+ thought: {
454
+ type: 'string',
455
+ required: true,
456
+ description: '一句话说明你现在要做什么(例如「我点击了搜索结果里的第二条」),会作为进度旁白展示给用户',
457
+ },
290
458
  },
291
459
  output: {
292
460
  schema: {
293
461
  type: 'object',
294
462
  additionalProperties: false,
295
463
  properties: {
464
+ thought: { type: 'string', required: true },
296
465
  clicked: { type: 'boolean', required: true },
297
466
  shotId: { type: 'number', required: true },
298
467
  index: { type: 'number', required: true },
@@ -330,6 +499,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
330
499
  render: (_args, value) => [{
331
500
  type: 'text',
332
501
  text:
502
+ `旁白:${value.thought}\n` +
333
503
  `相对点击完成:shot #${value.shotId} region #${value.index},` +
334
504
  `相对 (${value.relative.xPercent}, ${value.relative.yPercent}) → ` +
335
505
  `物理 (${value.physical.x}, ${value.physical.y}) → ` +
@@ -343,6 +513,109 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
343
513
  },
344
514
  })
345
515
 
516
+ const typeTool = defineTool({
517
+ name: 'vp_type',
518
+ description: TYPE_DESCRIPTION,
519
+ parameters: {
520
+ text: {
521
+ type: 'string',
522
+ required: true,
523
+ description: '要输入的文本(支持中英文/符号/emoji)。本工具绝不自动发送回车。',
524
+ },
525
+ thought: {
526
+ type: 'string',
527
+ required: true,
528
+ description: '一句话说明你现在要做什么(例如「我在搜索框里输入 DeepSeek」),会作为进度旁白展示给用户',
529
+ },
530
+ },
531
+ output: {
532
+ schema: {
533
+ type: 'object',
534
+ additionalProperties: false,
535
+ properties: {
536
+ thought: { type: 'string', required: true },
537
+ typed: { type: 'string', required: true },
538
+ success: { type: 'boolean', required: true },
539
+ method: { type: 'string', enum: ['unicode'], required: true },
540
+ chars: { type: 'number', required: true },
541
+ },
542
+ },
543
+ render: (_args, value) => [{
544
+ type: 'text',
545
+ text:
546
+ `旁白:${value.thought}\n` +
547
+ `输入完成:${value.typed}(${value.chars} 字符,Unicode 直注,未发送回车)\n` +
548
+ JSON.stringify(value),
549
+ }],
550
+ },
551
+ async execute(args) {
552
+ const thought = normalizeThought(args.thought, '(模型未提供旁白)')
553
+ // 授权与注入都在 typeAction 内完成:persistent 直接打字,unauthorized 直接抛错。
554
+ // V1.3 起这里不再弹卡片——点「允许」会把焦点抢回 DSH,紧接着的注入就落错窗口了。
555
+ const result = await typeAction({ text: args.text, thought })
556
+ return {
557
+ thought,
558
+ typed: result.typed,
559
+ success: true,
560
+ method: result.method,
561
+ chars: result.chars,
562
+ }
563
+ },
564
+ })
565
+
566
+ const keyTool = defineTool({
567
+ name: 'vp_key',
568
+ description: KEY_DESCRIPTION,
569
+ parameters: {
570
+ key: {
571
+ type: 'string',
572
+ required: true,
573
+ description: '键名或组合键,例如 enter / esc / tab / space / f5 / ctrl+c / shift+tab',
574
+ },
575
+ thought: {
576
+ type: 'string',
577
+ required: true,
578
+ description: '一句话说明你现在要按这个键做什么(例如「我按回车提交搜索」),会作为进度旁白展示给用户',
579
+ },
580
+ },
581
+ output: {
582
+ schema: {
583
+ type: 'object',
584
+ additionalProperties: false,
585
+ properties: {
586
+ thought: { type: 'string', required: true },
587
+ key: { type: 'string', required: true },
588
+ keyName: { type: 'string', required: true },
589
+ modifiers: { type: 'array', required: true, items: { type: 'string' } },
590
+ method: { type: 'string', enum: ['unicode', 'scancode'], required: true },
591
+ success: { type: 'boolean', required: true },
592
+ },
593
+ },
594
+ render: (_args, value) => [{
595
+ type: 'text',
596
+ text:
597
+ `旁白:${value.thought}\n` +
598
+ `按键完成:${value.key}` +
599
+ `${value.modifiers.length > 0 ? `(修饰键:${value.modifiers.join('+')})` : ''}` +
600
+ `(通道:${value.method === 'unicode' ? 'Unicode 直注' : '真实扫描码'})\n` +
601
+ JSON.stringify(value),
602
+ }],
603
+ },
604
+ async execute(args) {
605
+ const thought = normalizeThought(args.thought, '(模型未提供旁白)')
606
+ // 授权与注入都在 keyAction 内完成:persistent 直接按键,unauthorized 直接抛错。
607
+ const result = await keyAction({ key: args.key, thought })
608
+ return {
609
+ thought,
610
+ key: result.key,
611
+ keyName: result.keyName,
612
+ modifiers: result.modifiers,
613
+ method: result.method,
614
+ success: true,
615
+ }
616
+ },
617
+ })
618
+
346
619
  const diffTool = defineTool({
347
620
  name: 'vp_screenshot_diff',
348
621
  description: DIFF_DESCRIPTION,
@@ -374,6 +647,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
374
647
  bbox: BBOX_SCHEMA,
375
648
  localPath: { type: 'string', required: true },
376
649
  gridPath: { type: 'string' },
650
+ gridScale: { type: 'number' },
377
651
  gridSkipped: { type: 'string' },
378
652
  offset: OFFSET_SCHEMA,
379
653
  },
@@ -399,7 +673,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
399
673
  `全局坐标 = (bbox.x + lx, bbox.y + ly) = (${region.bbox.x} + lx, ${region.bbox.y} + ly);` +
400
674
  `局部图 ${region.localPath}` +
401
675
  (region.gridPath !== undefined
402
- ? `;网格图 ${region.gridPath}(每格 20 物理像素)`
676
+ ? `;网格图 ${region.gridPath}(${region.gridScale} 倍放大,每格 20 物理像素)`
403
677
  : `;无网格图:${region.gridSkipped}`),
404
678
  ).join('\n')
405
679
  : '未发现超过噪声阈值的差异区域;要确认操作是否生效,建议看全屏截图') +
@@ -453,6 +727,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
453
727
  bbox: BBOX_SCHEMA,
454
728
  localPath: { type: 'string', required: true },
455
729
  gridPath: { type: 'string' },
730
+ gridScale: { type: 'number' },
456
731
  gridSkipped: { type: 'string' },
457
732
  offset: OFFSET_SCHEMA,
458
733
  },
@@ -473,12 +748,12 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
473
748
  `全局坐标 = (${region.bbox.x} + lx, ${region.bbox.y} + ly);` +
474
749
  `局部图 ${region.localPath}` +
475
750
  (region.gridPath !== undefined
476
- ? `;网格图 ${region.gridPath}(每格 20 物理像素)`
751
+ ? `;网格图 ${region.gridPath}(${region.gridScale} 倍放大,每格 20 物理像素)`
477
752
  : `;无网格图:${region.gridSkipped}`),
478
753
  ).join('\n') +
479
754
  `\n` + JSON.stringify(value) + noticeLine()
480
755
  : `等待 ${value.waitedMs}ms 超时:屏幕相对 baseline #${_args.baseline} 没有可见变化,` +
481
- `本次未落盘、未消耗 shotId。可以加大 timeoutMs 再等一次,或用 vp_screenshot 看全屏。\n` +
756
+ `本次未落盘、未消耗 shotId。超时未检测到变化。请先调用 vp_screenshot 重新观察全屏,切勿盲目重试。\n` +
482
757
  JSON.stringify(value) + noticeLine(),
483
758
  }],
484
759
  },
@@ -493,9 +768,9 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
493
768
  parameters: {
494
769
  mode: {
495
770
  type: 'string',
496
- enum: ['once', 'persistent'],
771
+ enum: ['persistent'],
497
772
  required: true,
498
- description: 'once=只放行下一次点击;persistent=确认后持续放行,直到用户按 Shift+Space 撤销',
773
+ description: "只支持 persistent:确认后持续放行鼠标与键盘,直到用户按 Shift+Space 撤销",
499
774
  },
500
775
  },
501
776
  output: {
@@ -518,78 +793,12 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
518
793
  }],
519
794
  },
520
795
  async execute(args, exec) {
521
- const request = requestAuth(args.mode)
522
- // 卡片和热键是两个都能改 pendingRequest 的人,谁先落地谁说话:热键一按,
523
- // signal 就 abort,这里把它变成一次 reject,和 ask() 赛跑,而不是让答案撞进
524
- // confirmPending() 变成「没有待确认的授权请求」。
525
- const cancelled = new Promise((_resolve, reject) => {
526
- const abort = () => reject(request.signal.reason)
527
- if (request.signal.aborted) abort()
528
- else request.signal.addEventListener('abort', abort, { once: true })
796
+ const card = await askAuthCard(ctx, exec, {
797
+ question: '是否允许 Vision-Pilot 持续执行鼠标与键盘操作,直到你按 Shift+Space 撤销?',
798
+ allowDescription: '持续放行,直到按 Shift+Space 撤销',
799
+ rejectDescription: '不改变授权状态',
529
800
  })
530
- // ask() 还没建立就可能同步抛错(宿主没有 userQuestions),那时没人接住这个 reject。
531
- cancelled.catch(() => {})
532
- let answer
533
- try {
534
- if (ctx?.userQuestions === undefined) {
535
- throw new Error('宿主未提供 userQuestions 服务')
536
- }
537
- const asking = ctx.userQuestions.ask({
538
- questions: [{
539
- id: 'vp-auth',
540
- header: 'Vision-Pilot 授权',
541
- question: args.mode === 'once'
542
- ? '是否允许 Vision-Pilot 执行下一次鼠标点击?'
543
- : '是否允许 Vision-Pilot 持续执行鼠标点击,直到你按 Shift+Space 撤销?',
544
- options: [
545
- {
546
- label: '允许',
547
- description: args.mode === 'once' ? '只放行下一次点击' : '持续放行,直到按 Shift+Space 撤销',
548
- },
549
- { label: '拒绝', description: '不改变授权状态' },
550
- ],
551
- }],
552
- ...(exec?.agent !== undefined ? { agent: exec.agent } : {}),
553
- ...(exec?.signal !== undefined ? { signal: exec.signal } : {}),
554
- })
555
- // 本工具返回后那张卡片还在人面前,被关掉时宿主可能 reject;不能让悬空 rejection 冒出去。
556
- asking.catch(() => {})
557
- answer = await Promise.race([asking, cancelled])
558
- } catch (error) {
559
- if (error?.code === 'AUTH_CANCELLED') {
560
- // 不算失败:请求被更明确的人的动作取代了,把终态如实报回去。
561
- return {
562
- requestId: request.requestId,
563
- state: getStatus().state,
564
- confirmed: false,
565
- reason: typeof error.reason === 'string' ? error.reason : '本次请求已被取消',
566
- }
567
- }
568
- cancelPending()
569
- throw new Error(
570
- `授权确认失败:${error instanceof Error ? error.message : String(error)}`,
571
- )
572
- }
573
-
574
- const selected = answer?.answers?.[0]?.selected ?? []
575
- if (!selected.includes('允许')) {
576
- cancelPending()
577
- return {
578
- requestId: request.requestId,
579
- state: getStatus().state,
580
- confirmed: false,
581
- }
582
- }
583
-
584
- const status = confirmPending(request.requestId)
585
- return {
586
- requestId: request.requestId,
587
- state: status.state,
588
- confirmed: status.confirmed,
589
- ...(status.confirmed
590
- ? {}
591
- : { reason: '答案到达时这次请求已被热键接管或已取消,按丢弃处理' }),
592
- }
801
+ return card
593
802
  },
594
803
  })
595
804
 
@@ -608,7 +817,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
608
817
  additionalProperties: false,
609
818
  properties: {
610
819
  requestId: { type: 'string', required: true },
611
- mode: { type: 'string', enum: ['once', 'persistent'], required: true },
820
+ mode: { type: 'string', enum: ['persistent'], required: true },
612
821
  requestedAt: { type: 'number', required: true },
613
822
  },
614
823
  },
@@ -636,7 +845,72 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
636
845
  },
637
846
  })
638
847
 
639
- const tools = [screenshotTool, diffTool, waitTool, clickTool, clickRelativeTool, requestAuthTool, authStatusTool]
848
+ const summarizeTool = defineTool({
849
+ name: 'vp_summarize',
850
+ description: SUMMARIZE_DESCRIPTION,
851
+ parameters: {
852
+ keepShotIds: {
853
+ type: 'array',
854
+ required: true,
855
+ items: { type: 'number' },
856
+ description: '要长期保留的关键截图 shotId 列表(例如初始页与结果页);其余截图会被删除',
857
+ },
858
+ summary: {
859
+ type: 'string',
860
+ required: true,
861
+ description: '本次任务的文字总结,会连同保留清单追加写入 archive-summary.txt',
862
+ },
863
+ },
864
+ output: {
865
+ schema: {
866
+ type: 'object',
867
+ additionalProperties: false,
868
+ properties: {
869
+ summary: { type: 'string', required: true },
870
+ archivedShotIds: { type: 'array', required: true, items: { type: 'number' } },
871
+ missingShotIds: { type: 'array', required: true, items: { type: 'number' } },
872
+ failedShotIds: { type: 'array', required: true, items: { type: 'number' } },
873
+ failedArchives: { type: 'number', required: true },
874
+ removedShots: { type: 'number', required: true },
875
+ removedDiff: { type: 'number', required: true },
876
+ removedGrid: { type: 'number', required: true },
877
+ removedTotal: { type: 'number', required: true },
878
+ failedDeletes: { type: 'number', required: true },
879
+ archivesDir: { type: 'string', required: true },
880
+ summaryPath: { type: 'string', required: true },
881
+ },
882
+ },
883
+ render: (_args, value) => [{
884
+ type: 'text',
885
+ text:
886
+ `记忆压缩完成:归档 ${value.archivedShotIds.length} 张关键截图,` +
887
+ `清理 ${value.removedTotal} 个临时碎片` +
888
+ `(截图 ${value.removedShots}、差分 ${value.removedDiff}、网格 ${value.removedGrid})\n` +
889
+ (value.failedArchives > 0
890
+ ? `注意:shotId ${value.failedShotIds.join(',')} 归档失败(可能被占用),源文件已保留未删除\n`
891
+ : '') +
892
+ `总结:${value.summary}\n` +
893
+ `归档目录:${value.archivesDir}\n` +
894
+ JSON.stringify(value),
895
+ }],
896
+ },
897
+ async execute(args) {
898
+ return await summarizeAction(args)
899
+ },
900
+ })
901
+
902
+ const tools = [
903
+ screenshotTool,
904
+ diffTool,
905
+ waitTool,
906
+ clickTool,
907
+ clickRelativeTool,
908
+ typeTool,
909
+ keyTool,
910
+ requestAuthTool,
911
+ authStatusTool,
912
+ summarizeTool,
913
+ ]
640
914
 
641
915
  // 不再提供 apply() 方法:注册的唯一生产路径是 index.js 的 apply(ctx)(逐个 register tools),
642
916
  // 此前挂在本方法里的启动清理因无人调用而从未触发,清理已随接线修复移入 index.js。