dsh-mac-computer-use 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,679 @@
1
+ /**
2
+ * dsh-mac-computer-use —— 给 DSH 一组 Mac 桌面操作工具(视觉优先)
3
+ *
4
+ * 为什么不用 dsh-computer-use / cua-driver:那套依赖 macOS 无障碍树(AX),
5
+ * 而这台机器上第三方二进制拿不到 AXWindow(锁屏时尤其明显)。这里改走**视觉路线**:
6
+ *
7
+ * 看窗口 → CGWindowList(不需要任何权限)
8
+ * 看内容 → screencapture(DSH 已有屏幕录制权限)
9
+ * 操作 → CGEvent 投递给目标进程(不移动真实鼠标、不抢前台)
10
+ *
11
+ * 这正是 Codex 那种 computer use 的做法,也是目前这台机器上唯一确定能走通的路。
12
+ *
13
+ * 底层引擎是 dsh-cu(Swift 单文件命令行,源码在仓库 tools/,install.sh 装到 ~/.dsh/bin/)。
14
+ * 本机环境(命令行在哪、权限、用户设置)在 setup.js;DSH 设置页的后端在 panel.js、前端在 client.js。
15
+ */
16
+
17
+ import { spawn, execFile, execFileSync } from 'node:child_process'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import crypto from 'node:crypto'
22
+ import { findBin, INSTALL_CMD, INSTALL_HINT, OVERLAY, readConfig } from './setup.js'
23
+ import { PANEL_ROUTE, panelHandler } from './panel.js'
24
+
25
+ export const name = 'dsh-mac-computer-use'
26
+ export const inject = ['tools']
27
+
28
+ const TRACE = '/tmp/dsh-mac-computer-use.log'
29
+ function trace(line) {
30
+ try { fs.appendFileSync(TRACE, `${new Date().toISOString()} ${line}\n`) } catch {}
31
+ }
32
+
33
+ function cuBin() {
34
+ const bin = findBin()
35
+ if (!bin) throw new Error(INSTALL_HINT)
36
+ return bin.path
37
+ }
38
+
39
+ // ── 系统级光标浮层 ─────────────────────────────────────────────────────────
40
+ // 用户想看得见操作时,屏幕上得有个光标在动。浮层是独立的 NSPanel 进程:
41
+ // 不抢前台、点击穿透、永远置顶。这里按需拉起 —— 第一次要动桌面时才起,
42
+ // 不需要开机常驻,用户零操作。DSH 设置页里关掉「显示光标」就不起、也不让命令行去起。
43
+ // 浮层闲 60 秒会自己退出,所以每次都探一下:FIFO 能以非阻塞方式打开 = 有读者 = 浮层活着。
44
+
45
+ // 本用户私有临时目录(0700)下,别的账号占不了也读不到;dsh-cu 和浮层用同一个算法(confstr DARWIN_USER_TEMP_DIR)
46
+ const FIFO = (() => { try { return path.join(execFileSync('/usr/bin/getconf', ['DARWIN_USER_TEMP_DIR'], { encoding: 'utf8' }).trim(), 'dsh-cursor.fifo') } catch { return path.join(os.tmpdir(), 'dsh-cursor.fifo') } })()
47
+ // 浮层拉不起来(比如二进制坏了)就歇 5 分钟再试,别让每次桌面操作都白等
48
+ let cursorFailedAt = 0
49
+ // 正在拉起浮层的那一次:并发的工具调用一起等它,别起两个
50
+ let cursorStarting = null
51
+
52
+ const cursorOn = () => readConfig().showCursor !== false
53
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
54
+
55
+ function fifoOpen() {
56
+ try {
57
+ if (!fs.statSync(FIFO).isFIFO()) return -1 // 残留的普通文件别往里写
58
+ return fs.openSync(FIFO, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK)
59
+ } catch { return -1 } // 没文件 / 没读者(ENXIO)
60
+ }
61
+
62
+ /** 只认当前账号的浮层:别的账号开着浮层不算(否则每次操作都白等它的 FIFO) */
63
+ function overlayRunning() {
64
+ return new Promise((resolve) => {
65
+ if (typeof process.getuid !== 'function') return resolve(false)
66
+ execFile('/usr/bin/pgrep', ['-U', String(process.getuid()), '-f', 'dsh-cursor-overlay'], (err) => resolve(!err))
67
+ })
68
+ }
69
+
70
+ /** 异步等浮层就绪:DSH 宿主是单线程,绝不能同步睡(以前用 Atomics.wait,会把整个 DSH 卡住最多 2 秒) */
71
+ function ensureCursor() {
72
+ if (!cursorOn()) return Promise.resolve()
73
+ if (!cursorStarting) cursorStarting = startCursor().finally(() => { cursorStarting = null })
74
+ return cursorStarting
75
+ }
76
+
77
+ async function startCursor() {
78
+ const fd = fifoOpen()
79
+ if (fd >= 0) { fs.closeSync(fd); return }
80
+ // 进程在、FIFO 还没好 = 刚起(刚装好的二进制第一次启动 macOS 要验一下,会慢一点):等它,别再起第二个
81
+ const running = await overlayRunning()
82
+ if (!running) {
83
+ if (Date.now() - cursorFailedAt < 5 * 60_000) return
84
+ try {
85
+ fs.accessSync(OVERLAY, fs.constants.X_OK)
86
+ // FIFO 明着传:旧版浮层默认还是 /tmp 下那个公用路径
87
+ const child = spawn(OVERLAY, ['--fifo', FIFO], { detached: true, stdio: 'ignore' })
88
+ child.unref()
89
+ } catch (e) {
90
+ cursorFailedAt = Date.now()
91
+ trace(`光标浮层起不来: ${e && e.message}(不影响操作,只是看不见光标;5 分钟后再试)`)
92
+ return
93
+ }
94
+ }
95
+ // 等浮层把 FIFO 读端打开(最多 2 秒),否则这一次的光标指令会落空
96
+ for (let i = 0; i < 40; i++) {
97
+ await sleep(50)
98
+ const f = fifoOpen()
99
+ if (f >= 0) {
100
+ fs.closeSync(f)
101
+ cursorFailedAt = 0
102
+ if (!running) trace('已拉起光标浮层')
103
+ return
104
+ }
105
+ }
106
+ if (!(await overlayRunning())) {
107
+ cursorFailedAt = Date.now()
108
+ trace('光标浮层起不来:拉起后 2 秒内没见到它(不影响操作,只是看不见光标;5 分钟后再试)')
109
+ }
110
+ }
111
+
112
+ /** 给浮层发几行指令(move / label / click …)。浮层没在跑就算了,绝不拖慢操作。 */
113
+ function cursorSay(...cmds) {
114
+ if (!cursorOn()) return
115
+ const fd = fifoOpen()
116
+ if (fd < 0) return
117
+ try { fs.writeSync(fd, cmds.map((c) => String(c).replace(/[\r\n]+/g, ' ') + '\n').join('')) } catch {}
118
+ finally { try { fs.closeSync(fd) } catch {} }
119
+ }
120
+
121
+ /** 名牌上的短文字 */
122
+ const short = (s, n = 14) => {
123
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim()
124
+ return t.length > n ? t.slice(0, n - 1) + '…' : t
125
+ }
126
+
127
+ /** 输入的内容像不像密码:没有空格、没有中日韩文字的一串就不往屏幕上写 */
128
+ const typedLabel = (text) => {
129
+ const t = String(text ?? '')
130
+ return /\s|[\u3000-\u9fff\uac00-\ud7af]/.test(t) ? `输入「${short(t, 12)}」` : `输入 ${t.length} 个字符`
131
+ }
132
+
133
+ /**
134
+ * 跟引擎约定的协议版本(tools/dsh-cu.swift 的 ENGINE_PROTOCOL)。插件跟着 DSH 自动更新,引擎要手动重跑 install.sh,
135
+ * 两边可能对不上:旧引擎会把新参数当成输出路径、按旧单位滚动。所以第一次用引擎先核对,对不上就什么都不跑。
136
+ */
137
+ const ENGINE_PROTOCOL = '2'
138
+ let engineOk = '' // 核对通过的引擎(路径 + 修改时间 + 大小),重装后自动重新核对
139
+
140
+ function engineKey(bin) {
141
+ try { const st = fs.statSync(bin); return `${bin}:${st.mtimeMs}:${st.size}` } catch { return bin }
142
+ }
143
+
144
+ async function checkEngine(bin) {
145
+ const key = engineKey(bin)
146
+ if (key === engineOk) return
147
+ const got = await exec(bin, ['version'], 10000).catch(() => '')
148
+ if (got !== ENGINE_PROTOCOL) {
149
+ throw new Error(
150
+ `命令行引擎版本不对(插件要 ${ENGINE_PROTOCOL},引擎${got ? `是 ${got.slice(0, 20)}` : '太旧、没有 version 命令'}),这次什么都没做。` +
151
+ `请在 dsh-mac-computer-use 仓库根目录重跑 ./install.sh,再重试(没有仓库就重新装:${INSTALL_CMD})`)
152
+ }
153
+ engineOk = key
154
+ }
155
+
156
+ /** 协议号不变的引擎增强(dsh-cu features);旧引擎没这个命令就是空集合。按引擎文件缓存,重装后重新问 */
157
+ let featureCache = { key: '', set: new Set() }
158
+ async function engineFeatures() {
159
+ const bin = cuBin()
160
+ const key = engineKey(bin)
161
+ if (key !== featureCache.key) {
162
+ const out = await exec(bin, ['features'], 10000).catch(() => '')
163
+ featureCache = { key, set: new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)) }
164
+ }
165
+ return featureCache.set
166
+ }
167
+
168
+ /** 跑系统自带的小工具(screencapture / sips),不卡住 DSH */
169
+ function execFileP(cmd, args, timeoutMs = 15000) {
170
+ return new Promise((resolve, reject) => {
171
+ execFile(cmd, args, { timeout: timeoutMs }, (err, out) => (err ? reject(err) : resolve(String(out))))
172
+ })
173
+ }
174
+
175
+ /**
176
+ * 旧引擎(没重跑 install.sh,没有 shot --window)时插件自己截窗口本身:screencapture -l 窗口号,缩到「点」尺寸,
177
+ * 坐标换算跟引擎一样「1 像素 = 1 点」。截不到、或出图比例对不上(同引擎的 fits)回 null,调用方退回按屏幕区域截
178
+ */
179
+ async function shootWindow(w, file) {
180
+ try {
181
+ await execFileP('/usr/sbin/screencapture', ['-x', '-o', '-l', String(w.windowId), file])
182
+ const dims = await execFileP('/usr/bin/sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', file])
183
+ const pw = Number((/pixelWidth:\s*(\d+)/.exec(dims) || [])[1])
184
+ const ph = Number((/pixelHeight:\s*(\d+)/.exec(dims) || [])[1])
185
+ const sx = pw / w.width, sy = ph / w.height
186
+ if (!(sx >= 0.5 && Math.abs(sx - sy) < 0.05)) return null
187
+ if (pw !== w.width || ph !== w.height) {
188
+ await execFileP('/usr/bin/sips', ['-z', String(w.height), String(w.width), file, '--out', file])
189
+ }
190
+ return { x: w.x, y: w.y, width: w.width, height: w.height }
191
+ } catch {
192
+ return null
193
+ }
194
+ }
195
+
196
+ async function run(args, timeoutMs = 30000) {
197
+ const bin = cuBin()
198
+ await checkEngine(bin)
199
+ return exec(bin, args, timeoutMs)
200
+ }
201
+
202
+ function exec(bin, args, timeoutMs) {
203
+ return new Promise((resolve, reject) => {
204
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
205
+ let out = '', err = ''
206
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} ; reject(new Error(`dsh-cu 超时(${timeoutMs}ms)`)) }, timeoutMs)
207
+ child.stdout.on('data', (d) => { out += d })
208
+ child.stderr.on('data', (d) => { err += d })
209
+ child.on('error', (e) => { clearTimeout(timer); reject(new Error(`起不来 dsh-cu: ${e.message}`)) })
210
+ child.on('close', (code) => {
211
+ clearTimeout(timer)
212
+ if (code === 0) return resolve(out.trim())
213
+ const e = new Error(err.trim() || out.trim() || `dsh-cu 退出码 ${code}`)
214
+ e.exitCode = code // 调用方按退出码区分(find 的 2 = 没找到)
215
+ reject(e)
216
+ })
217
+ })
218
+ }
219
+
220
+ // ── 工具外壳(不 import 任何 @deepseek-ai/*,形状按 dsh-tools 的产出手搓)──────
221
+
222
+ function specToJsonSchema(spec) {
223
+ const properties = {}
224
+ const required = []
225
+ for (const [key, node] of Object.entries(spec)) {
226
+ const { required: isRequired, ...rest } = node
227
+ properties[key] = rest
228
+ if (isRequired) required.push(key)
229
+ }
230
+ return { type: 'object', properties, ...(required.length ? { required } : {}) }
231
+ }
232
+
233
+ function defineTool(options) {
234
+ return {
235
+ name: options.name,
236
+ description: options.description,
237
+ parameters: specToJsonSchema(options.parameters),
238
+ output: { schema: options.output.schema, render: options.output.render },
239
+ ...(options.presentCall ? { presentCall: options.presentCall } : {}),
240
+ execute: options.execute,
241
+ }
242
+ }
243
+
244
+ const textOutput = {
245
+ schema: {
246
+ type: 'object', additionalProperties: false,
247
+ properties: { text: { type: 'string' } }, required: ['text'],
248
+ },
249
+ render: (_args, value) => [{ type: 'text', text: value.text }],
250
+ }
251
+
252
+ const present = (title) => (args) => ({ card: 'generic', title, kind: 'other', rawInput: args })
253
+
254
+ /** render 是同步的,execute 里把附件引用存在这里,render 用 token 取回 */
255
+ const shotRefs = new Map()
256
+
257
+ /** OCR 找文字 → 屏幕坐标(dsh-cu find 的输出是 "序号\tx,y\t置信度\t文字") */
258
+ async function findText(text, pid) {
259
+ const a = ['find', text]
260
+ if (pid) a.push('--pid', String(pid))
261
+ let out
262
+ try {
263
+ out = await run(a, 60000)
264
+ } catch (e) {
265
+ // 只有退出码 2 才是「没找到」;没装、超时、没权限、截图失败都原样报,别都说成没找到
266
+ if (e.exitCode === 2) {
267
+ throw new Error(`没在屏幕上找到「${text}」${pid ? `(窗口 pid=${pid})` : ''}。先 desktop_screenshot 看一眼现在是什么界面`)
268
+ }
269
+ throw new Error(`OCR 找字没做成:${e.message}`)
270
+ }
271
+ const line = out.split('\n')[0]
272
+ const [idx, xy, conf, ...rest] = line.split('\t')
273
+ const [x, y] = (xy || '').split(',').map(Number)
274
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`OCR 结果解析不了: ${line}`)
275
+ return { x, y, conf: conf || '?', text: rest.join('\t') || text }
276
+ }
277
+
278
+ // 「x,y w×h」:副屏在主屏左边 / 上边时 x、y 是负数
279
+ const RECT_RE = /(-?\d+),(-?\d+) (\d+)×(\d+)/
280
+
281
+ /** dsh-cu shot 输出的第二行「rect\tx,y w×h」:截图覆盖的屏幕范围(点) */
282
+ function parseRect(raw) {
283
+ const m = RECT_RE.exec(raw.split('\n').find((l) => l.startsWith('rect\t')) || '')
284
+ return m ? { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) } : null
285
+ }
286
+
287
+ /** 告诉模型图上坐标怎么换成点击坐标。图按「点」出,k = 被 maxWidth 缩小的倍数 */
288
+ function mapping(rect, k = 1) {
289
+ if (k === 1 && rect.x === 0 && rect.y === 0) return '图上坐标就是屏幕坐标,可以直接给 desktop_click。'
290
+ const f = (o, v) => (k === 1 ? `${o} + ${v}` : `${o} + ${v}×${Number(k.toFixed(3))}`)
291
+ return `图上 (u, v) 对应屏幕坐标 (${f(rect.x, 'u')}, ${f(rect.y, 'v')}),换算好再给 desktop_click。`
292
+ }
293
+
294
+ /** 截图临时文件放这里:用户自己的临时目录下新建的 0700 子目录(别的账号读不到),用完整个删掉 */
295
+ const privateDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cu-'))
296
+ const removeDir = (dir) => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} }
297
+
298
+ /** 正整数参数(插件手搓的 defineTool 不做入参校验,这里自己把关);没给回 undefined */
299
+ function positiveInt(value, name) {
300
+ if (value === undefined || value === null) return undefined
301
+ const n = Number(value)
302
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} 要是正整数,收到的是 ${JSON.stringify(value)}`)
303
+ return n
304
+ }
305
+
306
+ function parseWindows(raw) {
307
+ return raw.split('\n').filter(Boolean).map((line) => {
308
+ const [pid, wid, app, title, bounds] = line.split('\t')
309
+ const m = RECT_RE.exec(bounds || '')
310
+ return {
311
+ pid: Number(pid), windowId: Number(wid), app, title,
312
+ x: m ? Number(m[1]) : 0, y: m ? Number(m[2]) : 0,
313
+ width: m ? Number(m[3]) : 0, height: m ? Number(m[4]) : 0,
314
+ }
315
+ })
316
+ }
317
+
318
+ export function apply(ctx, config) {
319
+ trace('apply 被调用')
320
+
321
+ const tools = [
322
+ defineTool({
323
+ name: 'desktop_windows',
324
+ description:
325
+ '列出当前屏幕上所有可见窗口,含 pid、窗口 id、应用名、标题和**屏幕坐标**(左上角 x,y 和宽高)。' +
326
+ '桌面操作前先用它拿到目标和坐标。不依赖无障碍权限,始终可用。',
327
+ parameters: {
328
+ filter: { type: 'string', description: '可选:按应用名或标题过滤(不区分大小写)' },
329
+ },
330
+ output: textOutput,
331
+ presentCall: present('Desktop windows'),
332
+ async execute(args) {
333
+ const wins = parseWindows(await run(['winlist']))
334
+ const f = (args.filter || '').toLowerCase()
335
+ const list = f ? wins.filter((w) => w.app.toLowerCase().includes(f) || w.title.toLowerCase().includes(f)) : wins
336
+ if (!list.length) return { text: '(没有匹配的窗口)' }
337
+ return {
338
+ text: list.map((w) =>
339
+ `pid=${w.pid} win=${w.windowId} ${w.app}${w.title ? ` «${w.title.slice(0, 40)}»` : ''} @(${w.x},${w.y}) ${w.width}×${w.height}`
340
+ ).join('\n'),
341
+ }
342
+ },
343
+ }),
344
+
345
+ defineTool({
346
+ name: 'desktop_screenshot',
347
+ description:
348
+ '给屏幕或某个窗口截图,图片直接返回给你看。给了 pid 就只截那个应用最前面的窗口(在哪块屏都行,被别的窗口挡住也截得到它本身),' +
349
+ '否则截主显示器。图按「点」出(1 像素 = 1 点):全屏图上的坐标就是屏幕坐标;窗口图的换算方式写在返回文字里。' +
350
+ '**先截图、看清按钮位置,再用 desktop_click 按坐标点** —— 这是推荐的工作方式。',
351
+ parameters: {
352
+ pid: { type: 'integer', description: '可选:只截这个进程的窗口' },
353
+ maxWidth: { type: 'integer', description: '可选:缩小到多少像素宽(默认不缩;缩了以后坐标要按返回文字里的倍数换算)' },
354
+ },
355
+ output: {
356
+ schema: {
357
+ type: 'object', additionalProperties: false,
358
+ properties: { text: { type: 'string' }, token: { type: 'string' } },
359
+ required: ['text', 'token'],
360
+ },
361
+ render: (_args, value) => {
362
+ const blocks = [{ type: 'text', text: value.text }]
363
+ const ref = shotRefs.get(value.token)
364
+ if (ref) { shotRefs.delete(value.token); blocks.push({ type: 'image', attachment: ref }) }
365
+ return blocks
366
+ },
367
+ },
368
+ presentCall: present('Desktop screenshot'),
369
+ async execute(args) {
370
+ const maxWidth = positiveInt(args.maxWidth, 'maxWidth')
371
+ const dir = privateDir()
372
+ const file = path.join(dir, 'shot.png')
373
+ let keep = false
374
+ try {
375
+ const a = ['shot']
376
+ let target = '主显示器'
377
+ let rect = null
378
+ if (args.pid) {
379
+ const wins = parseWindows(await run(['winlist'])).filter((w) => w.pid === Number(args.pid))
380
+ if (!wins.length) throw new Error(`pid ${args.pid} 没有可见窗口(应用没开窗口、窗口最小化了,或在别的桌面空间)`)
381
+ const w = wins[0]
382
+ target = `${w.app} «${w.title}»`
383
+ if ((await engineFeatures()).has('window-shot')) {
384
+ // 引擎按窗口号截窗口本身(被别的窗口挡住也行),截不到自己退回按屏幕区域截
385
+ a.push('--window', String(w.windowId))
386
+ } else {
387
+ // 旧引擎:插件自己按窗口号截;不行再让引擎按窗口所在那块屏的原点和缩放比裁(Retina / 副屏 / 负坐标都对)
388
+ rect = await shootWindow(w, file)
389
+ if (!rect) a.push('--rect', `${w.x},${w.y},${w.width},${w.height}`)
390
+ }
391
+ }
392
+ if (!rect) {
393
+ a.push('--out', file) // 放最后:万一碰上旧引擎(拿最后一个参数当输出路径)也只会写进私有目录
394
+ rect = parseRect(await run(a, 60000))
395
+ if (!rect) throw new Error('dsh-cu shot 没回截图范围:命令行引擎太旧,重新跑一遍 install.sh')
396
+ }
397
+ let buf = fs.readFileSync(file)
398
+ let k = 1
399
+ if (maxWidth && maxWidth < rect.width && buf.length > 200_000) {
400
+ execFileSync('/usr/bin/sips', ['--resampleWidth', String(maxWidth), file, '--out', file], { stdio: 'ignore' })
401
+ buf = fs.readFileSync(file)
402
+ k = rect.width / maxWidth
403
+ }
404
+ const note = `截图(${target} @(${rect.x},${rect.y}) ${rect.width}×${rect.height},${Math.round(buf.length / 1024)}KB)\n${mapping(rect, k)}`
405
+ const attachments = ctx.get('attachments')
406
+ if (attachments) {
407
+ try {
408
+ const [ref] = await attachments.saveImages([{ data: buf, mediaType: 'image/png', name: `desktop-${Date.now()}.png` }])
409
+ const token = crypto.randomUUID()
410
+ shotRefs.set(token, ref)
411
+ if (shotRefs.size > 20) shotRefs.delete(shotRefs.keys().next().value)
412
+ return { text: note, token }
413
+ } catch { /* 落文件兜底 */ }
414
+ }
415
+ keep = true
416
+ return { text: `${note}\n(附件库不可用,已存到 ${file})`, token: '' }
417
+ } finally {
418
+ if (!keep) removeDir(dir)
419
+ }
420
+ },
421
+ }),
422
+
423
+ defineTool({
424
+ name: 'desktop_click',
425
+ description:
426
+ '点击。**优先用 text**(OCR 找屏幕上的文字再点,最稳、不怕界面挪位置);' +
427
+ '找不到文字再用 x/y 坐标(来自 desktop_screenshot 或 desktop_windows)。' +
428
+ '默认**投递给目标进程**(不移动你的真实鼠标、不抢前台);某些应用不响应时加 global=true 走系统事件。',
429
+ parameters: {
430
+ pid: { type: 'integer', required: true, description: '目标进程 pid(来自 desktop_windows)' },
431
+ index: { type: 'integer', description: 'desktop_observe 给的编号 —— **最推荐**,比坐标稳' },
432
+ text: { type: 'string', description: '要点的文字(OCR 找它,例如「登录」「Continue」)' },
433
+ x: { type: 'integer', description: '屏幕坐标 X(左上原点),没给 text 时必填' },
434
+ y: { type: 'integer', description: '屏幕坐标 Y(左上原点),没给 text 时必填' },
435
+ global: { type: 'boolean', description: 'true = 走系统事件并移动真实鼠标(默认 false)' },
436
+ },
437
+ output: textOutput,
438
+ presentCall: present('Desktop click'),
439
+ async execute(args) {
440
+ if (args.index) {
441
+ await ensureCursor()
442
+ const a = ['click-index', String(args.pid), String(args.index)]
443
+ if (!cursorOn()) a.push('--no-cursor')
444
+ return { text: await run(a) }
445
+ }
446
+ let x = args.x, y = args.y, how = '坐标'
447
+ if (args.text) {
448
+ const hit = await findText(args.text, args.pid)
449
+ x = hit.x; y = hit.y; how = `文字「${hit.text}」`
450
+ }
451
+ if (x === undefined || y === undefined) throw new Error('要么给 text,要么给 x 和 y')
452
+ await ensureCursor()
453
+ cursorSay(args.text ? `label 点击 ${short(args.text)}` : 'label 点击')
454
+ const a = ['clickat', String(args.pid), String(Math.round(x)), String(Math.round(y))]
455
+ if (args.global) a.push('--global')
456
+ if (!cursorOn()) a.push('--no-cursor')
457
+ // 用引擎的真实结果:进程退出了、没辅助功能权限,引擎会报错,不能一律说点上了
458
+ const out = await run(a)
459
+ return { text: args.text ? `${out}(${how})` : out }
460
+ },
461
+ }),
462
+
463
+ defineTool({
464
+ name: 'desktop_observe',
465
+ description:
466
+ '**桌面操作的首选第一步**:截取目标窗口,并把所有可点元素(按钮/输入框/链接/勾选框…)在图上用蓝色编号标出来,' +
467
+ '同时返回编号清单(编号 / 类型 / 文字 / 坐标)。' +
468
+ '看完直接 `desktop_click(pid, index=N)` 点第 N 个 —— 比猜坐标可靠得多。' +
469
+ '(原理:走 macOS 无障碍树枚举元素;锁屏时拿不到,那种情况请改用 desktop_screenshot + desktop_find_text。)',
470
+ parameters: {
471
+ pid: { type: 'integer', required: true, description: '目标进程 pid(来自 desktop_windows)' },
472
+ limit: { type: 'integer', description: '最多标多少个元素,默认 60' },
473
+ },
474
+ output: {
475
+ schema: {
476
+ type: 'object', additionalProperties: false,
477
+ properties: { text: { type: 'string' }, token: { type: 'string' } },
478
+ required: ['text', 'token'],
479
+ },
480
+ render: (_args, value) => {
481
+ const blocks = [{ type: 'text', text: value.text }]
482
+ const ref = shotRefs.get(value.token)
483
+ if (ref) { shotRefs.delete(value.token); blocks.push({ type: 'image', attachment: ref }) }
484
+ return blocks
485
+ },
486
+ },
487
+ presentCall: present('Desktop observe'),
488
+ async execute(args) {
489
+ const limit = positiveInt(args.limit, 'limit')
490
+ const dir = privateDir()
491
+ const out = path.join(dir, `observe-${args.pid}.png`)
492
+ let keep = false
493
+ try {
494
+ const a = ['observe', String(args.pid)]
495
+ if (limit) a.push('--limit', String(limit))
496
+ if (!cursorOn()) a.push('--no-cursor')
497
+ a.push('--out', out) // 放最后,理由同 desktop_screenshot
498
+ await ensureCursor()
499
+ cursorSay('label 查看窗口')
500
+ let raw
501
+ try {
502
+ raw = await run(a, 60000)
503
+ } catch (e) {
504
+ throw new Error(`${e.message}\n(如果应用没有窗口,或屏幕锁着,就用 desktop_screenshot + desktop_find_text)`)
505
+ }
506
+ const lines = raw.split('\n')
507
+ const file = lines[0]
508
+ const win = (lines.find((l) => l.startsWith('window\t')) || '').split('\t')[1] || ''
509
+ const items = lines.filter((l) => /^\d+\t/.test(l))
510
+ const text = `窗口 ${win},共 ${items.length} 个可点元素:\n` +
511
+ items.map((l) => { const [i, role, label, xy] = l.split('\t'); return ` [${i}] ${role.replace('AX', '')} «${label}» @${xy}` }).join('\n')
512
+
513
+ const buf = fs.readFileSync(file)
514
+ const attachments = ctx.get('attachments')
515
+ if (attachments) {
516
+ try {
517
+ const [ref] = await attachments.saveImages([{ data: buf, mediaType: 'image/png', name: `observe-${args.pid}.png` }])
518
+ const token = crypto.randomUUID()
519
+ shotRefs.set(token, ref)
520
+ if (shotRefs.size > 20) shotRefs.delete(shotRefs.keys().next().value)
521
+ return { text, token }
522
+ } catch {}
523
+ }
524
+ keep = true
525
+ return { text: `${text}\n(附件库不可用,标注图在 ${file})`, token: '' }
526
+ } finally {
527
+ if (!keep) removeDir(dir)
528
+ }
529
+ },
530
+ }),
531
+
532
+ defineTool({
533
+ name: 'desktop_find_text',
534
+ description:
535
+ 'OCR 找屏幕上的文字,返回它的屏幕坐标(可以直接喂给 desktop_click)。' +
536
+ '界面里找不到元素、又不想靠猜坐标时用它。可以指定 pid 只在那个窗口里找。',
537
+ parameters: {
538
+ text: { type: 'string', required: true, description: '要找的文字(支持中英文)' },
539
+ pid: { type: 'integer', description: '可选:只在这个进程的窗口里找' },
540
+ },
541
+ output: textOutput,
542
+ presentCall: present('Desktop find text'),
543
+ async execute(args) {
544
+ const hit = await findText(args.text, args.pid)
545
+ return { text: `找到「${hit.text}」@ (${Math.round(hit.x)}, ${Math.round(hit.y)}) 置信度 ${hit.conf}` }
546
+ },
547
+ }),
548
+
549
+ defineTool({
550
+ name: 'desktop_type',
551
+ description: '往目标进程输入文字(支持中文)。输入前通常要先用 desktop_click 点一下输入框。',
552
+ parameters: {
553
+ pid: { type: 'integer', required: true, description: '目标进程 pid' },
554
+ text: { type: 'string', required: true, description: '要输入的文字' },
555
+ },
556
+ output: textOutput,
557
+ presentCall: present('Desktop type'),
558
+ async execute(args) {
559
+ cursorSay(`label ${typedLabel(args.text)}`)
560
+ return { text: await run(['type', String(args.pid), args.text]) }
561
+ },
562
+ }),
563
+
564
+ defineTool({
565
+ name: 'desktop_apps',
566
+ description: '列出正在运行的应用(pid + 名字 + bundleId,* = 当前前台)。想操作某个应用先用它拿 pid。',
567
+ parameters: {},
568
+ output: textOutput,
569
+ presentCall: present('Desktop apps'),
570
+ async execute() {
571
+ const out = await run(['apps'])
572
+ const list = out.split('\n').filter(Boolean)
573
+ return { text: list.length ? list.join('\n') : '(没有运行中的应用)' }
574
+ },
575
+ }),
576
+
577
+ defineTool({
578
+ name: 'desktop_launch',
579
+ description: '在**后台**启动一个应用(不会抢前台、不打断用户)。参数可以是应用名(如 "TextEdit"、"计算器")或 bundleId(如 "com.apple.TextEdit")。',
580
+ parameters: {
581
+ target: { type: 'string', required: true, description: '应用名或 bundleId' },
582
+ },
583
+ output: textOutput,
584
+ presentCall: present('Desktop launch'),
585
+ async execute(args) {
586
+ return { text: await run(['launch', args.target], 30000) }
587
+ },
588
+ }),
589
+
590
+ defineTool({
591
+ name: 'desktop_scroll',
592
+ description: '在目标窗口的 (x, y) 处滚动,单位是「行」(一般 3~10)。dy 为正向下滚、为负向上滚;dx 为正向右、为负向左(默认 0)。',
593
+ parameters: {
594
+ pid: { type: 'integer', required: true, description: '目标进程 pid' },
595
+ x: { type: 'integer', required: true, description: '滚轮所在屏幕坐标 X' },
596
+ y: { type: 'integer', required: true, description: '滚轮所在屏幕坐标 Y' },
597
+ dy: { type: 'integer', required: true, description: '纵向滚几行(正=向下,负=向上)' },
598
+ dx: { type: 'integer', description: '横向滚几行(正=向右,负=向左),默认 0' },
599
+ },
600
+ output: textOutput,
601
+ presentCall: present('Desktop scroll'),
602
+ async execute(args) {
603
+ await ensureCursor()
604
+ cursorSay(`label ${args.dy < 0 ? '向上滚动' : '向下滚动'}`)
605
+ // scroll-lines:单位是行、正=向下。旧引擎不认这个名字会直接报错,不会按像素反着滚
606
+ const a = ['scroll-lines', String(args.pid), String(args.x), String(args.y), String(args.dy)]
607
+ if (args.dx) a.push(String(args.dx))
608
+ if (!cursorOn()) a.push('--no-cursor')
609
+ return { text: await run(a) }
610
+ },
611
+ }),
612
+
613
+ defineTool({
614
+ name: 'desktop_drag',
615
+ description: '从 (x1,y1) 拖到 (x2,y2)。用于拖文件、拖滑块、选中文本。',
616
+ parameters: {
617
+ pid: { type: 'integer', required: true, description: '目标进程 pid' },
618
+ x1: { type: 'integer', required: true, description: '起点 X' },
619
+ y1: { type: 'integer', required: true, description: '起点 Y' },
620
+ x2: { type: 'integer', required: true, description: '终点 X' },
621
+ y2: { type: 'integer', required: true, description: '终点 Y' },
622
+ },
623
+ output: textOutput,
624
+ presentCall: present('Desktop drag'),
625
+ async execute(args) {
626
+ await ensureCursor()
627
+ cursorSay('label 拖拽')
628
+ const a = ['drag', String(args.pid), String(args.x1), String(args.y1), String(args.x2), String(args.y2)]
629
+ if (!cursorOn()) a.push('--no-cursor')
630
+ return { text: await run(a) }
631
+ },
632
+ }),
633
+
634
+ defineTool({
635
+ name: 'desktop_key',
636
+ description:
637
+ '给目标进程发按键。keycode 用 macOS 虚拟键码(回车 36、Tab 48、Esc 53、空格 49、' +
638
+ '方向键 123-126、删除 51)。可加修饰键:cmd / shift / alt / ctrl,例如 cmd+shift 就是 ["cmd","shift"]。' +
639
+ '目标在后台时,带 cmd 的全选 / 复制 / 粘贴这类系统快捷键多半不生效。',
640
+ parameters: {
641
+ pid: { type: 'integer', required: true, description: '目标进程 pid' },
642
+ keycode: { type: 'integer', required: true, description: 'macOS 虚拟键码,例如回车 36' },
643
+ flags: { type: 'array', items: { type: 'string' }, description: '修饰键:cmd / shift / alt / ctrl' },
644
+ },
645
+ output: textOutput,
646
+ presentCall: present('Desktop key'),
647
+ async execute(args) {
648
+ const a = ['key', String(args.pid), String(args.keycode), ...(args.flags || [])]
649
+ return { text: await run(a) }
650
+ },
651
+ }),
652
+ ]
653
+
654
+ // 不是 macOS 就不给模型这些工具(用不了);设置页照样挂,卡片会写「只支持 macOS」
655
+ if (process.platform !== 'darwin') {
656
+ trace(`不是 macOS(${process.platform}),不注册桌面工具`)
657
+ } else {
658
+ for (const tool of tools) {
659
+ try { ctx.tools.register(tool) } catch (e) { trace(`注册 ${tool.name} 失败: ${e && e.message}`); throw e }
660
+ }
661
+ trace(`已注册 ${tools.length} 个工具: ${tools.map((t) => t.name).join(', ')}`)
662
+ }
663
+
664
+ // DSH 设置页的数据路由。挂不上也只是设置页没数据,绝不能连累工具注册
665
+ try {
666
+ if (typeof ctx.inject === 'function') {
667
+ ctx.inject(['webServer'], (sub) => {
668
+ if (!sub || typeof sub.effect !== 'function' || !sub.webServer) return
669
+ sub.effect(() => sub.webServer.register({
670
+ kind: 'exact', path: PANEL_ROUTE,
671
+ handler: panelHandler({ origin: () => sub.webServer.getOrigin(), trace }),
672
+ }), 'dsh-mac-computer-use: settings panel route')
673
+ trace(`设置页路由已挂上 ${PANEL_ROUTE}`)
674
+ })
675
+ }
676
+ } catch (e) { trace(`设置页路由没挂上(忽略): ${e && e.message}`) }
677
+ }
678
+
679
+ trace('模块已加载')