dsh-lens-computer-control 0.0.0-stage → 0.8.7

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/index.js ADDED
@@ -0,0 +1,943 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { createServer } from 'node:http'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * Lens —— 零第三方依赖的电脑控制。
10
+ *
11
+ * 设计铁律:模型永远不输出绝对坐标。坐标只来自本地引擎 lens.exe 算出的候选框,
12
+ * 模型的工作只是「选编号」。这样自绘窗口(微信/QQ/券商/Java 客户端)也能精确点击。
13
+ */
14
+
15
+ /** 本包目录:引擎二进制随包分发,默认从这里解析,不依赖安装机器的任何绝对路径。 */
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+
18
+ const text = (s) => [{ type: 'text', text: s }]
19
+
20
+ /** Cordis plugin identity. */
21
+ export const name = 'lens-computer-control'
22
+ /** We only need the tool registry. */
23
+ export const inject = ['tools']
24
+
25
+ function pretty(value) {
26
+ try {
27
+ return JSON.stringify(value, null, 2)
28
+ } catch {
29
+ return String(value)
30
+ }
31
+ }
32
+
33
+ /** 把报错里的窗口对象渲染成人话,让"被谁挡了/目标是哪个"一眼可见。 */
34
+ function DescribeWindowInfo(w) {
35
+ if (!w) return "(未知)"
36
+ return `${w.title || "(无标题)"} [hwnd=${w.hwnd} ${w.process || ""} ${w.frame ? w.frame.w + "x" + w.frame.h + " @" + w.frame.x + "," + w.frame.y : ""}]`
37
+ }
38
+
39
+ function describe(w) {
40
+ if (!w) return '(未知)'
41
+ const vis = w.visible === false ? ' 不可见' : ''
42
+ return `${w.title || '(无标题)'} [hwnd=${w.hwnd} ${w.process || ''}${vis}]`
43
+ }
44
+
45
+ /** 容错解析:先整体 parse,失败则截取第一个平衡的 {…} 再试。
46
+ * 实测「点了登录按钮后窗口立刻销毁」时 stdout 可能夹杂半截输出,一刀切 JSON.parse 会把
47
+ * 一次成功的动作报成失败(B3)。 */
48
+ function parseLoose(text) {
49
+ const t = String(text || '').trim()
50
+ if (!t) return null
51
+ try {
52
+ return JSON.parse(t)
53
+ } catch {
54
+ /* 继续尝试截取 */
55
+ }
56
+ const start = t.indexOf('{')
57
+ if (start < 0) return null
58
+ let depth = 0
59
+ let inStr = false
60
+ let esc = false
61
+ for (let i = start; i < t.length; i++) {
62
+ const ch = t[i]
63
+ if (inStr) {
64
+ if (esc) esc = false
65
+ else if (ch === '\\') esc = true
66
+ else if (ch === '"') inStr = false
67
+ continue
68
+ }
69
+ if (ch === '"') inStr = true
70
+ else if (ch === '{') depth++
71
+ else if (ch === '}') {
72
+ depth--
73
+ if (depth === 0) {
74
+ try {
75
+ return JSON.parse(t.slice(start, i + 1))
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return null
83
+ }
84
+ export function apply(ctx, config) {
85
+ const exePath = config.exePath || join(HERE, 'bin', 'lens.exe')
86
+ const workDir = config.workDir || join(tmpdir(), 'lens-computer-control')
87
+ const timeoutMs = Number(config.timeoutMs) > 0 ? Number(config.timeoutMs) : 25000
88
+ const hudPort = Number(config.hudPort) > 0 ? Number(config.hudPort) : 45871
89
+
90
+ /** HUD 状态:动作时间线 + 急停闸。急停一旦拉起,所有 computer_* 工具立刻拒绝执行。 */
91
+ const hud = {
92
+ stopped: false,
93
+ startedAt: Date.now(),
94
+ timeline: [],
95
+ lastScan: null,
96
+ }
97
+
98
+ const record = (kind, detail, ok) => {
99
+ hud.timeline.unshift({ at: Date.now(), kind, detail, ok })
100
+ if (hud.timeline.length > 40) hud.timeline.length = 40
101
+ }
102
+
103
+ try {
104
+ mkdirSync(workDir, { recursive: true })
105
+ } catch {
106
+ /* workDir 可能已存在或无权限,运行期再报错 */
107
+ }
108
+
109
+ const runRaw = (args) =>
110
+ new Promise((resolve) => {
111
+ execFile(
112
+ exePath,
113
+ args,
114
+ { timeout: timeoutMs, windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
115
+ (err, stdout, stderr) => {
116
+ const out = String(stdout || '').trim()
117
+ if (out) {
118
+ const parsed = parseLoose(out)
119
+ if (parsed !== null) {
120
+ resolve(parsed)
121
+ return
122
+ }
123
+ resolve({
124
+ ok: false,
125
+ code: 'BAD_OUTPUT',
126
+ message: '引擎输出无法解析为 JSON',
127
+ raw: out.slice(0, 4000),
128
+ stderr: String(stderr || '').slice(0, 1000),
129
+ exitCode: err && typeof err.code === 'number' ? err.code : null,
130
+ })
131
+ return
132
+ }
133
+ resolve({
134
+ ok: false,
135
+ error: err ? String(err.message || err) : 'no output',
136
+ stderr: String(stderr || '').slice(0, 2000),
137
+ })
138
+ },
139
+ )
140
+ })
141
+
142
+ // 首次调用时把视觉反馈层拉起来(渐变蓝边 + 顶部胶囊)。失败不影响主流程。
143
+ let hudReady = false
144
+ const ensureHudOnce = async () => {
145
+ if (hudReady) return
146
+ hudReady = true
147
+ try {
148
+ await runRaw(['hud', '--action', 'start'])
149
+ } catch {
150
+ /* HUD 只是锦上添花 */
151
+ }
152
+ }
153
+
154
+ /** 每次引擎调用都进时间线,HUD 靠它显示"它刚才干了什么"。 */
155
+ const run = async (args) => {
156
+ await ensureHudOnce()
157
+ const verb = args[0]
158
+ if (hud.stopped && verb !== 'info' && verb !== 'verify' && verb !== 'focus') {
159
+ record(verb, 'blocked by 急停', false)
160
+ return { ok: false, error: 'EMERGENCY_STOP: 急停已生效,动作被拒绝。请让用户先解除急停。' }
161
+ }
162
+ const label = args
163
+ .slice(1)
164
+ .map((a) => String(a))
165
+ .join(' ')
166
+ // 把宿主 pid 传给引擎(追加在末尾:第一个参数必须是命令):它的窗口绝不能成为操作目标
167
+ const result = await runRaw(args.concat(['--excludePid', String(process.pid)]))
168
+ const ok = result && result.ok !== false
169
+ record(verb, label.slice(0, 90), ok)
170
+ if (verb === 'scan' && result && result.ok) {
171
+ hud.lastScan = {
172
+ at: Date.now(),
173
+ window: result.window && result.window.title,
174
+ level: result.capability && result.capability.level,
175
+ candidates: (result.candidates || []).length,
176
+ overlay: result.overlay,
177
+ }
178
+ }
179
+ return result
180
+ }
181
+
182
+ // ---------------------------------------------------------------- tool 1
183
+ const windowsTool = {
184
+ name: 'computer_windows',
185
+ description:
186
+ '列出当前所有可见的顶层窗口(标题、hwnd、DPI、进程、最小化状态、精确可见边框)。' +
187
+ '在做任何点击之前先用它拿到目标窗口,后续所有操作都用窗口标题的稳定片段或 hwnd 定位。',
188
+ parameters: {
189
+ type: 'object',
190
+ properties: {
191
+ filter: { type: 'string', description: '可选,只返回标题或进程名包含该子串的窗口' },
192
+ },
193
+ additionalProperties: false,
194
+ },
195
+ output: {
196
+ schema: {
197
+ type: 'object',
198
+ properties: {
199
+ ok: { type: 'boolean' },
200
+ count: { type: 'number' },
201
+ windows: { type: 'array', items: { type: 'object' } },
202
+ error: { type: 'string' },
203
+ },
204
+ additionalProperties: true,
205
+ },
206
+ render: (_args, value) => {
207
+ if (!value || value.ok !== true) return text(`列出窗口失败:${(value && value.error) || '未知错误'}`)
208
+ const lines = value.windows.map(
209
+ (w) =>
210
+ `hwnd=${w.hwnd} dpi=${w.dpi} ${w.minimized ? '[最小化]' : ''} ${w.process} | ${w.title} (${w.frame.w}x${w.frame.h} @ ${w.frame.x},${w.frame.y})`,
211
+ )
212
+ return text(`共 ${value.count} 个窗口:\n${lines.join('\n')}`)
213
+ },
214
+ },
215
+ async execute(args) {
216
+ const list = (await run(['info', '--json'])) || []
217
+ if (!Array.isArray(list)) {
218
+ return { ok: false, error: 'info 命令未返回窗口数组', detail: list }
219
+ }
220
+ const filter = String(args?.filter || '').trim().toLowerCase()
221
+ const filtered = filter
222
+ ? list.filter(
223
+ (w) =>
224
+ String(w.title || '').toLowerCase().includes(filter) ||
225
+ String(w.process || '').toLowerCase().includes(filter),
226
+ )
227
+ : list
228
+ return { ok: true, count: filtered.length, windows: filtered }
229
+ },
230
+ }
231
+
232
+ // ---------------------------------------------------------------- tool 2
233
+ const scanTool = {
234
+ name: 'computer_scan',
235
+ description:
236
+ '扫描一个窗口,产出「可点击候选表」并渲染一张带编号的标注图。**有文字控件的界面优先用它**——一次就能拿到全部候选。\n但如果候选里没有你要点的东西、或返回里出现「扫描质量差」的警告,**立刻改用 computer_vision**,不要重扫。\n' +
237
+ '候选来自多通道:uia(无障碍树) / solid(实心色块按钮) / ocr(文字) / table(表格行列) / template(已教过的锚点)。' +
238
+ '自绘窗口(微信、QQ、券商行情、Java 客户端)通常 UIA 为空,靠 solid+ocr+table 兜住。\n' +
239
+ '返回的每个候选带 id、通道、类型、屏幕物理坐标 screen{cx,cy}、文字与颜色。' +
240
+ '**你必须只用候选的 id 来指定点击目标,绝不要自己编造坐标。**\n' +
241
+ 'overlay 字段是一张标注图路径,需要看图确认时用 read_image 打开它。\n' +
242
+ '【微信/QQ 这类 Qt 自绘窗口】uia 基本为空(节点 <10),候选主要来自 ocr:一次约 20–40 个,' +
243
+ '够用但**文字会认错**(老师→老丝、取消→取肖、语音通话→浯音通话),所以 find=text 要写短一点的关键词、' +
244
+ '不要拿整句去精确匹配。会话列表在左、消息区在右;找不到某个联系人时先 scroll 列表或直接用搜索框,别硬扫。',
245
+ parameters: {
246
+ type: 'object',
247
+ properties: {
248
+ window: { type: 'string', description: '窗口标题片段或 hwnd' },
249
+ channels: {
250
+ type: 'string',
251
+ description: '可选,限定通道,逗号分隔:uia,solid,ocr,table,template。默认全开。',
252
+ },
253
+ noRestore: {
254
+ type: 'boolean',
255
+ description: '可选,窗口最小化时不要自动恢复(只看不碰)',
256
+ },
257
+ },
258
+ required: ['window'],
259
+ additionalProperties: false,
260
+ },
261
+ output: {
262
+ schema: {
263
+ type: 'object',
264
+ properties: {
265
+ ok: { type: 'boolean' },
266
+ window: { type: 'object' },
267
+ capability: { type: 'object' },
268
+ overlay: { type: 'string' },
269
+ candidates: { type: 'array', items: { type: 'object' } },
270
+ notes: { type: 'array', items: { type: 'string' } },
271
+ error: { type: 'string' },
272
+ },
273
+ additionalProperties: true,
274
+ },
275
+ render: (_args, value) => {
276
+ if (!value) return text('扫描无返回')
277
+ if (value.ok !== true) {
278
+ const why = [value.code || value.error || 'UNKNOWN', value.message || ''].join(' ').trim()
279
+ return text(`扫描失败 [${why}]` + (value.hint ? `\n建议:${value.hint}` : '') + (value.notes ? '\n' + value.notes.join('\n') : ''))
280
+ }
281
+ const cap = value.capability || {}
282
+ const head = [
283
+ `窗口: ${value.window?.title} (hwnd=${value.window?.hwnd}, dpi=${value.window?.dpi}, ${value.window?.frame?.w}x${value.window?.frame?.h} @ ${value.window?.frame?.x},${value.window?.frame?.y})`,
284
+ `能力: level=${cap.level} 捕获=${cap.captureMethod} 非黑=${cap.nonBlackRatio} uia=${cap.uiaUsable ? '可用' : '不可用'}/${cap.uiaNodes}节点 ocr=${cap.ocrWords}词 表格=${cap.tableDetected ? '有' : '无'}`,
285
+ `候选: ${value.candidates?.length || 0} 个,标注图: ${value.overlay || '无'}`,
286
+ value.fingerprint
287
+ ? `指纹: ${value.fingerprint} ← 逐格/按坐标操作时可回传给 computer_act {expectedFingerprint:"${value.fingerprint}"},界面一变就会被拒绝`
288
+ : null,
289
+ ].filter(Boolean)
290
+ const rows = (value.candidates || []).slice(0, 80).map((c) => {
291
+ const t = c.text ? ` "${c.text}"` : ''
292
+ const col = c.color ? ` ${c.color}` : ''
293
+ return ` #${c.id} ${c.channel}/${c.kind}${t}${col} screen=(${c.screen.cx},${c.screen.cy}) ${c.screen.w}x${c.screen.h} score=${c.score}`
294
+ })
295
+ const more =
296
+ (value.candidates?.length || 0) > 80 ? `\n …还有 ${value.candidates.length - 80} 个候选` : ''
297
+
298
+ // 动态触发:扫描质量差的时候,直接在结果里点名让模型换 vision。
299
+ // 只写在工具描述里没用——模型看的是这次返回的内容。
300
+ const n = value.candidates?.length || 0
301
+ const weak = []
302
+ if (!cap.uiaUsable && n < 8) weak.push(`UIA 不可用且只有 ${n} 个候选`)
303
+ if (n === 0) weak.push('一个候选都没有')
304
+ // OCR 词数极少通常意味着"内容区完全没认出来",UIA 这时往往只给了标题栏控件
305
+ if (cap.ocrWords !== undefined && cap.ocrWords < 10 && n < 20) weak.push(`OCR 只认出 ${cap.ocrWords} 个词`)
306
+ if (!cap.uiaUsable && !cap.ocrWords) weak.push('识别通道基本失灵')
307
+ const advice = weak.length
308
+ ? `\n\n⚠ 本次扫描质量差(${weak.join(';')})。如果候选里没有你要点的东西,**别再重复扫描或猜坐标**,` + `直接用 computer_vision 从画面里逐格定位——它不需要任何识别通道,一定能找到。`
309
+ : ''
310
+ return text(`${head.join('\n')}\n${rows.join('\n')}${more}${advice}\n通道日志: ${(value.notes || []).join(' | ')}`)
311
+ },
312
+ },
313
+ async execute(args) {
314
+ const w = String(args?.window || '').trim()
315
+ if (!w) return { ok: false, error: '缺少 window 参数' }
316
+ const stamp = Date.now().toString(36)
317
+ const overlay = `${workDir.replace(/\\/g, '/')}/overlay-${stamp}.png`
318
+ const argv = ['scan', '--window', w, '--json', '--overlay', overlay]
319
+ if (args?.channels) argv.push('--channels', String(args.channels))
320
+ if (args?.noRestore) argv.push('--no-restore')
321
+ const res = await run(argv)
322
+ if (res && res.ok !== undefined) return res
323
+ return res || { ok: false, error: 'scan 无输出' }
324
+ },
325
+ }
326
+
327
+ // ---------------------------------------------------------------- tool 3
328
+ const actTool = {
329
+ name: 'computer_act',
330
+ description:
331
+ '对窗口执行一个动作。三种最常用形态:\n' +
332
+ '1) 点候选:{window, id} —— 强烈推荐,坐标由引擎按候选框计算。\n' +
333
+ '2) 发键:{window, combo} —— 如 "ctrl+a"、"enter"、"alt+tab",键盘流往往比点击更可靠。\n' +
334
+ '3) 输入文字:{window, text} —— 支持中文(Unicode 注入)。\n' +
335
+ '4) 粘贴:{window, action:"paste", text} —— **超过 20 个字符优先用这个**:一次原子操作,' +
336
+ '不怕中途焦点漂移;完成后会自动还原你原来的剪贴板。\n' +
337
+ '可选 background=true:用窗口消息后台点击,不抢焦点、不动真实鼠标(对 Qt/Win32 自绘窗口常常有效)。\n' +
338
+ '默认会先把目标窗口强制前台(系统可能拒绝,返回里的 notes 会说明),然后再点。\n' +
339
+ '【微信】点会话/联系人优先用 {id}(来自 computer_scan 的候选编号),比坐标稳;' +
340
+ '发消息时先点输入框再 {text:"…"} 再 {combo:"enter"}。' +
341
+ '窗口点完消失(比如登录成功后登录窗关闭)会返回 warning:"window_gone",那是成功不是失败。' +
342
+ '登录后主窗是**新的 hwnd**,别继续用登录窗的句柄,用 window:"微信" 重新定位。\n' +
343
+ '【点不准时】不要反复试坐标;没有可用候选(图标/画布/游戏)就用 computer_vision 定位后再回来点。',
344
+ parameters: {
345
+ type: 'object',
346
+ properties: {
347
+ window: { type: 'string', description: '窗口标题片段或 hwnd' },
348
+ action: {
349
+ type: 'string',
350
+ enum: ['click', 'key', 'type', 'paste', 'move', 'focus', 'scroll', 'wait'],
351
+ description: '要执行的动作;省略时按 id/坐标推断为 click',
352
+ },
353
+ id: { type: 'number', description: 'computer_scan 返回的候选编号(click 用)' },
354
+ cell: { type: 'string', description: '网格单元 "列,行"(computer_vision mode:"grid" 得到),凑不齐候选时用它精确定位' },
355
+ x: { type: 'number', description: '屏幕物理坐标 x(无 id 时使用;scroll 时是滚动点)' },
356
+ y: { type: 'number', description: '屏幕物理坐标 y(无 id 时使用;scroll 时是滚动点)' },
357
+ button: { type: 'string', enum: ['left', 'right', 'middle'], description: '鼠标键,默认 left' },
358
+ count: { type: 'number', description: '点击次数,默认 1;2 表示双击' },
359
+ combo: { type: 'string', description: '组合键,如 ctrl+shift+s' },
360
+ text: { type: 'string', description: '要输入的文字' },
361
+ background: { type: 'boolean', description: '后台消息点击,不抢焦点' },
362
+ delta: { type: 'number', description: 'scroll 时滚几格:正=上/左,负=下/右,默认 -3' },
363
+ horizontal: { type: 'boolean', description: 'scroll 时横向滚动' },
364
+ expectedFingerprint: { type: 'string', description: '扫描时拿到的窗口指纹;给了就校验界面有没有变过,变了就拒绝执行(防止按旧坐标打偏)' },
365
+ condition: { type: 'string', enum: ['exists', 'visible', 'foreground'], description: 'wait 时等什么条件' },
366
+ timeout: { type: 'number', description: 'wait 时最长等待毫秒,默认 8000' },
367
+ verify: {
368
+ type: 'boolean',
369
+ description: '动作后自动做像素差分核验(省一次 computer_verify 调用);changed=false 说明没生效,应换策略而不是重复点',
370
+ },
371
+ },
372
+ required: ['window'],
373
+ additionalProperties: false,
374
+ },
375
+ output: {
376
+ schema: {
377
+ type: 'object',
378
+ properties: {
379
+ ok: { type: 'boolean' },
380
+ x: { type: 'number' },
381
+ y: { type: 'number' },
382
+ button: { type: 'string' },
383
+ notes: { type: 'array', items: { type: 'string' } },
384
+ error: { type: 'string' },
385
+ },
386
+ additionalProperties: true,
387
+ },
388
+ render: (_args, value) => {
389
+ if (!value) return text('动作无返回')
390
+ if (value.ok === false) {
391
+ const lines = [`动作失败 [${value.code || value.error || 'UNKNOWN'}] ${value.message || ''}`]
392
+ if (value.target) lines.push(`目标窗口:${describe(value.target)}`)
393
+ if (value.coveredBy) lines.push(`被遮挡于:${describe(value.coveredBy)}`)
394
+ if (value.hint) lines.push(`建议:${value.hint}`)
395
+ if (value.raw) lines.push(`原始输出:${String(value.raw).slice(0, 300)}`)
396
+ if (value.notes?.length) lines.push(`细节:${value.notes.join(';')}`)
397
+ lines.push('提示:若原因是「找不到目标 / 候选对不上」,不要反复重试——改用 computer_vision 从画面逐格定位。')
398
+ return text(lines.join('\n'))
399
+ }
400
+ const lines = []
401
+ const bits = []
402
+ // wait 的结果是 {for, elapsedMs, window},没有坐标,单独渲染
403
+ if (value.for && value.elapsedMs !== undefined) {
404
+ return text(`等待完成:${value.for} 已满足(${value.elapsedMs}ms)${value.window?.title ? ' — ' + value.window.title : ''}`)
405
+ }
406
+ if (value.combo) return text(`已发送按键 ${value.combo}${value.notes?.length ? '(' + value.notes.join(';') + ')' : ''}`)
407
+ if (value.pasted) return text(`已粘贴 ${value.pasted} 个字符${value.notes?.length ? '(' + value.notes.join(';') + ')' : ''}`)
408
+ if (value.chars !== undefined && value.x === undefined) {
409
+ return text(`已输入 ${value.chars} 个字符${value.foreground === false ? '(注意:发送时目标窗口不在前台)' : ''}`)
410
+ }
411
+ if (value.x !== undefined) bits.push(`坐标 (${value.x},${value.y})`)
412
+ if (value.button) bits.push(`按钮 ${value.button}`)
413
+ if (value.warning === 'window_gone') {
414
+ lines.push('动作已执行;目标窗口随后关闭(通常说明点对了,例如登录成功后登录窗消失)')
415
+ }
416
+ lines.push(`动作已执行 ${bits.length ? '— ' + bits.join(',') : ''}`)
417
+ if (value.notes?.length) lines.push(`细节:${value.notes.join(';')}`)
418
+ if (value.verify) {
419
+ lines.push(value.verify.changed ? '核验:画面有变化,动作生效' : '核验:画面没变化(changed=false)——这一下可能没生效,换策略而不是重复点')
420
+ }
421
+ return text(lines.join('\n'))
422
+ },
423
+ },
424
+ async execute(args) {
425
+ const w = String(args?.window || '').trim()
426
+ if (!w) return { ok: false, code: 'BAD_ARGS', message: '缺少 window 参数' }
427
+
428
+ // B13:action 必须白名单命中,未识别就明确报错。
429
+ // 旧实现会静默落到 click 兜底,x/y 缺省成 0 → 在屏幕左上角点了一下还报 ok:true。
430
+ const ALLOWED = ['click', 'key', 'type', 'paste', 'move', 'focus', 'scroll', 'wait']
431
+ const action = String(args?.action || 'click')
432
+ if (!ALLOWED.includes(action)) {
433
+ return {
434
+ ok: false,
435
+ code: 'BAD_ACTION',
436
+ message: `不支持的 action: ${action}`,
437
+ allowed: ALLOWED,
438
+ hint: '想等界面变化用 action:"wait";想点击用 id / cell / x,y 之一。',
439
+ }
440
+ }
441
+
442
+ if (action === 'wait') {
443
+ const wv = ['wait', '--window', w, '--for', String(args?.condition || 'exists')]
444
+ wv.push('--timeout', String(args?.timeout || 8000))
445
+ return run(wv)
446
+ }
447
+ if (action === 'focus') return run(['focus', '--window', w])
448
+ if (action === 'paste') {
449
+ // 长文本用粘贴:一次原子操作,不怕中途焦点漂移;用完会把原剪贴板还原
450
+ await run(['focus', '--window', w])
451
+ const txt = String(args?.text || '')
452
+ if (!txt) return { ok: false, code: 'BAD_ARGS', message: 'paste 需要 text' }
453
+ return run(['paste', '--text', txt])
454
+ }
455
+ if (action === 'key' || action === 'type') {
456
+ // 键盘事件发给前台窗口,所以先把目标窗口弄到前台
457
+ await run(['focus', '--window', w])
458
+ if (action === 'key') return run(['key', '--combo', String(args?.combo || '')])
459
+ return run(['type', '--text', String(args?.text || '')])
460
+ }
461
+ if (action === 'move') {
462
+ if (args?.x === undefined || args?.y === undefined)
463
+ return { ok: false, code: 'BAD_ARGS', message: 'move 需要 x 和 y' }
464
+ return run(['move', '--x', String(args.x), '--y', String(args.y)])
465
+ }
466
+ if (action === 'scroll') {
467
+ const sv = ['scroll', '--instant']
468
+ if (w) sv.push('--window', w)
469
+ if (args?.x !== undefined) sv.push('--x', String(args.x))
470
+ if (args?.y !== undefined) sv.push('--y', String(args.y))
471
+ if (args?.delta !== undefined) sv.push('--delta', String(args.delta))
472
+ if (args?.count) sv.push('--times', String(args.count))
473
+ if (args?.horizontal) sv.push('--horizontal')
474
+ return run(sv)
475
+ }
476
+
477
+ // 点击:id / cell / (x,y) 三者必须给其一,绝不再静默退回 (0,0)
478
+ if (args?.id === undefined && !args?.cell && (args?.x === undefined || args?.y === undefined)) {
479
+ return {
480
+ ok: false,
481
+ code: 'BAD_ARGS',
482
+ message: '点击需要 id / cell / x+y 三者之一',
483
+ hint: '先 computer_scan 拿候选 id;或 computer_vision 定位后给 x,y。',
484
+ }
485
+ }
486
+
487
+ const argv = ['click', '--window', w]
488
+ if (args?.cell) argv.push('--cell', String(args.cell))
489
+ else if (args?.id !== undefined) argv.push('--id', String(args.id))
490
+ else argv.push('--x', String(args.x), '--y', String(args.y))
491
+ if (args?.button) argv.push('--button', String(args.button))
492
+ if (args?.count) argv.push('--count', String(args.count))
493
+ if (args?.background) argv.push('--bg')
494
+ if (args?.expectedFingerprint) argv.push('--expectFingerprint', String(args.expectedFingerprint))
495
+ const clicked = await run(argv)
496
+ // verify=true:点完立刻差分核验,省掉一次单独的工具调用
497
+ if (args?.verify && clicked && clicked.ok !== false) {
498
+ const v = await run(['verify', '--window', w])
499
+ return Object.assign({}, clicked, { verify: v })
500
+ }
501
+ return clicked
502
+ },
503
+ }
504
+
505
+ // 坐标定位:切格子(递归下钻)/ 轴刻度网格 / 放大某格,三种模式合成一个工具
506
+ const visionTool = {
507
+ name: 'computer_vision',
508
+ description:
509
+ '**纯视觉坐标定位**,不依赖任何识别通道。三种模式:\n' +
510
+ 'mode="cells"(默认):把屏幕/窗口切成 N 格,每格印着屏幕绝对坐标,' +
511
+ '你用 read_image 看图、报格子编号;再传 path 就只切那一格,逐步收窄直到够准。\n' +
512
+ 'mode="grid":给某个窗口盖一层轴刻度网格(列号/行号),返回标注图。\n' +
513
+ 'mode="zoom":把 grid 的某个格子放大并叠二级网格,返回每个子格子的精确屏幕坐标。\n' +
514
+ '\n【什么时候必须用它 —— 不要硬用 computer_scan 反复试】\n' +
515
+ '· scan 一个候选都没给出,或候选里根本没有你要点的东西\n' +
516
+ '· 扫描显示 uia=不可用 且候选很少(<8)——识别通道基本失灵\n' +
517
+ '· 目标是**没有文字的东西**:图标、图形按钮、开关、滑块、画布、游戏、远程桌面\n' +
518
+ '· 需要很精确的落点(误差 <30 像素),而这些对象没有可用的元素条目\n' +
519
+ '**判断法则:scan 返回的候选和你要找的东西对不上,别重扫第二次,直接换视觉定位。**\n' +
520
+ '\n【效率技巧】\n' +
521
+ '· cells=9(3×3)时每级格子形状完全不变,最稳、推荐;cells=10 也能用但长宽比逐级翻转\n' +
522
+ '· 已知大概位置就用 rect:"x,y,w,h" 直接框那块,比逐级下钻省两次往返\n' +
523
+ '· 拿不准就 mark:"x,y" 画红色十字准星,一眼看出目标落在哪格、偏多少\n' +
524
+ '· 做到够准后 computer_act {x,y} 点击,坐标就是格子给的 cx,cy',
525
+ parameters: {
526
+ type: 'object',
527
+ properties: {
528
+ mode: {
529
+ type: 'string',
530
+ enum: ['cells', 'grid', 'zoom'],
531
+ description: 'cells=切格子(默认);grid=窗口轴刻度网格;zoom=放大某格(需 c/r)',
532
+ },
533
+ path: { type: 'string', description: '下钻路径,如 "7" 选第 7 格,"7.4" 再选其中第 4 格;留空从全屏开始' },
534
+ rect: { type: 'string', description: '直接指定区域 "x,y,w,h"(屏幕物理坐标),跳过逐级下钻' },
535
+ mark: { type: 'string', description: '在 "x,y" 处画红色十字准星,用于确认目标位置' },
536
+ cells: { type: 'number', description: '每级切几格,默认 10(推荐 9)' },
537
+ cols: { type: 'number', description: '强制列数(与 rows 一起用)' },
538
+ rows: { type: 'number', description: '强制行数(与 cols 一起用)' },
539
+ c: { type: 'number', description: 'zoom 模式的列号' },
540
+ r: { type: 'number', description: 'zoom 模式的行号' },
541
+ sub: { type: 'number', description: 'zoom 的二级网格密度,默认 8' },
542
+ window: { type: 'string', description: '只在某个窗口范围内操作;留空用整个桌面(grid/zoom 必填)' },
543
+ scale: { type: 'number', description: '放大倍数,默认按区域大小自动(长边约 1500px)' },
544
+ },
545
+ additionalProperties: false,
546
+ },
547
+ output: {
548
+ schema: {
549
+ type: 'object',
550
+ properties: {
551
+ ok: { type: 'boolean' },
552
+ path: { type: 'string' },
553
+ level: { type: 'number' },
554
+ image: { type: 'string' },
555
+ cols: { type: 'number' },
556
+ rows: { type: 'number' },
557
+ cells: { type: 'array', items: { type: 'object' } },
558
+ hint: { type: 'string' },
559
+ code: { type: 'string' },
560
+ message: { type: 'string' },
561
+ },
562
+ additionalProperties: true,
563
+ },
564
+ render: (_args, value) => {
565
+ if (!value || value.ok !== true) {
566
+ const why = [value?.code || 'FAILED', value?.message || ''].join(' ').trim()
567
+ return text(`视觉定位失败 [${why}]${value?.hint ? `\n建议:${value.hint}` : ''}`)
568
+ }
569
+ if (value.cells && value.sub) {
570
+ return text(`二级网格 ${value.sub}x${value.sub}(放大 ${value.scale}x)\n标注图:${value.image}\n读二级刻度后 computer_act {cell:"列,行"},或取 cells 里的 cx,cy。`)
571
+ }
572
+ if (value.screenCellW) {
573
+ return text(
574
+ `轴刻度网格 ${value.cols}列 x ${value.rows}行,单格约 ${value.screenCellW}x${value.screenCellH} 屏幕像素\n` +
575
+ `标注图:${value.image}\n报 (列,行) 后 computer_act {cell:"列,行"};需要更准先 mode:"zoom"。`,
576
+ )
577
+ }
578
+ const lines = [
579
+ `第 ${value.level} 级:${value.cols}列 × ${value.rows}行,共 ${value.cells?.length || 0} 格,单格约 ${value.cellW}x${value.cellH} 屏幕像素`,
580
+ `标注图:${value.image}`,
581
+ `用 read_image 打开,每格左上角写着 #编号 和 x,y WxH @中心点(都是屏幕绝对坐标)。`,
582
+ ]
583
+ if (!value.path) lines.push('选好格子后:继续放大 → path:"编号";够准了 → computer_act {x: cx, y: cy}。')
584
+ else lines.push(`当前路径 ${value.path};更准就传 path:"${value.path}.<编号>",够准了就 computer_act {x,y}。`)
585
+ return text(lines.join('\n'))
586
+ },
587
+ },
588
+ async execute(args) {
589
+ const stamp = Date.now().toString(36)
590
+ const out = `${workDir.replace(/\\/g, '/')}/vision-${stamp}.png`
591
+ const w = String(args?.window || '').trim()
592
+ const mode = String(args?.mode || 'cells')
593
+
594
+ if (mode === 'grid' || mode === 'zoom') {
595
+ if (!w) return { ok: false, code: 'BAD_ARGS', message: 'grid/zoom 需要 window 参数' }
596
+ if (mode === 'zoom') {
597
+ if (args?.c === undefined || args?.r === undefined)
598
+ return { ok: false, code: 'BAD_ARGS', message: 'zoom 需要 c 和 r(列号、行号)' }
599
+ const zv = ['zoom', '--c', String(args.c), '--r', String(args.r), '--out', out]
600
+ if (args?.sub) zv.push('--sub', String(args.sub))
601
+ return run(zv)
602
+ }
603
+ const gv = ['grid', '--window', w, '--out', out]
604
+ if (args?.cols) gv.push('--cols', String(args.cols))
605
+ if (args?.rows) gv.push('--rows', String(args.rows))
606
+ return run(gv)
607
+ }
608
+
609
+ const argv = ['vision', '--out', out]
610
+ if (w) argv.push('--window', w)
611
+ else argv.push('--desktop')
612
+ if (args?.path) argv.push('--path', String(args.path))
613
+ if (args?.rect) argv.push('--rect', String(args.rect))
614
+ if (args?.mark) argv.push('--mark', String(args.mark))
615
+ if (args?.cells) argv.push('--cells', String(args.cells))
616
+ if (args?.cols && args?.rows) argv.push('--cols', String(args.cols), '--rows', String(args.rows))
617
+ if (args?.scale) argv.push('--scale', String(args.scale))
618
+ return run(argv)
619
+ },
620
+ }
621
+
622
+ // ---------------------------------------------------------------- tool 4
623
+ const runTool = {
624
+ name: 'computer_run',
625
+ description:
626
+ '一次调用执行多步电脑控制(脚本自动化),省掉 N 次往返。每行一个动作,格式「动词 键=值 …」:\n' +
627
+ ' window <标题> 设定目标窗口(后续坐标可写 w 前缀表示相对客户区)\n' +
628
+ ' focus 把目标窗口置前\n' +
629
+ ' click x=100 y=200 / click find=text:确定 / click id=6 count=2\n' +
630
+ ' type text=你好\n' +
631
+ ' key combo=ctrl+a\n' +
632
+ ' scroll x=500 y=400 delta=-3 times=2\n' +
633
+ ' drag from=w100,w100 to=w300,w400 duration=600\n' +
634
+ ' wait for=changed|exists|text:xxx timeout=5000\n' +
635
+ ' verify expect=changed\n' +
636
+ ' sleep ms=500\n' +
637
+ '遇到失败默认停下并返回每一步的 trace;加 continue=true 则失败也继续。\n' +
638
+ '适合「打开→输入→回车→截图核验」这类固定套路,比逐步调用快得多也更稳。\n' +
639
+ '【微信发消息】推荐走搜索框,比在会话列表里翻找可靠得多:\n' +
640
+ ' window 微信 / focus / key combo=ctrl+f / type text=张三 / key combo=enter\n' +
641
+ ' sleep ms=400 / type text=在吗 / key combo=enter / wait for=changed timeout=3000\n' +
642
+ '【微信已知坑】find=text 要用短关键词(OCR 会认错字);登录窗和主窗是两个不同 hwnd,' +
643
+ '脚本里用 window 标题而不是 hwnd;窗口在托盘时先 focus(会自动还原),不要重开应用。',
644
+ parameters: {
645
+ type: 'object',
646
+ properties: {
647
+ script: { type: 'string', description: '多行脚本,每行一个动作' },
648
+ steps: { type: 'array', items: { type: 'string' }, description: '也可以直接传动作字符串数组,会自动拼成脚本' },
649
+ continueOnError: { type: 'boolean', description: '某步失败后是否继续,默认 false' },
650
+ },
651
+ additionalProperties: false,
652
+ },
653
+ output: {
654
+ schema: {
655
+ type: 'object',
656
+ properties: {
657
+ ok: { type: 'boolean' },
658
+ steps: { type: 'number' },
659
+ okSteps: { type: 'number' },
660
+ failed: { type: 'number' },
661
+ ms: { type: 'number' },
662
+ trace: { type: 'array', items: { type: 'object' } },
663
+ error: { type: 'string' },
664
+ },
665
+ additionalProperties: true,
666
+ },
667
+ render: (_args, value) => {
668
+ if (!value) return text('脚本无返回')
669
+ if (value.error) return text(`脚本执行失败:${value.error}`)
670
+ const lines = [`脚本执行完成:${value.okSteps}/${value.steps} 步成功` + (value.failed ? `,${value.failed} 步失败` : '') + `(${value.ms}ms)`]
671
+ for (const t of value.trace || []) {
672
+ const mark = t.ok ? '✓' : '✗'
673
+ lines.push(` ${mark} ${t.verb}${t.detail ? ` — ${String(t.detail).slice(0, 60)}` : ''}`)
674
+ }
675
+ return text(lines.join('\n'))
676
+ },
677
+ },
678
+ async execute(args) {
679
+ let script = String(args?.script || '').trim()
680
+ if (!script && Array.isArray(args?.steps)) script = args.steps.map((s) => String(s)).join('\n')
681
+ if (!script) return { ok: false, error: '缺少 script 或 steps' }
682
+ const argv = ['run', '--text', script]
683
+ if (args?.continueOnError) argv.push('--continue')
684
+ return run(argv)
685
+ },
686
+ }
687
+
688
+ const windowTool = {
689
+ name: 'computer_window',
690
+ description:
691
+ '窗口管理:最大化 / 最小化 / 还原 / 关闭 / 移动缩放 / 置顶。\n' +
692
+ '自动化前先把窗口摆到确定的位置和大小很重要——否则坐标和截图会随用户拖动而变。\n' +
693
+ 'action=move 时给 x/y/w/h(屏幕像素,不传则沿用当前值)。\n' +
694
+ '**故意不提供 close**:关闭应用是不可逆操作,误选一次就可能丢掉用户未保存的工作,需要关程序请让用户自己来。',
695
+ parameters: {
696
+ type: 'object',
697
+ properties: {
698
+ window: { type: 'string', description: '窗口标题片段或 hwnd' },
699
+ action: {
700
+ type: 'string',
701
+ enum: ['focus', 'maximize', 'minimize', 'restore', 'move', 'topmost', 'untopmost', 'wait', 'info'],
702
+ description: '要执行的操作,默认 info(只读)',
703
+ },
704
+ x: { type: 'number', description: 'move 时的屏幕 X' },
705
+ y: { type: 'number', description: 'move 时的屏幕 Y' },
706
+ w: { type: 'number', description: 'move 时的宽' },
707
+ h: { type: 'number', description: 'move 时的高' },
708
+ condition: { type: 'string', enum: ['exists', 'visible', 'foreground'], description: 'wait 时等什么条件,默认 exists' },
709
+ timeout: { type: 'number', description: 'wait 时最长等待毫秒,默认 8000' },
710
+ },
711
+ required: ['window'],
712
+ additionalProperties: false,
713
+ },
714
+ output: {
715
+ schema: {
716
+ type: 'object',
717
+ properties: {
718
+ ok: { type: 'boolean' },
719
+ action: { type: 'string' },
720
+ window: { type: 'object' },
721
+ warning: { type: 'string' },
722
+ code: { type: 'string' },
723
+ },
724
+ additionalProperties: true,
725
+ },
726
+ render: (_args, value) => {
727
+ if (!value || value.ok !== true) {
728
+ const lines = [`窗口操作失败 [${value?.code || 'FAILED'}] ${value?.message || ''}`]
729
+ if (value?.hint) lines.push(`建议:${value.hint}`)
730
+ return text(lines.join('\n'))
731
+ }
732
+ if (value.warning === 'window_gone') return text(`已${value.action || '执行'};窗口随后关闭`)
733
+ const f = value.window?.frame || {}
734
+ return text(`${value.action || 'done'} 完成:${value.window?.title} 现在 ${f.w}x${f.h} @ ${f.x},${f.y}`)
735
+ },
736
+ },
737
+ async execute(args) {
738
+ const w = String(args?.window || '').trim()
739
+ if (!w) return { ok: false, code: 'BAD_ARGS', message: '缺少 window 参数' }
740
+ const act = String(args?.action || 'info')
741
+ // 双保险:即使模型硬传 close 也拒绝——关闭应用不可逆,不该由 AI 触发
742
+ if (act === 'close') {
743
+ return {
744
+ ok: false,
745
+ code: 'CLOSE_NOT_ALLOWED',
746
+ message: '已禁用 close 动作',
747
+ hint: '需要关闭程序请让用户自己操作;你只负责操作界面,不负责结束进程。',
748
+ }
749
+ }
750
+ // focus:从托盘唤出并置前(合并自原 computer_focus)
751
+ if (act === 'focus') return run(['focus', '--window', w])
752
+ // wait:等条件成立(合并自原 computer_wait)
753
+ if (act === 'wait') {
754
+ const wv = ['wait', '--window', w, '--for', String(args?.condition || 'exists')]
755
+ wv.push('--timeout', String(args?.timeout || 8000))
756
+ return run(wv)
757
+ }
758
+ const argv = ['window', '--window', w, '--action', act]
759
+ for (const k of ['x', 'y', 'w', 'h']) if (args?.[k] !== undefined) argv.push(`--${k}`, String(args[k]))
760
+ return run(argv)
761
+ },
762
+ }
763
+
764
+ const lookTool = {
765
+ name: 'computer_look',
766
+ description:
767
+ '**看一眼**:mode="image" 截取窗口/桌面得到干净原图(用 read_image 直接看布局);' +
768
+ 'mode="text" 读某个矩形区域的文字(OCR)。\n' +
769
+ '和 computer_scan 的区别:scan 返回候选表+标注图,look 给的是**没有标注的原始画面**,' +
770
+ '适合判断"这界面上到底有什么"、确认区域长相、或读出列表内容。\n' +
771
+ 'image 模式返回的 x,y 是图片左上角在屏幕上的坐标:图内像素 (px,py) → 屏幕 (x+px, y+py)。\n' +
772
+ 'text 模式的 rect 是**相对窗口客户区**的 "x,y,w,h"。OCR 会认错字(老师→老丝),关键值别整句比对。',
773
+ parameters: {
774
+ type: 'object',
775
+ properties: {
776
+ window: { type: 'string', description: '窗口标题片段或 hwnd;image 模式留空则截整个桌面' },
777
+ mode: { type: 'string', enum: ['image', 'text'], description: 'image=截图(默认),text=读文字' },
778
+ rect: { type: 'string', description: 'text 模式下相对窗口的 "x,y,w,h",留空读整个窗口' },
779
+ expect: {
780
+ type: 'string',
781
+ description:
782
+ 'text 模式下给出期望文本 → 返回 match / similarity。比对已做归一化(全角半角、空格、易混字 0O/1Il/5S/8B),' +
783
+ '所以 OCR 认错个别字也能判对。校验"我刚填进去的内容是否正确"时用它,别自己做字符串比较。',
784
+ },
785
+ },
786
+ additionalProperties: false,
787
+ },
788
+ output: {
789
+ schema: {
790
+ type: 'object',
791
+ properties: {
792
+ ok: { type: 'boolean' },
793
+ file: { type: 'string' },
794
+ text: { type: 'string' },
795
+ w: { type: 'number' },
796
+ h: { type: 'number' },
797
+ x: { type: 'number' },
798
+ y: { type: 'number' },
799
+ },
800
+ additionalProperties: true,
801
+ },
802
+ render: (_args, value) => {
803
+ if (!value || value.ok !== true) {
804
+ return text(`查看失败 [${value?.code || 'FAILED'}] ${value?.message || value?.error || ''}`)
805
+ }
806
+ if (value.text !== undefined) {
807
+ if (!value.text) return text('该区域没有识别到文字')
808
+ const head = `读到文字(区域 ${value.rect?.w}x${value.rect?.h}):\n${value.text}`
809
+ if (value.match === undefined) return text(head)
810
+ const verdict = value.match
811
+ ? `✔ 与期望值一致(相似度 ${value.similarity})`
812
+ : `✘ 与期望值不一致(相似度 ${value.similarity})—— 期望「${value.expect}」,实际「${value.text}」`
813
+ return text(`${head}\n\n${verdict}`)
814
+ }
815
+ return text(
816
+ `截图已保存:${value.file}\n` +
817
+ `尺寸 ${value.w}x${value.h},左上角在屏幕 (${value.x},${value.y}),方式=${value.method}\n` +
818
+ `用 read_image 打开它;图内像素 (px,py) 对应屏幕坐标 (${value.x}+px, ${value.y}+py)。`,
819
+ )
820
+ },
821
+ },
822
+ async execute(args) {
823
+ const w = String(args?.window || '').trim()
824
+ const mode = String(args?.mode || 'image')
825
+ if (mode === 'text') {
826
+ if (!w) return { ok: false, code: 'BAD_ARGS', message: 'text 模式需要 window 参数' }
827
+ const argv = ['read', '--window', w, '--json']
828
+ argv.push('--rect', args?.rect ? String(args.rect) : '0,0,99999,99999')
829
+ if (args?.expect !== undefined) argv.push('--expect', String(args.expect))
830
+ return run(argv)
831
+ }
832
+ const stamp = Date.now().toString(36)
833
+ const out = `${workDir.replace(/\\/g, '/')}/shot-${stamp}.png`
834
+ const argv = ['cap', '--out', out]
835
+ if (w) argv.push('--window', w)
836
+ else argv.push('--desktop')
837
+ return run(argv)
838
+ },
839
+ }
840
+
841
+ const tools = [windowsTool, scanTool, actTool, visionTool, windowTool, lookTool, runTool]
842
+ for (const tool of tools) {
843
+ ctx.effect(() => ctx.tools.register(tool))
844
+ }
845
+
846
+ // ---- 急停闸:拉起后任何 computer_* 调用都被工具层直接拒绝 ----
847
+ ctx.effect(() =>
848
+ ctx.tools.guard((execution) => {
849
+ if (!hud.stopped) return undefined
850
+ if (!String(execution.name || '').startsWith('computer_')) return undefined
851
+ return '急停已生效(Lens HUD 的红钮),请先点击解除急停再继续操作电脑。'
852
+ }),
853
+ )
854
+
855
+ // ---- HUD 数据面:localhost 小服务(带 CORS,供 Web 面板轮询)----
856
+ const cors = {
857
+ 'Access-Control-Allow-Origin': '*',
858
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
859
+ 'Access-Control-Allow-Headers': 'content-type',
860
+ 'Cache-Control': 'no-store',
861
+ }
862
+
863
+ const sendJson = (res, body) => {
864
+ const text = JSON.stringify(body)
865
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
866
+ res.end(text)
867
+ }
868
+
869
+ const newestOverlay = () => {
870
+ try {
871
+ const files = readdirSync(workDir)
872
+ .filter((f) => f.startsWith('overlay-') && f.endsWith('.png'))
873
+ .map((f) => ({ f, t: statSync(`${workDir}/${f}`).mtimeMs }))
874
+ .sort((a, b) => b.t - a.t)
875
+ return files.length ? `${workDir}/${files[0].f}` : null
876
+ } catch {
877
+ return null
878
+ }
879
+ }
880
+
881
+ const server = createServer((req, res) => {
882
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
883
+ if (req.method === 'OPTIONS') {
884
+ res.writeHead(204, cors)
885
+ res.end()
886
+ return
887
+ }
888
+ if (url.pathname === '/status') {
889
+ sendJson(res, {
890
+ ok: true,
891
+ stopped: hud.stopped,
892
+ uptimeMs: Date.now() - hud.startedAt,
893
+ lastScan: hud.lastScan,
894
+ timeline: hud.timeline.slice(0, 12),
895
+ hudPort,
896
+ })
897
+ return
898
+ }
899
+ if (url.pathname === '/stop' || url.pathname === '/resume') {
900
+ hud.stopped = url.pathname === '/stop'
901
+ record('hud', hud.stopped ? '急停已拉起' : '急停已解除', true)
902
+ sendJson(res, { ok: true, stopped: hud.stopped })
903
+ return
904
+ }
905
+ if (url.pathname === '/thumb') {
906
+ const file = newestOverlay()
907
+ if (!file) {
908
+ res.writeHead(404, cors)
909
+ res.end('no scan yet')
910
+ return
911
+ }
912
+ try {
913
+ const buf = readFileSync(file)
914
+ res.writeHead(200, { ...cors, 'Content-Type': 'image/png' })
915
+ res.end(buf)
916
+ } catch {
917
+ res.writeHead(500, cors)
918
+ res.end('read failed')
919
+ }
920
+ return
921
+ }
922
+ res.writeHead(404, cors)
923
+ res.end('not found')
924
+ })
925
+ server.on('error', () => {
926
+ /* 端口被占用就算了,HUD 显示离线即可,不能拖垮插件 */
927
+ })
928
+
929
+ ctx.effect(() => {
930
+ try {
931
+ server.listen(hudPort, '127.0.0.1')
932
+ } catch {
933
+ /* ignore */
934
+ }
935
+ return () => {
936
+ try {
937
+ server.close()
938
+ } catch {
939
+ /* ignore */
940
+ }
941
+ }
942
+ })
943
+ }