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/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'
|
|
@@ -46,6 +47,12 @@ const COVERAGE_WARNING =
|
|
|
46
47
|
'如果界面是相同背景色(如白底)的元素位移或扩展,coverage 会非常低,但界面布局可能已经发生巨变。' +
|
|
47
48
|
'判断界面是否发生重要改变,不要只看 coverage,要以返回的 regions 和 gridPath 的实际内容为准。'
|
|
48
49
|
|
|
50
|
+
// 旁白要求:模型必须用一句自然语言说明自己在干什么,这句话会展示给用户。
|
|
51
|
+
const THOUGHT_REQUIRED =
|
|
52
|
+
'【必须写旁白】调用本工具时必须通过 thought 参数,用一句自然语言描述你当前正在做什么' +
|
|
53
|
+
'(例如「我点击了搜索框,准备输入文字」)。这句话会作为进度旁白展示给用户,' +
|
|
54
|
+
'也是人判断「这个工具到底在干什么」的唯一依据,不许省略、不许写「执行操作」这类空话。'
|
|
55
|
+
|
|
49
56
|
const SCREENSHOT_DESCRIPTION =
|
|
50
57
|
'截取主屏全屏并保存为 PNG 文件,返回文件路径(不是 base64,避免占用 token)。' +
|
|
51
58
|
'返回的图像尺寸为物理像素,坐标原点为主屏左上角。' +
|
|
@@ -113,8 +120,10 @@ const CLICK_DESCRIPTION =
|
|
|
113
120
|
'与 vp_screenshot 返回图像的坐标系一致。' +
|
|
114
121
|
'内部会除以主屏 scaleFactor 换算成逻辑坐标再注入鼠标,调用方不要自己换算。' +
|
|
115
122
|
'坐标越界会直接抛错,不会静默夹到边界。' +
|
|
116
|
-
'
|
|
117
|
-
|
|
123
|
+
'调用前必须处于持续授权(persistent):由用户按 Shift+Space 开启,' +
|
|
124
|
+
"或调用 vp_request_auth(mode='persistent') 请求人类确认。未授权时会直接抛错,不会弹卡片。" +
|
|
125
|
+
'MVP 阶段仅支持单击:不支持拖拽、滚轮、双击;键盘输入请用 vp_type / vp_key。' +
|
|
126
|
+
THOUGHT_REQUIRED +
|
|
118
127
|
BLIND_CLICK_WARNING +
|
|
119
128
|
AGENT_PROTOCOL
|
|
120
129
|
|
|
@@ -127,14 +136,48 @@ const CLICK_RELATIVE_DESCRIPTION =
|
|
|
127
136
|
'换算由本工具完成:physical.x = bbox.x + round(xPercent × (bbox.width - 1)),y 同理;不要自己算。' +
|
|
128
137
|
'index 越界、百分比不在 0~1、shotId 没有差分区域时直接抛错。' +
|
|
129
138
|
'只有 vp_screenshot_diff / vp_wait_change 产生的区域可点;普通 vp_screenshot 的 shotId 没有区域。' +
|
|
130
|
-
'
|
|
139
|
+
'调用前同样必须处于持续授权:由用户按 Shift+Space 开启,' +
|
|
140
|
+
"或调用 vp_request_auth(mode='persistent') 请求。未授权时直接抛错,不会弹卡片。" +
|
|
141
|
+
THOUGHT_REQUIRED +
|
|
131
142
|
BLIND_CLICK_WARNING +
|
|
132
143
|
AGENT_PROTOCOL
|
|
133
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
|
+
|
|
134
176
|
const AUTH_REQUEST_DESCRIPTION =
|
|
135
|
-
'
|
|
177
|
+
'请求人类开启持续授权(persistent)。这是请求,不是授权本身:' +
|
|
136
178
|
'工具会暂停,并通过宿主的人类问答界面等待用户选择“允许”或“拒绝”。' +
|
|
137
|
-
'
|
|
179
|
+
'确认后放行此后所有鼠标与键盘操作,直到用户按 Shift+Space 急停撤销。' +
|
|
180
|
+
'V1.3 起只支持持续授权,没有单次授权——单次卡片会抢走焦点,反而破坏后续注入。' +
|
|
138
181
|
'用户拒绝、取消或问答超时不会改变授权状态。' +
|
|
139
182
|
'等待期间用户按了 Shift+Space 会取代这次请求:那时本工具正常返回 confirmed:false 并带 reason 说明' +
|
|
140
183
|
'(热键接管/热键撤销),不是报错;以返回的 state 为准,不要重试请求。' +
|
|
@@ -142,15 +185,101 @@ const AUTH_REQUEST_DESCRIPTION =
|
|
|
142
185
|
|
|
143
186
|
const AUTH_STATUS_DESCRIPTION =
|
|
144
187
|
'查看 Vision-Pilot 的授权状态(只读,不改变任何权限)。' +
|
|
145
|
-
'state
|
|
146
|
-
'
|
|
147
|
-
'persistent(持续授权中)。' +
|
|
188
|
+
'state 有三种:unauthorized(未授权)、pending-persistent(持续授权待人类确认)、' +
|
|
189
|
+
'persistent(持续授权中,鼠标与键盘一律放行)。' +
|
|
148
190
|
'持续授权一直有效,直到用户按 Shift+Space 撤销;没有到期时间,也不返回剩余毫秒。' +
|
|
149
191
|
'另返回待确认请求 pendingRequest、全局热键(Shift+Space)是否可用 hotkeyAvailable / hotkeyError,' +
|
|
150
192
|
'以及授权审计日志路径 auditLogPath。' +
|
|
151
193
|
AGENT_PROTOCOL
|
|
152
194
|
|
|
153
|
-
const
|
|
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
|
+
}
|
|
154
283
|
|
|
155
284
|
/** 索引损坏等只说一次的告知;render 每个结果调用一次,所以用户会看到但它不会刷屏。 */
|
|
156
285
|
function noticeLine() {
|
|
@@ -243,12 +372,18 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
243
372
|
default: 'left',
|
|
244
373
|
description: '鼠标按键,默认 left',
|
|
245
374
|
},
|
|
375
|
+
thought: {
|
|
376
|
+
type: 'string',
|
|
377
|
+
required: true,
|
|
378
|
+
description: '一句话说明你现在要做什么(例如「我点击了地址栏,准备输入搜索词」),会作为进度旁白展示给用户',
|
|
379
|
+
},
|
|
246
380
|
},
|
|
247
381
|
output: {
|
|
248
382
|
schema: {
|
|
249
383
|
type: 'object',
|
|
250
384
|
additionalProperties: false,
|
|
251
385
|
properties: {
|
|
386
|
+
thought: { type: 'string', required: true },
|
|
252
387
|
clicked: { type: 'boolean', required: true },
|
|
253
388
|
physical: {
|
|
254
389
|
type: 'object',
|
|
@@ -274,6 +409,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
274
409
|
render: (_args, value) => [{
|
|
275
410
|
type: 'text',
|
|
276
411
|
text:
|
|
412
|
+
`旁白:${value.thought}\n` +
|
|
277
413
|
`点击完成:物理 (${value.physical.x}, ${value.physical.y}) → ` +
|
|
278
414
|
`逻辑 (${value.logical.x}, ${value.logical.y}),按键 ${value.button}\n` +
|
|
279
415
|
JSON.stringify(value),
|
|
@@ -314,12 +450,18 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
314
450
|
default: 'left',
|
|
315
451
|
description: '鼠标按键,默认 left',
|
|
316
452
|
},
|
|
453
|
+
thought: {
|
|
454
|
+
type: 'string',
|
|
455
|
+
required: true,
|
|
456
|
+
description: '一句话说明你现在要做什么(例如「我点击了搜索结果里的第二条」),会作为进度旁白展示给用户',
|
|
457
|
+
},
|
|
317
458
|
},
|
|
318
459
|
output: {
|
|
319
460
|
schema: {
|
|
320
461
|
type: 'object',
|
|
321
462
|
additionalProperties: false,
|
|
322
463
|
properties: {
|
|
464
|
+
thought: { type: 'string', required: true },
|
|
323
465
|
clicked: { type: 'boolean', required: true },
|
|
324
466
|
shotId: { type: 'number', required: true },
|
|
325
467
|
index: { type: 'number', required: true },
|
|
@@ -357,6 +499,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
357
499
|
render: (_args, value) => [{
|
|
358
500
|
type: 'text',
|
|
359
501
|
text:
|
|
502
|
+
`旁白:${value.thought}\n` +
|
|
360
503
|
`相对点击完成:shot #${value.shotId} region #${value.index},` +
|
|
361
504
|
`相对 (${value.relative.xPercent}, ${value.relative.yPercent}) → ` +
|
|
362
505
|
`物理 (${value.physical.x}, ${value.physical.y}) → ` +
|
|
@@ -370,6 +513,109 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
370
513
|
},
|
|
371
514
|
})
|
|
372
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
|
+
|
|
373
619
|
const diffTool = defineTool({
|
|
374
620
|
name: 'vp_screenshot_diff',
|
|
375
621
|
description: DIFF_DESCRIPTION,
|
|
@@ -522,9 +768,9 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
522
768
|
parameters: {
|
|
523
769
|
mode: {
|
|
524
770
|
type: 'string',
|
|
525
|
-
enum: ['
|
|
771
|
+
enum: ['persistent'],
|
|
526
772
|
required: true,
|
|
527
|
-
description:
|
|
773
|
+
description: "只支持 persistent:确认后持续放行鼠标与键盘,直到用户按 Shift+Space 撤销",
|
|
528
774
|
},
|
|
529
775
|
},
|
|
530
776
|
output: {
|
|
@@ -547,78 +793,12 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
547
793
|
}],
|
|
548
794
|
},
|
|
549
795
|
async execute(args, exec) {
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const cancelled = new Promise((_resolve, reject) => {
|
|
555
|
-
const abort = () => reject(request.signal.reason)
|
|
556
|
-
if (request.signal.aborted) abort()
|
|
557
|
-
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: '不改变授权状态',
|
|
558
800
|
})
|
|
559
|
-
|
|
560
|
-
cancelled.catch(() => {})
|
|
561
|
-
let answer
|
|
562
|
-
try {
|
|
563
|
-
if (ctx?.userQuestions === undefined) {
|
|
564
|
-
throw new Error('宿主未提供 userQuestions 服务')
|
|
565
|
-
}
|
|
566
|
-
const asking = ctx.userQuestions.ask({
|
|
567
|
-
questions: [{
|
|
568
|
-
id: 'vp-auth',
|
|
569
|
-
header: 'Vision-Pilot 授权',
|
|
570
|
-
question: args.mode === 'once'
|
|
571
|
-
? '是否允许 Vision-Pilot 执行下一次鼠标点击?'
|
|
572
|
-
: '是否允许 Vision-Pilot 持续执行鼠标点击,直到你按 Shift+Space 撤销?',
|
|
573
|
-
options: [
|
|
574
|
-
{
|
|
575
|
-
label: '允许',
|
|
576
|
-
description: args.mode === 'once' ? '只放行下一次点击' : '持续放行,直到按 Shift+Space 撤销',
|
|
577
|
-
},
|
|
578
|
-
{ label: '拒绝', description: '不改变授权状态' },
|
|
579
|
-
],
|
|
580
|
-
}],
|
|
581
|
-
...(exec?.agent !== undefined ? { agent: exec.agent } : {}),
|
|
582
|
-
...(exec?.signal !== undefined ? { signal: exec.signal } : {}),
|
|
583
|
-
})
|
|
584
|
-
// 本工具返回后那张卡片还在人面前,被关掉时宿主可能 reject;不能让悬空 rejection 冒出去。
|
|
585
|
-
asking.catch(() => {})
|
|
586
|
-
answer = await Promise.race([asking, cancelled])
|
|
587
|
-
} catch (error) {
|
|
588
|
-
if (error?.code === 'AUTH_CANCELLED') {
|
|
589
|
-
// 不算失败:请求被更明确的人的动作取代了,把终态如实报回去。
|
|
590
|
-
return {
|
|
591
|
-
requestId: request.requestId,
|
|
592
|
-
state: getStatus().state,
|
|
593
|
-
confirmed: false,
|
|
594
|
-
reason: typeof error.reason === 'string' ? error.reason : '本次请求已被取消',
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
cancelPending()
|
|
598
|
-
throw new Error(
|
|
599
|
-
`授权确认失败:${error instanceof Error ? error.message : String(error)}`,
|
|
600
|
-
)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const selected = answer?.answers?.[0]?.selected ?? []
|
|
604
|
-
if (!selected.includes('允许')) {
|
|
605
|
-
cancelPending()
|
|
606
|
-
return {
|
|
607
|
-
requestId: request.requestId,
|
|
608
|
-
state: getStatus().state,
|
|
609
|
-
confirmed: false,
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const status = confirmPending(request.requestId)
|
|
614
|
-
return {
|
|
615
|
-
requestId: request.requestId,
|
|
616
|
-
state: status.state,
|
|
617
|
-
confirmed: status.confirmed,
|
|
618
|
-
...(status.confirmed
|
|
619
|
-
? {}
|
|
620
|
-
: { reason: '答案到达时这次请求已被热键接管或已取消,按丢弃处理' }),
|
|
621
|
-
}
|
|
801
|
+
return card
|
|
622
802
|
},
|
|
623
803
|
})
|
|
624
804
|
|
|
@@ -637,7 +817,7 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
637
817
|
additionalProperties: false,
|
|
638
818
|
properties: {
|
|
639
819
|
requestId: { type: 'string', required: true },
|
|
640
|
-
mode: { type: 'string', enum: ['
|
|
820
|
+
mode: { type: 'string', enum: ['persistent'], required: true },
|
|
641
821
|
requestedAt: { type: 'number', required: true },
|
|
642
822
|
},
|
|
643
823
|
},
|
|
@@ -665,7 +845,72 @@ export function createPlugin(defineTool, ctx, hotkeyState = { available: false,
|
|
|
665
845
|
},
|
|
666
846
|
})
|
|
667
847
|
|
|
668
|
-
const
|
|
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
|
+
]
|
|
669
914
|
|
|
670
915
|
// 不再提供 apply() 方法:注册的唯一生产路径是 index.js 的 apply(ctx)(逐个 register tools),
|
|
671
916
|
// 此前挂在本方法里的启动清理因无人调用而从未触发,清理已随接线修复移入 index.js。
|
package/src/shot-index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 落盘策略是有意的:JSON 不是行式格式,无法像审计日志那样 append,
|
|
6
6
|
// 所以每次 record 都用 writeFileSync 全量覆盖。MVP 假定单进程、工具调用串行,
|
|
7
7
|
// 因此不做并发保护(两个进程同时写会互相覆盖,属于已知取舍,不是隐藏 bug)。
|
|
8
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { resolveShotsDir } from './paths.js'
|
|
11
11
|
|
|
@@ -107,6 +107,31 @@ export function get(shotId) {
|
|
|
107
107
|
return load()[String(shotId)]
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* vp_summarize 把保留的截图移进 archives/ 后,同步改索引里的路径,
|
|
112
|
+
* 否则「关键截图」虽然还在盘上,差分却会读不到。
|
|
113
|
+
*/
|
|
114
|
+
export function relocate(shotId, newPath) {
|
|
115
|
+
const entry = get(shotId)
|
|
116
|
+
if (entry === undefined) return false
|
|
117
|
+
entry.path = newPath
|
|
118
|
+
flush()
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 索引条目还在、文件却被 vp_summarize 清掉时,给出确定的错误而不是裸 ENOENT。
|
|
124
|
+
* 条目本身刻意不删:nextShotId 靠索引最大键播种,删条目会让宿主重启后复用旧 shotId,
|
|
125
|
+
* 静默指向一张新截图 —— 那比「文件不存在」危险得多。
|
|
126
|
+
*/
|
|
127
|
+
export function assertShotFileExists(shotId, meta, toolName) {
|
|
128
|
+
if (!existsSync(meta.path)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${toolName}: shotId ${shotId} 的截图文件已不存在(可能已被 vp_summarize 清理),请重新截图`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
/**
|
|
111
136
|
* 把差分区域挂到某张截图的索引条目上。
|
|
112
137
|
* vp_click_relative 用 shotId + 1-based index 反查 bbox,所以区域必须随索引落盘 ——
|