mocode-ai 1.4.7 → 1.5.0
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/dist/attachments/image.js +87 -0
- package/dist/llm/index.js +27 -5
- package/dist/repl/commands/tool-group.js +32 -1
- package/dist/repl/runtime.js +6 -1
- package/dist/runtime/idle-guard.js +62 -0
- package/dist/runtime/input-injector.js +96 -14
- package/dist/runtime/screen-capture-service.js +251 -0
- package/dist/runtime/screen-capture.js +108 -8
- package/dist/runtime/screen-pipeline.js +73 -4
- package/dist/runtime/shutdown.js +13 -0
- package/dist/tools/builtins/computer.js +291 -32
- package/dist/tools/builtins/screenshot.js +38 -9
- package/package.json +1 -1
|
@@ -12,10 +12,36 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
import { jailResolve } from '../../sandbox/index.js';
|
|
14
14
|
import { captureDesktop } from '../../runtime/screen-capture.js';
|
|
15
|
-
import { decodePng, encodePng, downscale, fitToLongEdge, crop, normToPhysical, normRectToPhysical, physicalToNorm, isNormCoord, } from '../../runtime/screen-pipeline.js';
|
|
15
|
+
import { decodePng, encodePng, downscale, fitToLongEdge, crop, readPngSize, diffRatio, normToPhysical, normRectToPhysical, physicalToNorm, isNormCoord, } from '../../runtime/screen-pipeline.js';
|
|
16
16
|
import { createInputInjector } from '../../runtime/input-injector.js';
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 喂模型的截图长边上限。1280 是 Anthropic 的兼容下限,1568 对齐主流视觉模型原生分辨率 ——
|
|
19
|
+
* 4K 屏上小字/密集 UI 的定位精度提升明显,代价是每张图多约 300 token。可用
|
|
20
|
+
* MOCODE_CU_MAX_EDGE 覆盖。
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_MAX_EDGE = 1568;
|
|
23
|
+
function maxEdge() {
|
|
24
|
+
const raw = Number(process.env.MOCODE_CU_MAX_EDGE);
|
|
25
|
+
if (!Number.isFinite(raw) || raw < 256 || raw > 4096)
|
|
26
|
+
return DEFAULT_MAX_EDGE;
|
|
27
|
+
return Math.round(raw);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 帧间差异低于此比例就认为「屏幕没变」,跳过回灌。
|
|
31
|
+
* 0.005 = 0.5%,足以吃掉抗锯齿/光标抖动,又不会漏掉真实重绘(局部重绘通常 >1%)。
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_DIFF_THRESHOLD = 0.005;
|
|
34
|
+
function diffThreshold() {
|
|
35
|
+
// 空串必须显式回落:Number('') === 0,而 0 能通过下面的区间校验 —— 用户在 .env 里清空这个
|
|
36
|
+
// 变量(注释掉的常见写法)会静默把去重关掉。0 是合法的显式值(关闭去重),空不是。
|
|
37
|
+
const rawEnv = process.env.MOCODE_CU_DIFF_THRESHOLD;
|
|
38
|
+
if (rawEnv === undefined || rawEnv.trim() === '')
|
|
39
|
+
return DEFAULT_DIFF_THRESHOLD;
|
|
40
|
+
const raw = Number(rawEnv);
|
|
41
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > 1)
|
|
42
|
+
return DEFAULT_DIFF_THRESHOLD;
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
19
45
|
const WAIT_MAX_MS = 10000;
|
|
20
46
|
const SCROLL_MAX = 10;
|
|
21
47
|
const ACTIONS = [
|
|
@@ -98,7 +124,22 @@ export function validateComputerArgs(args) {
|
|
|
98
124
|
}
|
|
99
125
|
return null;
|
|
100
126
|
}
|
|
101
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* 会话级几何缓存。显示器分辨率/DPI 在一次会话里几乎不变,而每次调用都重抓一次屏只为拿
|
|
129
|
+
* 宽高是纯粹的浪费 —— 抓屏是整条链路最贵的一步(冷启动 PowerShell + 4K 位图 CopyFromScreen)。
|
|
130
|
+
* 缓存后每个输入动作只需**一次**抓屏(动作后的重截屏),比原来少一半。
|
|
131
|
+
*
|
|
132
|
+
* 失效:每次动作后的重截屏都会回传 geometry,与之不符就立即更新缓存并提示模型重试
|
|
133
|
+
* (插拔外接屏 / 改缩放比时,这一步的坐标是用旧几何算的,必须让模型知道)。
|
|
134
|
+
*/
|
|
135
|
+
let geometryCache = null;
|
|
136
|
+
/** 会话级上一帧(已 downscale),用于差分。/cu off 时清空。 */
|
|
137
|
+
let lastFrame = null;
|
|
138
|
+
export function resetComputerState() {
|
|
139
|
+
geometryCache = null;
|
|
140
|
+
lastFrame = null;
|
|
141
|
+
}
|
|
142
|
+
/** 抓一次主屏并返回几何信息。Windows 走 captureDesktop 的 geometry(零解码);其余平台只读 IHDR。 */
|
|
102
143
|
async function capturePrimary(signal) {
|
|
103
144
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
104
145
|
const outputPath = jailResolve(`.mocode/screenshots/computer-${ts}-${Math.random().toString(36).slice(2, 8)}.png`);
|
|
@@ -107,24 +148,99 @@ async function capturePrimary(signal) {
|
|
|
107
148
|
if (cap.status !== 'passed') {
|
|
108
149
|
throw new Error(`screenshot capture failed: ${cap.detail}`);
|
|
109
150
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
151
|
+
const g = cap.geometry;
|
|
152
|
+
if (g && g.shotW > 0 && g.shotH > 0 && g.physW > 0 && g.physH > 0) {
|
|
153
|
+
return {
|
|
154
|
+
path: outputPath,
|
|
155
|
+
shotW: g.shotW,
|
|
156
|
+
shotH: g.shotH,
|
|
157
|
+
physW: g.physW,
|
|
158
|
+
physH: g.physH,
|
|
159
|
+
originX: g.originX,
|
|
160
|
+
originY: g.originY,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// 非 Windows 或 geometry 缺失:只读 IHDR,不要为了两个数字解码 14MB 像素。
|
|
164
|
+
const size = readPngSize(await readFile(outputPath));
|
|
165
|
+
if (!size)
|
|
166
|
+
throw new Error('screenshot captured but its dimensions could not be read');
|
|
167
|
+
return {
|
|
168
|
+
path: outputPath,
|
|
169
|
+
shotW: size.width,
|
|
170
|
+
shotH: size.height,
|
|
171
|
+
physW: size.width,
|
|
172
|
+
physH: size.height,
|
|
173
|
+
originX: 0,
|
|
174
|
+
originY: 0,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** 取当前屏幕几何:命中缓存则不抓屏(path 为 undefined)。 */
|
|
178
|
+
async function resolveScreen(signal) {
|
|
179
|
+
if (geometryCache)
|
|
180
|
+
return { screen: geometryCache };
|
|
181
|
+
const shot = await capturePrimary(signal);
|
|
182
|
+
geometryCache = {
|
|
183
|
+
shotW: shot.shotW,
|
|
184
|
+
shotH: shot.shotH,
|
|
185
|
+
physW: shot.physW,
|
|
186
|
+
physH: shot.physH,
|
|
187
|
+
originX: shot.originX,
|
|
188
|
+
originY: shot.originY,
|
|
189
|
+
};
|
|
190
|
+
return { screen: geometryCache, path: shot.path };
|
|
112
191
|
}
|
|
113
|
-
/**
|
|
114
|
-
|
|
192
|
+
/** 用最新一次抓屏的几何校正缓存;返回 true 表示本次动作的坐标可能是用过期几何算的。 */
|
|
193
|
+
function syncGeometry(shot) {
|
|
194
|
+
const prev = geometryCache;
|
|
195
|
+
const changed = !prev ||
|
|
196
|
+
prev.physW !== shot.physW ||
|
|
197
|
+
prev.physH !== shot.physH ||
|
|
198
|
+
prev.shotW !== shot.shotW ||
|
|
199
|
+
prev.shotH !== shot.shotH ||
|
|
200
|
+
prev.originX !== shot.originX ||
|
|
201
|
+
prev.originY !== shot.originY;
|
|
202
|
+
geometryCache = { ...shot };
|
|
203
|
+
return changed && prev !== null;
|
|
204
|
+
}
|
|
205
|
+
/** 读 PNG 文件 → 缩放 → 编码 → dataUrl,顺带算出与上一帧的差异。 */
|
|
206
|
+
async function toAttachment(path, m) {
|
|
207
|
+
let t = Date.now();
|
|
115
208
|
const png = decodePng(await readFile(path));
|
|
116
|
-
|
|
209
|
+
if (m)
|
|
210
|
+
m.decodeMs += Date.now() - t;
|
|
211
|
+
t = Date.now();
|
|
212
|
+
const { img } = downscale(png, maxEdge());
|
|
213
|
+
if (m)
|
|
214
|
+
m.scaleMs += Date.now() - t;
|
|
215
|
+
const prev = lastFrame;
|
|
216
|
+
let diff;
|
|
217
|
+
if (prev && prev.width === img.width && prev.height === img.height) {
|
|
218
|
+
diff = diffRatio(prev, img);
|
|
219
|
+
}
|
|
220
|
+
t = Date.now();
|
|
117
221
|
const buf = encodePng(img);
|
|
222
|
+
if (m)
|
|
223
|
+
m.encodeMs += Date.now() - t;
|
|
118
224
|
return {
|
|
119
225
|
dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
|
|
120
226
|
bytes: buf.length,
|
|
121
227
|
dispW: img.width,
|
|
122
228
|
dispH: img.height,
|
|
229
|
+
diff,
|
|
230
|
+
img,
|
|
123
231
|
};
|
|
124
232
|
}
|
|
233
|
+
/** 回灌了一帧:把它设为新的差分基准。 */
|
|
234
|
+
function commitFrame(img) {
|
|
235
|
+
lastFrame = img;
|
|
236
|
+
}
|
|
125
237
|
async function executeAction(injector, action, args, screen) {
|
|
126
|
-
const { physW, physH } = screen;
|
|
127
|
-
|
|
238
|
+
const { physW, physH, originX, originY } = screen;
|
|
239
|
+
// norm1000 → 虚拟桌面物理像素:先按物理分辨率缩放,再叠加主屏原点(多屏)。
|
|
240
|
+
const toPhys = (pair) => {
|
|
241
|
+
const [x, y] = normToPhysical(pair[0], pair[1], physW, physH);
|
|
242
|
+
return [originX + x, originY + y];
|
|
243
|
+
};
|
|
128
244
|
switch (action) {
|
|
129
245
|
case 'mouse_move': {
|
|
130
246
|
const [x, y] = toPhys(args.coordinate);
|
|
@@ -183,17 +299,51 @@ async function executeAction(injector, action, args, screen) {
|
|
|
183
299
|
}
|
|
184
300
|
case 'cursor_position': {
|
|
185
301
|
const pos = await injector.cursorPosition();
|
|
186
|
-
const [nx, ny] = physicalToNorm(pos.x, pos.y, physW, physH);
|
|
302
|
+
const [nx, ny] = physicalToNorm(pos.x - originX, pos.y - originY, physW, physH);
|
|
187
303
|
return `cursor at physical (${pos.x}, ${pos.y}) = normalized (${nx}, ${ny})`;
|
|
188
304
|
}
|
|
189
305
|
default:
|
|
190
306
|
throw new Error(`no executor for action "${action}"`);
|
|
191
307
|
}
|
|
192
308
|
}
|
|
309
|
+
const METRIC_CAP = 50;
|
|
310
|
+
const metrics = [];
|
|
311
|
+
function recordMetric(m) {
|
|
312
|
+
metrics.push(m);
|
|
313
|
+
if (metrics.length > METRIC_CAP)
|
|
314
|
+
metrics.shift();
|
|
315
|
+
}
|
|
316
|
+
function pct(values, p) {
|
|
317
|
+
if (!values.length)
|
|
318
|
+
return 0;
|
|
319
|
+
const s = [...values].sort((a, b) => a - b);
|
|
320
|
+
const i = Math.min(s.length - 1, Math.max(0, Math.round((p / 100) * (s.length - 1))));
|
|
321
|
+
return Math.round(s[i]);
|
|
322
|
+
}
|
|
323
|
+
export function getComputerMetrics() {
|
|
324
|
+
const totals = metrics.map((m) => m.totalMs);
|
|
325
|
+
const caps = metrics.map((m) => m.captureMs);
|
|
326
|
+
const skipped = metrics.filter((m) => m.skipped).length;
|
|
327
|
+
const bytes = metrics.reduce((a, m) => a + m.bytes, 0);
|
|
328
|
+
return {
|
|
329
|
+
count: metrics.length,
|
|
330
|
+
totalP50: pct(totals, 50),
|
|
331
|
+
totalP95: pct(totals, 95),
|
|
332
|
+
captureP50: pct(caps, 50),
|
|
333
|
+
skippedRatio: metrics.length ? skipped / metrics.length : 0,
|
|
334
|
+
avgBytes: metrics.length ? Math.round(bytes / metrics.length) : 0,
|
|
335
|
+
last: metrics[metrics.length - 1],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export function resetComputerMetrics() {
|
|
339
|
+
metrics.length = 0;
|
|
340
|
+
}
|
|
193
341
|
export const computerTool = {
|
|
194
342
|
name: 'computer',
|
|
195
343
|
description: 'Control the desktop GUI: move/click the mouse, type text, press keys, scroll, and zoom into screen regions. ' +
|
|
196
|
-
'Every action
|
|
344
|
+
'Every action re-captures the screen. If the screen is unchanged since the last screenshot you saw, no image is ' +
|
|
345
|
+
'attached and the result says so — treat the previous screenshot as still current instead of assuming the action failed. ' +
|
|
346
|
+
'Inspect each attached screenshot before the next action and self-correct. ' +
|
|
197
347
|
'Coordinates use a normalized 0-1000 grid (x right, y down) over the primary screen; you never need the physical resolution. ' +
|
|
198
348
|
'Use zoom on a tight region before clicking small or dense UI elements. Destructive or sensitive targets ' +
|
|
199
349
|
'(form submit, payment, credentials, delete/send) require explicit user intent — ask first.',
|
|
@@ -242,9 +392,26 @@ export const computerTool = {
|
|
|
242
392
|
async execute(args, ctx) {
|
|
243
393
|
const invalid = validateComputerArgs(args);
|
|
244
394
|
if (invalid) {
|
|
245
|
-
return {
|
|
395
|
+
return {
|
|
396
|
+
status: 'error',
|
|
397
|
+
code: 'INVALID_ARGUMENTS',
|
|
398
|
+
retryable: false,
|
|
399
|
+
output: `Invalid computer action: ${invalid}`,
|
|
400
|
+
};
|
|
246
401
|
}
|
|
247
402
|
const action = args.action;
|
|
403
|
+
const started = Date.now();
|
|
404
|
+
const m = {
|
|
405
|
+
action,
|
|
406
|
+
captureMs: 0,
|
|
407
|
+
decodeMs: 0,
|
|
408
|
+
scaleMs: 0,
|
|
409
|
+
encodeMs: 0,
|
|
410
|
+
injectMs: 0,
|
|
411
|
+
totalMs: 0,
|
|
412
|
+
bytes: 0,
|
|
413
|
+
skipped: false,
|
|
414
|
+
};
|
|
248
415
|
let injector;
|
|
249
416
|
try {
|
|
250
417
|
injector = createInputInjector();
|
|
@@ -254,60 +421,135 @@ export const computerTool = {
|
|
|
254
421
|
return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: message };
|
|
255
422
|
}
|
|
256
423
|
injector.bindSignal(ctx?.signal);
|
|
424
|
+
// left_mouse_down 之后若中途失败/中断,鼠标会一直处于按下状态 —— 用户的桌面就废了。
|
|
425
|
+
// 这里统一在 finally 里兜底释放。
|
|
426
|
+
let pendingMouseDown = false;
|
|
257
427
|
try {
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (action === 'zoom') {
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
428
|
+
// 只有 zoom / screenshot 真正需要一张新位图;输入动作只需要**几何**,命中会话缓存就不抓屏
|
|
429
|
+
// (抓屏是整条链路最贵的一步,省掉这一步等于把每步的本地开销砍半)。
|
|
430
|
+
let screen;
|
|
431
|
+
let freshPath;
|
|
432
|
+
if (action === 'zoom' || action === 'screenshot') {
|
|
433
|
+
const t = Date.now();
|
|
434
|
+
const shot = await capturePrimary(ctx?.signal);
|
|
435
|
+
m.captureMs += Date.now() - t;
|
|
436
|
+
screen = {
|
|
437
|
+
shotW: shot.shotW,
|
|
438
|
+
shotH: shot.shotH,
|
|
439
|
+
physW: shot.physW,
|
|
440
|
+
physH: shot.physH,
|
|
441
|
+
originX: shot.originX,
|
|
442
|
+
originY: shot.originY,
|
|
443
|
+
};
|
|
444
|
+
syncGeometry(screen);
|
|
445
|
+
freshPath = shot.path;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const t = Date.now();
|
|
449
|
+
const r = await resolveScreen(ctx?.signal);
|
|
450
|
+
m.captureMs += Date.now() - t;
|
|
451
|
+
screen = r.screen;
|
|
452
|
+
freshPath = r.path;
|
|
453
|
+
}
|
|
454
|
+
// zoom:不注入输入,裁 region 放大回灌。裁剪发生在截图位图上,故用 shotW/shotH。
|
|
455
|
+
if (action === 'zoom' && freshPath) {
|
|
456
|
+
const rect = normRectToPhysical(args.region, screen.shotW, screen.shotH);
|
|
457
|
+
let t = Date.now();
|
|
458
|
+
const png = decodePng(await readFile(freshPath));
|
|
459
|
+
m.decodeMs += Date.now() - t;
|
|
460
|
+
t = Date.now();
|
|
461
|
+
const zoomed = fitToLongEdge(crop(png, rect.x, rect.y, rect.w, rect.h), maxEdge());
|
|
462
|
+
m.scaleMs += Date.now() - t;
|
|
463
|
+
t = Date.now();
|
|
266
464
|
const buf = encodePng(zoomed);
|
|
465
|
+
m.encodeMs += Date.now() - t;
|
|
466
|
+
m.bytes = buf.length;
|
|
467
|
+
// zoom 出的图不是全屏基准,不能喂给差分。
|
|
468
|
+
lastFrame = null;
|
|
267
469
|
return {
|
|
268
470
|
status: 'success',
|
|
269
471
|
code: 'OK',
|
|
270
472
|
retryable: false,
|
|
271
|
-
output: `Zoomed into region ${JSON.stringify(args.region)} →
|
|
473
|
+
output: `Zoomed into region ${JSON.stringify(args.region)} → screenshot pixels (${rect.x}, ${rect.y}, ${rect.w}×${rect.h}), ` +
|
|
272
474
|
`magnified to ${zoomed.width}×${zoomed.height}. The attached image shows ONLY this region — ` +
|
|
273
475
|
`coordinates you output still map to the FULL screen, so do not reuse this region's local coordinates for clicks.`,
|
|
274
476
|
modelAttachments: [
|
|
275
|
-
{
|
|
477
|
+
{
|
|
478
|
+
type: 'image',
|
|
479
|
+
name: 'computer-zoom.png',
|
|
480
|
+
mime: 'image/png',
|
|
481
|
+
dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
|
|
482
|
+
},
|
|
276
483
|
],
|
|
277
484
|
};
|
|
278
485
|
}
|
|
279
486
|
// screenshot:只截屏回灌(target 可指定 all)。
|
|
280
487
|
if (action === 'screenshot') {
|
|
281
|
-
let shotPath =
|
|
488
|
+
let shotPath = freshPath;
|
|
282
489
|
if (args.target === 'all') {
|
|
283
490
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
284
491
|
const allPath = jailResolve(`.mocode/screenshots/computer-${ts}-all.png`);
|
|
285
492
|
await mkdir(dirname(allPath), { recursive: true });
|
|
493
|
+
const t = Date.now();
|
|
286
494
|
const cap = await captureDesktop(allPath, 'all', ctx?.signal);
|
|
495
|
+
m.captureMs += Date.now() - t;
|
|
287
496
|
if (cap.status !== 'passed')
|
|
288
497
|
throw new Error(`screenshot capture failed: ${cap.detail}`);
|
|
289
498
|
shotPath = allPath;
|
|
290
499
|
}
|
|
291
|
-
const att = await toAttachment(shotPath);
|
|
500
|
+
const att = await toAttachment(shotPath, m);
|
|
501
|
+
commitFrame(att.img);
|
|
502
|
+
m.bytes = att.bytes;
|
|
292
503
|
return {
|
|
293
504
|
status: 'success',
|
|
294
505
|
code: 'OK',
|
|
295
506
|
retryable: false,
|
|
296
|
-
output: `Screenshot captured (
|
|
507
|
+
output: `Screenshot captured (primary screen ${screen.physW}×${screen.physH} physical, shown at ${att.dispW}×${att.dispH}). ` +
|
|
297
508
|
'Coordinates remain normalized 0-1000 over the primary screen. Visual content is attached to the next model request.',
|
|
298
|
-
modelAttachments: [
|
|
509
|
+
modelAttachments: [
|
|
510
|
+
{ type: 'image', name: 'computer-screenshot.png', mime: 'image/png', dataUrl: att.dataUrl },
|
|
511
|
+
],
|
|
299
512
|
};
|
|
300
513
|
}
|
|
301
514
|
// 输入动作:注入 → 重截屏回灌(wait 也回灌,等待后界面往往已变化)。
|
|
515
|
+
if (action === 'left_mouse_down')
|
|
516
|
+
pendingMouseDown = true;
|
|
517
|
+
let t = Date.now();
|
|
302
518
|
const summary = await executeAction(injector, action, args, screen);
|
|
519
|
+
m.injectMs += Date.now() - t;
|
|
520
|
+
if (action === 'left_mouse_up')
|
|
521
|
+
pendingMouseDown = false;
|
|
522
|
+
t = Date.now();
|
|
303
523
|
const after = await capturePrimary(ctx?.signal);
|
|
304
|
-
|
|
524
|
+
m.captureMs += Date.now() - t;
|
|
525
|
+
const geometryStale = syncGeometry(after);
|
|
526
|
+
const att = await toAttachment(after.path, m);
|
|
527
|
+
m.bytes = att.bytes;
|
|
528
|
+
m.diff = att.diff;
|
|
529
|
+
const staleNote = geometryStale
|
|
530
|
+
? ' WARNING: display geometry changed mid-action (monitor or scaling change); ' +
|
|
531
|
+
'coordinates for this action used the previous geometry — verify and repeat if the click landed wrong.'
|
|
532
|
+
: '';
|
|
533
|
+
// 差分命中:屏幕与模型上次看到的相比没变,再发一张同样的图只是白烧 token 和 prefill。
|
|
534
|
+
if (att.diff !== undefined && att.diff < diffThreshold()) {
|
|
535
|
+
m.skipped = true;
|
|
536
|
+
return {
|
|
537
|
+
status: 'success',
|
|
538
|
+
code: 'OK',
|
|
539
|
+
retryable: false,
|
|
540
|
+
output: `${summary}. No visible change on screen (frame diff ${(att.diff * 100).toFixed(2)}% < ` +
|
|
541
|
+
`${(diffThreshold() * 100).toFixed(2)}% threshold) — no new screenshot is attached; ` +
|
|
542
|
+
'the previous screenshot is still current. If you expected a change, the action likely did not ' +
|
|
543
|
+
`take effect: re-check the target coordinates or try a different approach.${staleNote}`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
commitFrame(att.img);
|
|
305
547
|
return {
|
|
306
548
|
status: 'success',
|
|
307
549
|
code: 'OK',
|
|
308
550
|
retryable: false,
|
|
309
|
-
output: `${summary}. Screen re-captured (
|
|
310
|
-
|
|
551
|
+
output: `${summary}. Screen re-captured (primary screen ${screen.physW}×${screen.physH} physical, shown at ${att.dispW}×${att.dispH}): ` +
|
|
552
|
+
`inspect the attached screenshot to verify the result before the next action.${staleNote}`,
|
|
311
553
|
modelAttachments: [{ type: 'image', name: 'computer-result.png', mime: 'image/png', dataUrl: att.dataUrl }],
|
|
312
554
|
};
|
|
313
555
|
}
|
|
@@ -316,7 +558,24 @@ export const computerTool = {
|
|
|
316
558
|
if (ctx?.signal?.aborted || /aborted/i.test(message)) {
|
|
317
559
|
return { status: 'aborted', code: 'ABORTED', retryable: false, output: message };
|
|
318
560
|
}
|
|
319
|
-
return {
|
|
561
|
+
return {
|
|
562
|
+
status: 'error',
|
|
563
|
+
code: 'EXECUTION_ERROR',
|
|
564
|
+
retryable: false,
|
|
565
|
+
output: `computer action failed: ${message}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
finally {
|
|
569
|
+
if (pendingMouseDown) {
|
|
570
|
+
try {
|
|
571
|
+
await injector.mouseUp('left');
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
// 释放失败也要让上层拿到原本的错误,不掩盖。
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
m.totalMs = Date.now() - started;
|
|
578
|
+
recordMetric(m);
|
|
320
579
|
}
|
|
321
580
|
},
|
|
322
581
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises';
|
|
2
|
-
import { dirname, extname } from 'node:path';
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, extname } from 'node:path';
|
|
3
3
|
import { loadImageAttachment, MAX_INLINE_BYTES_DEFAULT } from '../../attachments/image.js';
|
|
4
4
|
import { jailResolve } from '../../sandbox/index.js';
|
|
5
5
|
// 平台捕获逻辑已抽到 runtime/screen-capture.ts(computer 工具闭环共用),本文件只做薄封装。
|
|
6
6
|
import { captureDesktop } from '../../runtime/screen-capture.js';
|
|
7
|
+
import { decodePng, downscale, encodePng } from '../../runtime/screen-pipeline.js';
|
|
8
|
+
/** 原图超过内联上限时降级缩放的长边(对齐主流视觉模型的原生分辨率)。 */
|
|
9
|
+
const FALLBACK_MAX_EDGE = 1568;
|
|
7
10
|
export const screenshotTool = {
|
|
8
11
|
name: 'screenshot',
|
|
9
12
|
description: 'Capture the desktop and inspect it as visual model input. Use this to diagnose visible UI state, dialogs, rendering problems, or applications that cannot be understood from source files alone. The user must approve each capture because screenshots may contain sensitive information.',
|
|
@@ -68,15 +71,41 @@ export const screenshotTool = {
|
|
|
68
71
|
const loaded = await loadImageAttachment(outputPath, {
|
|
69
72
|
maxBytes: MAX_INLINE_BYTES_DEFAULT,
|
|
70
73
|
});
|
|
74
|
+
const detail = args.detail === 'low' || args.detail === 'auto' ? args.detail : 'high';
|
|
75
|
+
// 高分屏(尤其 DPI aware 后抓到物理分辨率)的 PNG 可能超过内联上限:
|
|
76
|
+
// 原图仍留在磁盘上,回灌改用缩放版本,不让工具因此失败。
|
|
71
77
|
if (!loaded.ok) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
try {
|
|
79
|
+
const png = decodePng(await readFile(outputPath));
|
|
80
|
+
const { img } = downscale(png, FALLBACK_MAX_EDGE);
|
|
81
|
+
const buf = encodePng(img);
|
|
82
|
+
return {
|
|
83
|
+
status: 'success',
|
|
84
|
+
code: 'OK',
|
|
85
|
+
retryable: false,
|
|
86
|
+
output: `Captured ${target} display to ${requestedPath} (${png.width}×${png.height}); ` +
|
|
87
|
+
`it exceeded the inline limit, so the attached copy was resized to ${img.width}×${img.height}.`,
|
|
88
|
+
modelAttachments: [
|
|
89
|
+
{
|
|
90
|
+
type: 'image',
|
|
91
|
+
name: basename(outputPath),
|
|
92
|
+
mime: 'image/png',
|
|
93
|
+
dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
|
|
94
|
+
detail,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
return {
|
|
102
|
+
status: 'error',
|
|
103
|
+
code: 'EXECUTION_ERROR',
|
|
104
|
+
retryable: false,
|
|
105
|
+
output: `Screenshot saved to ${requestedPath}, but it could not be attached: ${loaded.reason}${message ? ` (resize fallback failed: ${message})` : ''}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
78
108
|
}
|
|
79
|
-
const detail = args.detail === 'low' || args.detail === 'auto' ? args.detail : 'high';
|
|
80
109
|
const { att } = loaded;
|
|
81
110
|
return {
|
|
82
111
|
status: 'success',
|