mocode-ai 1.7.2 → 1.7.4
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/config/index.js +1 -1
- package/dist/config/profiles.js +2 -2
- package/dist/flows/cli.js +168 -0
- package/dist/flows/flow.js +483 -0
- package/dist/flows/runner.js +259 -0
- package/dist/flows/trace.js +242 -0
- package/dist/i18n/index.js +28 -0
- package/dist/index.js +7 -0
- package/dist/llm/index.js +1 -1
- package/dist/permissions/computer-review.js +40 -0
- package/dist/permissions/flow-review.js +41 -0
- package/dist/permissions/index.js +47 -28
- package/dist/repl/commands/flow.js +210 -0
- package/dist/repl/commands/registry.js +2 -0
- package/dist/repl/commands/tool-group.js +9 -1
- package/dist/repl/commands.js +10 -0
- package/dist/repl/running-input.js +75 -14
- package/dist/repl/runtime.js +4 -1
- package/dist/runtime/input-injector.js +39 -26
- package/dist/runtime/shutdown.js +3 -1
- package/dist/runtime/uia-selector.js +324 -0
- package/dist/runtime/uia-service.js +510 -0
- package/dist/runtime/wait-until.js +199 -0
- package/dist/runtime/window-list.js +164 -0
- package/dist/session/gui-actions.js +47 -0
- package/dist/tools/builtins/computer.js +647 -10
- package/dist/tools/builtins/index.js +4 -0
- package/dist/tools/builtins/run-flow.js +123 -0
- package/dist/tools/constants.js +1 -0
- package/dist/tools/router.js +1 -1
- package/dist/tools/validation.js +57 -0
- package/dist/ui/composer.js +6 -2
- package/dist/ui/layout-internal/content-write.js +74 -6
- package/dist/ui/layout-internal/core.js +32 -8
- package/dist/ui/layout-internal/screen.js +4 -0
- package/dist/ui/layout-internal/scroll.js +5 -3
- package/dist/ui/layout-internal/state.js +6 -0
- package/dist/ui/layout-internal/statusbar.js +16 -1
- package/dist/ui/render.js +19 -11
- package/docs/usage.md +344 -344
- package/package.json +92 -92
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
* 安全:risk=dangerous(权限弹窗默认高亮「拒绝」),plan 模式常驻屏蔽,
|
|
9
9
|
* /cu off 时既不进 schema 也被运行时兜底拦截。
|
|
10
10
|
*/
|
|
11
|
-
import { mkdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { mkdir, readFile, rm } 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
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
|
+
import { getUiaService, UiaElementGoneError, UiaWindowGoneError } from '../../runtime/uia-service.js';
|
|
18
|
+
import { formatWindowList, pickWindow, pickWindowForReplay, } from '../../runtime/window-list.js';
|
|
19
|
+
import { clearElementRefNames, formatSnapshot, matchSelector, rectCenter, rectToNorm, selectDisplayNodes, setElementRefNames, } from '../../runtime/uia-selector.js';
|
|
20
|
+
import { describeCondition, parseWaitCondition, pickTypeMethod, waitUntil, WAIT_UNTIL_DEFAULT_TIMEOUT_MS, WAIT_UNTIL_MAX_TIMEOUT_MS, WAIT_UNTIL_MIN_TIMEOUT_MS, } from '../../runtime/wait-until.js';
|
|
21
|
+
import { appendTrace, buildTraceEntry, isRecordedAction, isTraceSuspended, nextTraceSeq, outputIndicatesChange, traceEnabled, } from '../../flows/trace.js';
|
|
22
|
+
import { lookupElementRefName } from '../../runtime/uia-selector.js';
|
|
17
23
|
/**
|
|
18
24
|
* 喂模型的截图长边上限。1280 是 Anthropic 的兼容下限,1568 对齐主流视觉模型原生分辨率 ——
|
|
19
25
|
* 4K 屏上小字/密集 UI 的定位精度提升明显,代价是每张图多约 300 token。可用
|
|
@@ -60,8 +66,18 @@ const ACTIONS = [
|
|
|
60
66
|
'key',
|
|
61
67
|
'scroll',
|
|
62
68
|
'wait',
|
|
69
|
+
'wait_until',
|
|
63
70
|
'cursor_position',
|
|
71
|
+
'inspect',
|
|
72
|
+
'click_element',
|
|
73
|
+
'set_value',
|
|
74
|
+
'list_windows',
|
|
75
|
+
'focus_window',
|
|
64
76
|
];
|
|
77
|
+
/** inspect 默认/最大展示节点数(design-notes/computer-use-rpa.md §2.4)。 */
|
|
78
|
+
const INSPECT_DEFAULT_NODES = 120;
|
|
79
|
+
const INSPECT_MAX_NODES = 300;
|
|
80
|
+
const ELEMENT_ACTIONS = new Set(['click_element', 'set_value']);
|
|
65
81
|
const ACTIONS_NEEDING_COORDINATE = new Set([
|
|
66
82
|
'mouse_move',
|
|
67
83
|
'left_click',
|
|
@@ -106,6 +122,24 @@ export function validateComputerArgs(args) {
|
|
|
106
122
|
return `action "${action}" requires a non-empty text string`;
|
|
107
123
|
}
|
|
108
124
|
}
|
|
125
|
+
if (action === 'type' && args.method !== undefined) {
|
|
126
|
+
if (args.method !== 'auto' && args.method !== 'keys' && args.method !== 'paste') {
|
|
127
|
+
return 'type method must be auto|keys|paste';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (action === 'wait_until') {
|
|
131
|
+
const parsed = parseWaitCondition(args.condition, diffThreshold());
|
|
132
|
+
if (!parsed.ok)
|
|
133
|
+
return parsed.error;
|
|
134
|
+
const to = args.timeout_ms;
|
|
135
|
+
if (to !== undefined &&
|
|
136
|
+
(typeof to !== 'number' ||
|
|
137
|
+
!Number.isInteger(to) ||
|
|
138
|
+
to < WAIT_UNTIL_MIN_TIMEOUT_MS ||
|
|
139
|
+
to > WAIT_UNTIL_MAX_TIMEOUT_MS)) {
|
|
140
|
+
return `timeout_ms must be an integer in ${WAIT_UNTIL_MIN_TIMEOUT_MS}-${WAIT_UNTIL_MAX_TIMEOUT_MS}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
109
143
|
if (action === 'scroll') {
|
|
110
144
|
const dir = args.scroll_direction;
|
|
111
145
|
if (dir !== 'up' && dir !== 'down' && dir !== 'left' && dir !== 'right') {
|
|
@@ -122,6 +156,53 @@ export function validateComputerArgs(args) {
|
|
|
122
156
|
return `action "wait" requires duration_ms: integer in 1-${WAIT_MAX_MS}`;
|
|
123
157
|
}
|
|
124
158
|
}
|
|
159
|
+
if (action === 'inspect' && args.max_nodes !== undefined) {
|
|
160
|
+
const n = args.max_nodes;
|
|
161
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > INSPECT_MAX_NODES) {
|
|
162
|
+
return `max_nodes must be an integer in 1-${INSPECT_MAX_NODES}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (ELEMENT_ACTIONS.has(action)) {
|
|
166
|
+
if (typeof args.ref !== 'string' || !/^e\d+$/.test(args.ref)) {
|
|
167
|
+
return `action "${action}" requires ref: an element ref like "e12" from the latest inspect`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (action === 'click_element') {
|
|
171
|
+
const b = args.button;
|
|
172
|
+
if (b !== undefined && b !== 'left' && b !== 'right' && b !== 'middle') {
|
|
173
|
+
return 'click_element button must be left|right|middle';
|
|
174
|
+
}
|
|
175
|
+
const c = args.click_count;
|
|
176
|
+
if (c !== undefined && c !== 1 && c !== 2 && c !== 3)
|
|
177
|
+
return 'click_element click_count must be 1, 2 or 3';
|
|
178
|
+
if (args.via !== undefined && args.via !== 'mouse' && args.via !== 'pattern') {
|
|
179
|
+
return 'click_element via must be mouse|pattern';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (action === 'set_value') {
|
|
183
|
+
if (typeof args.text !== 'string')
|
|
184
|
+
return 'action "set_value" requires a text string';
|
|
185
|
+
if (args.via !== undefined && args.via !== 'pattern' && args.via !== 'keys') {
|
|
186
|
+
return 'set_value via must be pattern|keys';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// 窗口作用域(design-notes/computer-use-rpa.md §4):window("wN",来自最近一次 list_windows)与 title_regex 二选一。
|
|
190
|
+
const hasWin = args.window !== undefined;
|
|
191
|
+
const hasRe = args.title_regex !== undefined;
|
|
192
|
+
if (hasWin && (typeof args.window !== 'string' || !/^w\d+$/.test(args.window))) {
|
|
193
|
+
return 'window must look like "w3" (a ref from the latest list_windows); use title_regex to match by title';
|
|
194
|
+
}
|
|
195
|
+
if (hasRe && (typeof args.title_regex !== 'string' || args.title_regex.length === 0)) {
|
|
196
|
+
return 'title_regex must be a non-empty string';
|
|
197
|
+
}
|
|
198
|
+
if (hasWin && hasRe)
|
|
199
|
+
return 'pass either window or title_regex, not both';
|
|
200
|
+
if (action === 'focus_window' && !hasWin && !hasRe) {
|
|
201
|
+
return 'action "focus_window" requires window ("wN" from list_windows) or title_regex';
|
|
202
|
+
}
|
|
203
|
+
if (args.move_to_primary !== undefined && typeof args.move_to_primary !== 'boolean') {
|
|
204
|
+
return 'move_to_primary must be a boolean';
|
|
205
|
+
}
|
|
125
206
|
return null;
|
|
126
207
|
}
|
|
127
208
|
/**
|
|
@@ -135,9 +216,40 @@ export function validateComputerArgs(args) {
|
|
|
135
216
|
let geometryCache = null;
|
|
136
217
|
/** 会话级上一帧(已 downscale),用于差分。/cu off 时清空。 */
|
|
137
218
|
let lastFrame = null;
|
|
219
|
+
let elementSnapshot = null;
|
|
220
|
+
let snapshotGeneration = 0;
|
|
221
|
+
/**
|
|
222
|
+
* 最近一次 list_windows 的窗口快照(w1..wN → hwnd)。ref 只在下一次 list_windows 之前有效;
|
|
223
|
+
* title_regex 选择走实时枚举、不覆盖它,以免悄悄让模型手里的 wN 失效。
|
|
224
|
+
*/
|
|
225
|
+
let windowSnapshot = [];
|
|
226
|
+
/** 窗口选择失败(ref 过期 / 正则无匹配或歧义)。execute 据此返回专门前缀,让模型重新 list_windows。 */
|
|
227
|
+
class WindowSelectionError extends Error {
|
|
228
|
+
}
|
|
229
|
+
/** 按 window:'wN'(快照)或 title_regex(实时枚举)选窗口。 */
|
|
230
|
+
async function resolveWindow(args, screen, signal) {
|
|
231
|
+
let picked;
|
|
232
|
+
if (typeof args.window === 'string') {
|
|
233
|
+
picked = pickWindow(windowSnapshot, { ref: args.window });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const { items } = formatWindowList(await getUiaService().windows(signal), screen);
|
|
237
|
+
picked = pickWindow(items, { titleRegex: args.title_regex });
|
|
238
|
+
}
|
|
239
|
+
if (!picked.ok)
|
|
240
|
+
throw new WindowSelectionError(picked.error);
|
|
241
|
+
return picked.item;
|
|
242
|
+
}
|
|
243
|
+
function windowLabel(w) {
|
|
244
|
+
const title = w.title.replace(/\s+/g, ' ').trim();
|
|
245
|
+
return `${w.ref} ${w.processName ? `${w.processName}.exe` : 'unknown'} ${JSON.stringify(title.length > 60 ? `${title.slice(0, 60)}…` : title)}`;
|
|
246
|
+
}
|
|
138
247
|
export function resetComputerState() {
|
|
139
248
|
geometryCache = null;
|
|
140
249
|
lastFrame = null;
|
|
250
|
+
elementSnapshot = null;
|
|
251
|
+
windowSnapshot = [];
|
|
252
|
+
clearElementRefNames();
|
|
141
253
|
}
|
|
142
254
|
/** 抓一次主屏并返回几何信息。Windows 走 captureDesktop 的 geometry(零解码);其余平台只读 IHDR。 */
|
|
143
255
|
async function capturePrimary(signal) {
|
|
@@ -234,7 +346,42 @@ async function toAttachment(path, m) {
|
|
|
234
346
|
function commitFrame(img) {
|
|
235
347
|
lastFrame = img;
|
|
236
348
|
}
|
|
237
|
-
|
|
349
|
+
/** 元素动作无法定位目标(ref 过期 / 元素消失)。execute 据此返回专门提示,让模型重新 inspect。 */
|
|
350
|
+
class ElementNotFoundError extends Error {
|
|
351
|
+
}
|
|
352
|
+
/** 按 ref 查当前快照;未 inspect 或 ref 不在当前 generation → ElementNotFoundError。 */
|
|
353
|
+
function lookupElement(ref) {
|
|
354
|
+
if (!elementSnapshot) {
|
|
355
|
+
throw new ElementNotFoundError(`no element snapshot yet; call inspect before using ref ${ref}`);
|
|
356
|
+
}
|
|
357
|
+
const item = elementSnapshot.items.get(ref);
|
|
358
|
+
if (!item) {
|
|
359
|
+
throw new ElementNotFoundError(`ref ${ref} is not in the current snapshot (generation ${elementSnapshot.generation}); call inspect again`);
|
|
360
|
+
}
|
|
361
|
+
return { hwnd: elementSnapshot.tree.window.hwnd, item };
|
|
362
|
+
}
|
|
363
|
+
/** via:'pattern' 时按优先级挑一个可用的点击语义 pattern。 */
|
|
364
|
+
export function pickClickPattern(patterns) {
|
|
365
|
+
for (const p of ['invoke', 'toggle', 'select', 'expand']) {
|
|
366
|
+
if (patterns.includes(p))
|
|
367
|
+
return p;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
/** 调 UIA act;元素已变化/消失统一转成 ElementNotFoundError。 */
|
|
372
|
+
async function actOnElement(el, kind, signal, text) {
|
|
373
|
+
const { node } = el.item;
|
|
374
|
+
try {
|
|
375
|
+
return await getUiaService().act({ hwnd: el.hwnd, path: node.path, kind, text, expectRole: node.role, expectName: node.name }, signal);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (error instanceof UiaElementGoneError) {
|
|
379
|
+
throw new ElementNotFoundError(`element ${el.item.ref} ${node.role} ${JSON.stringify(node.name)} is gone or changed (${error.message}); call inspect again`);
|
|
380
|
+
}
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function executeAction(injector, action, args, screen, signal) {
|
|
238
385
|
const { physW, physH, originX, originY } = screen;
|
|
239
386
|
// norm1000 → 虚拟桌面物理像素:先按物理分辨率缩放,再叠加主屏原点(多屏)。
|
|
240
387
|
const toPhys = (pair) => {
|
|
@@ -279,8 +426,18 @@ async function executeAction(injector, action, args, screen) {
|
|
|
279
426
|
return `dragged from (${x0}, ${y0}) to (${x1}, ${y1})`;
|
|
280
427
|
}
|
|
281
428
|
case 'type': {
|
|
282
|
-
|
|
283
|
-
|
|
429
|
+
const text = args.text;
|
|
430
|
+
const rawMethod = args.method;
|
|
431
|
+
const method = pickTypeMethod(text, rawMethod === 'auto' ? undefined : rawMethod);
|
|
432
|
+
if (method === 'paste') {
|
|
433
|
+
const { clipboardOverwritten } = await injector.paste(text);
|
|
434
|
+
return (`pasted ${text.length} characters via clipboard` +
|
|
435
|
+
(clipboardOverwritten
|
|
436
|
+
? ' (WARNING: the previous clipboard held non-text data (image/files) and could not be restored)'
|
|
437
|
+
: ''));
|
|
438
|
+
}
|
|
439
|
+
await injector.typeText(text);
|
|
440
|
+
return `typed ${text.length} characters`;
|
|
284
441
|
}
|
|
285
442
|
case 'key': {
|
|
286
443
|
await injector.pressKey(args.text);
|
|
@@ -302,6 +459,56 @@ async function executeAction(injector, action, args, screen) {
|
|
|
302
459
|
const [nx, ny] = physicalToNorm(pos.x - originX, pos.y - originY, physW, physH);
|
|
303
460
|
return `cursor at physical (${pos.x}, ${pos.y}) = normalized (${nx}, ${ny})`;
|
|
304
461
|
}
|
|
462
|
+
case 'click_element': {
|
|
463
|
+
const el = lookupElement(args.ref);
|
|
464
|
+
const { node } = el.item;
|
|
465
|
+
const label = `element ${el.item.ref} ${node.role} ${JSON.stringify(node.name)}`;
|
|
466
|
+
if (args.via === 'pattern') {
|
|
467
|
+
const kind = pickClickPattern(node.patterns);
|
|
468
|
+
if (!kind) {
|
|
469
|
+
throw new Error(`${label} exposes no invoke/toggle/select/expand pattern; retry click_element with via "mouse"`);
|
|
470
|
+
}
|
|
471
|
+
await actOnElement(el, kind, signal);
|
|
472
|
+
return `clicked ${label} via ${kind} pattern`;
|
|
473
|
+
}
|
|
474
|
+
// mouse:按 hwnd+path 重定位拿**实时**矩形,不信任 inspect 时的旧矩形(窗口可能已移动)。
|
|
475
|
+
const { rect } = await actOnElement(el, 'rect', signal);
|
|
476
|
+
if (rect.w <= 0 || rect.h <= 0) {
|
|
477
|
+
throw new Error(`${label} has an empty bounding rectangle (hidden or collapsed); call inspect again`);
|
|
478
|
+
}
|
|
479
|
+
const [x, y] = rectCenter(rect);
|
|
480
|
+
await injector.moveTo(x, y);
|
|
481
|
+
const button = args.button ?? 'left';
|
|
482
|
+
const count = args.click_count ?? 1;
|
|
483
|
+
await injector.click(button, count);
|
|
484
|
+
return `clicked ${label} at (${x}, ${y})`;
|
|
485
|
+
}
|
|
486
|
+
case 'set_value': {
|
|
487
|
+
const el = lookupElement(args.ref);
|
|
488
|
+
const { node } = el.item;
|
|
489
|
+
const text = args.text;
|
|
490
|
+
const label = `element ${el.item.ref} ${node.role} ${JSON.stringify(node.name)}`;
|
|
491
|
+
// 密码框:模型不代填密码(design-notes/computer-use-rpa.md §2.6),用户需要时手动输入。
|
|
492
|
+
if (node.isPassword)
|
|
493
|
+
throw new Error(`${label} is a password field; refusing to fill it — ask the user to type it`);
|
|
494
|
+
const hasValuePattern = node.patterns.includes('value');
|
|
495
|
+
const via = args.via ?? (hasValuePattern ? 'pattern' : 'keys');
|
|
496
|
+
if (via === 'pattern') {
|
|
497
|
+
if (!hasValuePattern) {
|
|
498
|
+
throw new Error(`${label} does not support ValuePattern; retry set_value with via "keys"`);
|
|
499
|
+
}
|
|
500
|
+
await actOnElement(el, 'setvalue', signal, text);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
await actOnElement(el, 'focus', signal);
|
|
504
|
+
await injector.pressKey('ctrl+a');
|
|
505
|
+
if (text.length > 0)
|
|
506
|
+
await injector.typeText(text);
|
|
507
|
+
else
|
|
508
|
+
await injector.pressKey('Delete');
|
|
509
|
+
}
|
|
510
|
+
return `set value of ${label} (${text.length} chars) via ${via}`;
|
|
511
|
+
}
|
|
305
512
|
default:
|
|
306
513
|
throw new Error(`no executor for action "${action}"`);
|
|
307
514
|
}
|
|
@@ -321,11 +528,28 @@ function pct(values, p) {
|
|
|
321
528
|
return Math.round(s[i]);
|
|
322
529
|
}
|
|
323
530
|
export function getComputerMetrics() {
|
|
531
|
+
return summarizeMetrics(metrics);
|
|
532
|
+
}
|
|
533
|
+
/** 纯函数:把一批度量汇总成分位数(导出供单测用合成数据覆盖,不依赖模块级 ring buffer)。 */
|
|
534
|
+
export function summarizeMetrics(metrics) {
|
|
324
535
|
const totals = metrics.map((m) => m.totalMs);
|
|
325
536
|
const caps = metrics.map((m) => m.captureMs);
|
|
326
537
|
const skipped = metrics.filter((m) => m.skipped).length;
|
|
327
538
|
const bytes = metrics.reduce((a, m) => a + m.bytes, 0);
|
|
539
|
+
const grouped = new Map();
|
|
540
|
+
for (const m of metrics) {
|
|
541
|
+
const list = grouped.get(m.action);
|
|
542
|
+
if (list)
|
|
543
|
+
list.push(m.totalMs);
|
|
544
|
+
else
|
|
545
|
+
grouped.set(m.action, [m.totalMs]);
|
|
546
|
+
}
|
|
547
|
+
const byAction = {};
|
|
548
|
+
for (const [action, list] of grouped) {
|
|
549
|
+
byAction[action] = { count: list.length, p50: pct(list, 50), p95: pct(list, 95) };
|
|
550
|
+
}
|
|
328
551
|
return {
|
|
552
|
+
byAction,
|
|
329
553
|
count: metrics.length,
|
|
330
554
|
totalP50: pct(totals, 50),
|
|
331
555
|
totalP95: pct(totals, 95),
|
|
@@ -338,15 +562,17 @@ export function getComputerMetrics() {
|
|
|
338
562
|
export function resetComputerMetrics() {
|
|
339
563
|
metrics.length = 0;
|
|
340
564
|
}
|
|
341
|
-
|
|
565
|
+
const computerToolBase = {
|
|
342
566
|
name: 'computer',
|
|
343
567
|
description: 'Control the desktop GUI: move/click the mouse, type text, press keys, scroll, and zoom into screen regions. ' +
|
|
344
568
|
'Every action re-captures the screen. If the screen is unchanged since the last screenshot you saw, no image is ' +
|
|
345
569
|
'attached and the result says so — treat the previous screenshot as still current instead of assuming the action failed. ' +
|
|
346
570
|
'Inspect each attached screenshot before the next action and self-correct. ' +
|
|
347
571
|
'Coordinates use a normalized 0-1000 grid (x right, y down) over the primary screen; you never need the physical resolution. ' +
|
|
348
|
-
'Use zoom on a tight region before clicking small or dense UI elements.
|
|
349
|
-
'
|
|
572
|
+
'Use zoom on a tight region before clicking small or dense UI elements. ' +
|
|
573
|
+
'Prefer inspect + click_element/set_value for standard desktop apps (Windows UI Automation): element refs are exact and ' +
|
|
574
|
+
'cheaper than screenshots. Fall back to coordinates when the tree is sparse or the target is not listed. ' +
|
|
575
|
+
'Destructive or sensitive targets (form submit, payment, credentials, delete/send) require explicit user intent — ask first.',
|
|
350
576
|
risk: 'dangerous',
|
|
351
577
|
parameters: {
|
|
352
578
|
type: 'object',
|
|
@@ -368,7 +594,7 @@ export const computerTool = {
|
|
|
368
594
|
},
|
|
369
595
|
text: {
|
|
370
596
|
type: 'string',
|
|
371
|
-
description: 'Text to type (type)
|
|
597
|
+
description: 'Text to type (type), key combo like "ctrl+s" / "Return" / "ctrl+shift+t" (key), or the new value (set_value; may be empty to clear).',
|
|
372
598
|
},
|
|
373
599
|
scroll_direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
|
374
600
|
scroll_amount: { type: 'integer', description: `Scroll clicks 1-${SCROLL_MAX} (default 3).` },
|
|
@@ -385,6 +611,62 @@ export const computerTool = {
|
|
|
385
611
|
enum: ['primary', 'all'],
|
|
386
612
|
description: 'screenshot only: display scope (default primary). All other actions act on the primary screen.',
|
|
387
613
|
},
|
|
614
|
+
max_nodes: {
|
|
615
|
+
type: 'integer',
|
|
616
|
+
description: `inspect only: max elements listed (1-${INSPECT_MAX_NODES}, default ${INSPECT_DEFAULT_NODES}).`,
|
|
617
|
+
},
|
|
618
|
+
include_screenshot: {
|
|
619
|
+
type: 'boolean',
|
|
620
|
+
description: 'inspect only: also attach a screenshot (default false; the element list is usually enough).',
|
|
621
|
+
},
|
|
622
|
+
window: {
|
|
623
|
+
type: 'string',
|
|
624
|
+
description: 'Window ref like "w3" from the latest list_windows. focus_window: the window to bring to front. ' +
|
|
625
|
+
'inspect / screenshot: scope to that window (works even when it is covered by other windows).',
|
|
626
|
+
},
|
|
627
|
+
title_regex: {
|
|
628
|
+
type: 'string',
|
|
629
|
+
description: 'Alternative to window: case-insensitive regex matched against window titles (must match exactly one window). ' +
|
|
630
|
+
'focus_window / inspect / screenshot.',
|
|
631
|
+
},
|
|
632
|
+
move_to_primary: {
|
|
633
|
+
type: 'boolean',
|
|
634
|
+
description: 'focus_window only: if the window is on a secondary display or off-screen, move it to the primary screen first ' +
|
|
635
|
+
'(default false: a window on another display is only reported, not moved).',
|
|
636
|
+
},
|
|
637
|
+
ref: {
|
|
638
|
+
type: 'string',
|
|
639
|
+
description: 'click_element/set_value: element ref like "e12" from the latest inspect (refs expire on the next inspect).',
|
|
640
|
+
},
|
|
641
|
+
button: {
|
|
642
|
+
type: 'string',
|
|
643
|
+
enum: ['left', 'right', 'middle'],
|
|
644
|
+
description: 'click_element only: mouse button (default left).',
|
|
645
|
+
},
|
|
646
|
+
click_count: { type: 'integer', description: 'click_element only: 1-3 (default 1).' },
|
|
647
|
+
via: {
|
|
648
|
+
type: 'string',
|
|
649
|
+
enum: ['mouse', 'pattern', 'keys'],
|
|
650
|
+
description: 'click_element: "mouse" (default, real click at the live element center) or "pattern" (UIA Invoke/Toggle/Select/Expand, no mouse). ' +
|
|
651
|
+
'set_value: "pattern" (ValuePattern, default when supported) or "keys" (focus, select all, type).',
|
|
652
|
+
},
|
|
653
|
+
condition: {
|
|
654
|
+
type: 'object',
|
|
655
|
+
description: 'wait_until only. { kind: "stable" | "changed" | "element_present" | "element_absent", ' +
|
|
656
|
+
'frames?: 2-5 (stable), threshold?: 0-1 frame-diff ratio, interval_ms?: 200-2000, ' +
|
|
657
|
+
'ref?: "e12" or selector_text?: \'Button:"OK"\' (element_* only, exactly one) }. ' +
|
|
658
|
+
'"stable" = screen stops changing (page/app finished loading); "changed" = screen differs from the last screenshot you saw.',
|
|
659
|
+
},
|
|
660
|
+
timeout_ms: {
|
|
661
|
+
type: 'integer',
|
|
662
|
+
description: `wait_until only: give up after this many ms (${WAIT_UNTIL_MIN_TIMEOUT_MS}-${WAIT_UNTIL_MAX_TIMEOUT_MS}, default ${WAIT_UNTIL_DEFAULT_TIMEOUT_MS}).`,
|
|
663
|
+
},
|
|
664
|
+
method: {
|
|
665
|
+
type: 'string',
|
|
666
|
+
enum: ['auto', 'keys', 'paste'],
|
|
667
|
+
description: 'type only: "keys" (per-character key events), "paste" (clipboard paste, fast and IME-safe; restores text clipboard), ' +
|
|
668
|
+
'or "auto" (default: paste for text >200 chars or containing newlines, else keys).',
|
|
669
|
+
},
|
|
388
670
|
},
|
|
389
671
|
required: ['action'],
|
|
390
672
|
additionalProperties: false,
|
|
@@ -429,7 +711,9 @@ export const computerTool = {
|
|
|
429
711
|
// (抓屏是整条链路最贵的一步,省掉这一步等于把每步的本地开销砍半)。
|
|
430
712
|
let screen;
|
|
431
713
|
let freshPath;
|
|
432
|
-
|
|
714
|
+
// 窗口作用域的 screenshot 走 PrintWindow,不需要整屏位图,只要几何(命中缓存即免抓屏)。
|
|
715
|
+
const windowScoped = args.window !== undefined || args.title_regex !== undefined;
|
|
716
|
+
if (action === 'zoom' || (action === 'screenshot' && !windowScoped)) {
|
|
433
717
|
const t = Date.now();
|
|
434
718
|
const shot = await capturePrimary(ctx?.signal);
|
|
435
719
|
m.captureMs += Date.now() - t;
|
|
@@ -483,6 +767,91 @@ export const computerTool = {
|
|
|
483
767
|
],
|
|
484
768
|
};
|
|
485
769
|
}
|
|
770
|
+
// list_windows:枚举顶层窗口并刷新 wN 快照;不注入输入、不附截图。
|
|
771
|
+
if (action === 'list_windows') {
|
|
772
|
+
const t = Date.now();
|
|
773
|
+
const raw = await getUiaService().windows(ctx?.signal);
|
|
774
|
+
m.injectMs += Date.now() - t;
|
|
775
|
+
const { text, items } = formatWindowList(raw, screen);
|
|
776
|
+
windowSnapshot = items;
|
|
777
|
+
return { status: 'success', code: 'OK', retryable: false, output: text };
|
|
778
|
+
}
|
|
779
|
+
// focus_window:还原 + 置前。副屏窗口默认只报告不搬动(computer 的动作只作用于主屏),move_to_primary 才挪到主屏。
|
|
780
|
+
if (action === 'focus_window') {
|
|
781
|
+
const w = await resolveWindow(args, screen, ctx?.signal);
|
|
782
|
+
const moveToPrimary = args.move_to_primary === true;
|
|
783
|
+
if (!w.minimized && !w.onPrimary && !moveToPrimary) {
|
|
784
|
+
return {
|
|
785
|
+
status: 'success',
|
|
786
|
+
code: 'OK',
|
|
787
|
+
retryable: false,
|
|
788
|
+
output: `${windowLabel(w)} is on a secondary display or off-screen, so it was NOT focused or moved ` +
|
|
789
|
+
'(computer actions only reach the primary screen). Retry focus_window with move_to_primary: true to bring it onto the primary screen.',
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const t = Date.now();
|
|
793
|
+
const res = await getUiaService().focusWindow({
|
|
794
|
+
hwnd: w.hwnd,
|
|
795
|
+
moveTo: moveToPrimary && !w.onPrimary ? { x: screen.originX + 40, y: screen.originY + 40 } : undefined,
|
|
796
|
+
}, ctx?.signal);
|
|
797
|
+
m.injectMs += Date.now() - t;
|
|
798
|
+
const norm = rectToNorm(res.rect, screen);
|
|
799
|
+
const cx = res.rect.x + res.rect.w / 2;
|
|
800
|
+
const cy = res.rect.y + res.rect.h / 2;
|
|
801
|
+
const nowOnPrimary = cx >= screen.originX &&
|
|
802
|
+
cx < screen.originX + screen.physW &&
|
|
803
|
+
cy >= screen.originY &&
|
|
804
|
+
cy < screen.originY + screen.physH;
|
|
805
|
+
const notes = [];
|
|
806
|
+
if (!res.foreground) {
|
|
807
|
+
notes.push('The OS refused the foreground switch (focus-stealing protection); the window may still be behind others — retry once or click its title bar.');
|
|
808
|
+
}
|
|
809
|
+
if (!nowOnPrimary) {
|
|
810
|
+
notes.push('The window is not on the primary screen; retry with move_to_primary: true.');
|
|
811
|
+
}
|
|
812
|
+
const t2 = Date.now();
|
|
813
|
+
const shot = await capturePrimary(ctx?.signal);
|
|
814
|
+
m.captureMs += Date.now() - t2;
|
|
815
|
+
syncGeometry(shot);
|
|
816
|
+
const att = await toAttachment(shot.path, m);
|
|
817
|
+
commitFrame(att.img);
|
|
818
|
+
m.bytes = att.bytes;
|
|
819
|
+
return {
|
|
820
|
+
status: 'success',
|
|
821
|
+
code: 'OK',
|
|
822
|
+
retryable: false,
|
|
823
|
+
output: `Focused ${windowLabel(w)}${nowOnPrimary ? ` rect=[${norm.join(', ')}] (normalized 0-1000 over the primary screen)` : ''}. ` +
|
|
824
|
+
`Screen re-captured (shown at ${att.dispW}×${att.dispH}).${notes.length ? ` ${notes.join(' ')}` : ''}`,
|
|
825
|
+
modelAttachments: [{ type: 'image', name: 'computer-result.png', mime: 'image/png', dataUrl: att.dataUrl }],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
// screenshot + window/title_regex:PrintWindow 截单个窗口(被遮挡也能截),不是整屏基准,不参与差分。
|
|
829
|
+
if (action === 'screenshot' && windowScoped) {
|
|
830
|
+
const w = await resolveWindow(args, screen, ctx?.signal);
|
|
831
|
+
if (w.minimized) {
|
|
832
|
+
throw new WindowSelectionError(`${windowLabel(w)} is minimized; call focus_window first`);
|
|
833
|
+
}
|
|
834
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
835
|
+
const winPath = jailResolve(`.mocode/screenshots/computer-${ts}-window.png`);
|
|
836
|
+
await mkdir(dirname(winPath), { recursive: true });
|
|
837
|
+
const t = Date.now();
|
|
838
|
+
const cap = await getUiaService().captureWindow({ hwnd: w.hwnd, path: winPath }, ctx?.signal);
|
|
839
|
+
m.captureMs += Date.now() - t;
|
|
840
|
+
const att = await toAttachment(winPath, m);
|
|
841
|
+
lastFrame = null;
|
|
842
|
+
m.bytes = att.bytes;
|
|
843
|
+
const norm = rectToNorm(cap.rect, screen);
|
|
844
|
+
return {
|
|
845
|
+
status: 'success',
|
|
846
|
+
code: 'OK',
|
|
847
|
+
retryable: false,
|
|
848
|
+
output: `Window screenshot of ${windowLabel(w)} (${cap.method}, ${cap.width}×${cap.height} physical, shown at ${att.dispW}×${att.dispH}). ` +
|
|
849
|
+
`On the primary screen this window occupies normalized rect [${norm.join(', ')}] (x, y, w, h): ` +
|
|
850
|
+
'the attached image shows ONLY the window, so convert image-local fractions to screen coordinates ' +
|
|
851
|
+
'(x = rect.x + fx·rect.w, y = rect.y + fy·rect.h) before clicking. If other windows cover it, focus_window first — clicks hit whatever is on top.',
|
|
852
|
+
modelAttachments: [{ type: 'image', name: 'computer-window.png', mime: 'image/png', dataUrl: att.dataUrl }],
|
|
853
|
+
};
|
|
854
|
+
}
|
|
486
855
|
// screenshot:只截屏回灌(target 可指定 all)。
|
|
487
856
|
if (action === 'screenshot') {
|
|
488
857
|
let shotPath = freshPath;
|
|
@@ -511,11 +880,144 @@ export const computerTool = {
|
|
|
511
880
|
],
|
|
512
881
|
};
|
|
513
882
|
}
|
|
883
|
+
// inspect:读前台窗口 UIA 元素树,文本回灌;不注入输入,默认不附截图(元素清单本身就是感知结果)。
|
|
884
|
+
if (action === 'inspect') {
|
|
885
|
+
// window/title_regex:按 hwnd 读指定窗口(可被遮挡);省略 = 前台窗口。
|
|
886
|
+
let inspectHwnd;
|
|
887
|
+
if (windowScoped) {
|
|
888
|
+
const w = await resolveWindow(args, screen, ctx?.signal);
|
|
889
|
+
if (w.minimized) {
|
|
890
|
+
throw new WindowSelectionError(`${windowLabel(w)} is minimized; call focus_window first`);
|
|
891
|
+
}
|
|
892
|
+
inspectHwnd = w.hwnd;
|
|
893
|
+
}
|
|
894
|
+
const t = Date.now();
|
|
895
|
+
const tree = await getUiaService().tree(inspectHwnd !== undefined ? { hwnd: inspectHwnd } : {}, ctx?.signal);
|
|
896
|
+
m.injectMs += Date.now() - t;
|
|
897
|
+
const maxNodes = args.max_nodes ?? INSPECT_DEFAULT_NODES;
|
|
898
|
+
const { items, truncated } = selectDisplayNodes(tree, maxNodes);
|
|
899
|
+
snapshotGeneration += 1;
|
|
900
|
+
elementSnapshot = {
|
|
901
|
+
generation: snapshotGeneration,
|
|
902
|
+
tree,
|
|
903
|
+
items: new Map(items.map((it) => [it.ref, it])),
|
|
904
|
+
};
|
|
905
|
+
setElementRefNames(items);
|
|
906
|
+
const text = formatSnapshot(tree, items, {
|
|
907
|
+
generation: snapshotGeneration,
|
|
908
|
+
truncated,
|
|
909
|
+
maxNodes,
|
|
910
|
+
geometry: screen,
|
|
911
|
+
});
|
|
912
|
+
if (args.include_screenshot === true) {
|
|
913
|
+
const t2 = Date.now();
|
|
914
|
+
const shot = await capturePrimary(ctx?.signal);
|
|
915
|
+
m.captureMs += Date.now() - t2;
|
|
916
|
+
syncGeometry(shot);
|
|
917
|
+
const att = await toAttachment(shot.path, m);
|
|
918
|
+
commitFrame(att.img);
|
|
919
|
+
m.bytes = att.bytes;
|
|
920
|
+
return {
|
|
921
|
+
status: 'success',
|
|
922
|
+
code: 'OK',
|
|
923
|
+
retryable: false,
|
|
924
|
+
output: text,
|
|
925
|
+
modelAttachments: [
|
|
926
|
+
{ type: 'image', name: 'computer-inspect.png', mime: 'image/png', dataUrl: att.dataUrl },
|
|
927
|
+
],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return { status: 'success', code: 'OK', retryable: false, output: text };
|
|
931
|
+
}
|
|
932
|
+
// wait_until:轮询直到画面稳定/变化或元素出现/消失;不注入输入(design-notes/computer-use-rpa.md §3.1)。
|
|
933
|
+
// 轮询帧只在内存里比对、用完即删文件;仅结束时抓一张正式截图回灌并设为新的差分基准。
|
|
934
|
+
if (action === 'wait_until') {
|
|
935
|
+
const parsed = parseWaitCondition(args.condition, diffThreshold());
|
|
936
|
+
if (!parsed.ok)
|
|
937
|
+
throw new Error(parsed.error); // validate 已拦过,这里只为收窄类型
|
|
938
|
+
const cond = parsed.condition;
|
|
939
|
+
const timeoutMs = args.timeout_ms ?? WAIT_UNTIL_DEFAULT_TIMEOUT_MS;
|
|
940
|
+
const grab = async () => {
|
|
941
|
+
const t0 = Date.now();
|
|
942
|
+
const shot = await capturePrimary(ctx?.signal);
|
|
943
|
+
m.captureMs += Date.now() - t0;
|
|
944
|
+
try {
|
|
945
|
+
return downscale(decodePng(await readFile(shot.path)), maxEdge()).img;
|
|
946
|
+
}
|
|
947
|
+
finally {
|
|
948
|
+
await rm(shot.path, { force: true }).catch(() => undefined);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
// 元素存在性:在前台窗口当前 UIA 树里按 role+name 找(ref 取自最近一次 inspect 快照)。
|
|
952
|
+
const elementExists = async (target) => {
|
|
953
|
+
let role;
|
|
954
|
+
let name;
|
|
955
|
+
if ('ref' in target) {
|
|
956
|
+
const known = lookupElement(target.ref).item.node;
|
|
957
|
+
role = known.role;
|
|
958
|
+
name = known.name;
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
role = target.step.role;
|
|
962
|
+
name = target.step.name;
|
|
963
|
+
}
|
|
964
|
+
const tree = await getUiaService().tree({}, ctx?.signal);
|
|
965
|
+
return tree.nodes.some((n) => n.id !== 0 && (role === undefined || n.role === role) && (name === undefined || n.name === name));
|
|
966
|
+
};
|
|
967
|
+
const res = await waitUntil(cond, timeoutMs, {
|
|
968
|
+
captureFrame: grab,
|
|
969
|
+
elementExists,
|
|
970
|
+
baseline: lastFrame,
|
|
971
|
+
signal: ctx?.signal,
|
|
972
|
+
});
|
|
973
|
+
if (res.outcome === 'aborted') {
|
|
974
|
+
return { status: 'aborted', code: 'ABORTED', retryable: false, output: 'wait_until aborted' };
|
|
975
|
+
}
|
|
976
|
+
const timedOut = res.outcome === 'timeout';
|
|
977
|
+
const desc = describeCondition(cond);
|
|
978
|
+
const stats = `${res.elapsedMs}ms, ${res.polls} polls` +
|
|
979
|
+
(res.lastDiff !== undefined ? `, last frame diff ${(res.lastDiff * 100).toFixed(2)}%` : '');
|
|
980
|
+
// 画面类条件、以及任何超时,都回灌一张最终截图;元素类条件满足时纯文本即可。
|
|
981
|
+
const wantShot = timedOut || cond.kind === 'stable' || cond.kind === 'changed';
|
|
982
|
+
let attachments;
|
|
983
|
+
if (wantShot) {
|
|
984
|
+
const t2 = Date.now();
|
|
985
|
+
const shot = await capturePrimary(ctx?.signal);
|
|
986
|
+
m.captureMs += Date.now() - t2;
|
|
987
|
+
syncGeometry(shot);
|
|
988
|
+
const att = await toAttachment(shot.path, m);
|
|
989
|
+
commitFrame(att.img);
|
|
990
|
+
m.bytes = att.bytes;
|
|
991
|
+
m.diff = att.diff;
|
|
992
|
+
attachments = [{ type: 'image', name: 'computer-wait.png', mime: 'image/png', dataUrl: att.dataUrl }];
|
|
993
|
+
}
|
|
994
|
+
const shotNote = wantShot
|
|
995
|
+
? ` Screen re-captured (primary screen ${screen.physW}×${screen.physH} physical): inspect the attached screenshot.`
|
|
996
|
+
: '';
|
|
997
|
+
if (timedOut) {
|
|
998
|
+
// 超时不自动重试(TIMEOUT 契约):已烧掉整个等待窗口,由模型决定改条件/加长超时/换思路。
|
|
999
|
+
return {
|
|
1000
|
+
status: 'error',
|
|
1001
|
+
code: 'TIMEOUT',
|
|
1002
|
+
retryable: false,
|
|
1003
|
+
output: `waited until ${desc}: timed out (${stats}). The condition did not hold within ${timeoutMs}ms.` +
|
|
1004
|
+
`${shotNote} Check the screenshot, then adjust the condition, raise timeout_ms, or take a different approach.`,
|
|
1005
|
+
modelAttachments: attachments,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
status: 'success',
|
|
1010
|
+
code: 'OK',
|
|
1011
|
+
retryable: false,
|
|
1012
|
+
output: `waited until ${desc}: met (${stats}).${shotNote}`,
|
|
1013
|
+
modelAttachments: attachments,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
514
1016
|
// 输入动作:注入 → 重截屏回灌(wait 也回灌,等待后界面往往已变化)。
|
|
515
1017
|
if (action === 'left_mouse_down')
|
|
516
1018
|
pendingMouseDown = true;
|
|
517
1019
|
let t = Date.now();
|
|
518
|
-
const summary = await executeAction(injector, action, args, screen);
|
|
1020
|
+
const summary = await executeAction(injector, action, args, screen, ctx?.signal);
|
|
519
1021
|
m.injectMs += Date.now() - t;
|
|
520
1022
|
if (action === 'left_mouse_up')
|
|
521
1023
|
pendingMouseDown = false;
|
|
@@ -558,6 +1060,25 @@ export const computerTool = {
|
|
|
558
1060
|
if (ctx?.signal?.aborted || /aborted/i.test(message)) {
|
|
559
1061
|
return { status: 'aborted', code: 'ABORTED', retryable: false, output: message };
|
|
560
1062
|
}
|
|
1063
|
+
if (error instanceof ElementNotFoundError) {
|
|
1064
|
+
// ToolOutcomeCode 无 ELEMENT_NOT_FOUND;用 POSTCONDITION_FAILED 表达「前置定位不成立」,
|
|
1065
|
+
// 输出前缀固定,供台账识别。不自动重试:界面已变,必须让模型重新 inspect。
|
|
1066
|
+
return {
|
|
1067
|
+
status: 'error',
|
|
1068
|
+
code: 'POSTCONDITION_FAILED',
|
|
1069
|
+
retryable: false,
|
|
1070
|
+
output: `computer action failed: ELEMENT_NOT_FOUND: ${message}`,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
if (error instanceof WindowSelectionError || error instanceof UiaWindowGoneError) {
|
|
1074
|
+
// 窗口选不到 / 已关闭:同样是「前置定位不成立」,不自动重试,让模型重新 list_windows。
|
|
1075
|
+
return {
|
|
1076
|
+
status: 'error',
|
|
1077
|
+
code: 'POSTCONDITION_FAILED',
|
|
1078
|
+
retryable: false,
|
|
1079
|
+
output: `computer action failed: WINDOW_NOT_FOUND: ${message}`,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
561
1082
|
return {
|
|
562
1083
|
status: 'error',
|
|
563
1084
|
code: 'EXECUTION_ERROR',
|
|
@@ -579,3 +1100,119 @@ export const computerTool = {
|
|
|
579
1100
|
}
|
|
580
1101
|
},
|
|
581
1102
|
};
|
|
1103
|
+
/**
|
|
1104
|
+
* 构造 trace 条目的上下文:必须在执行前读取模块级快照(执行后 inspect/list_windows 可能已替换)。
|
|
1105
|
+
* 任何异常都按"不录制"处理,绝不影响动作本身。
|
|
1106
|
+
*/
|
|
1107
|
+
function buildTraceForCall(args) {
|
|
1108
|
+
const ctx = {
|
|
1109
|
+
seq: nextTraceSeq(),
|
|
1110
|
+
geometry: geometryCache ?? undefined,
|
|
1111
|
+
lookupRef: lookupElementRefName,
|
|
1112
|
+
};
|
|
1113
|
+
if ((args.action === 'click_element' || args.action === 'set_value') && typeof args.ref === 'string') {
|
|
1114
|
+
const item = elementSnapshot?.items.get(args.ref);
|
|
1115
|
+
if (elementSnapshot && item)
|
|
1116
|
+
ctx.element = { tree: elementSnapshot.tree, nodeId: item.node.id };
|
|
1117
|
+
}
|
|
1118
|
+
if (args.action === 'focus_window' && typeof args.window === 'string') {
|
|
1119
|
+
ctx.windowProcess = windowSnapshot.find((w) => w.ref === args.window)?.processName || undefined;
|
|
1120
|
+
}
|
|
1121
|
+
return buildTraceEntry(args, ctx);
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* 对外导出的 computer 工具 = 基础工具 + 结构化录制(design-notes/computer-use-rpa.md §5.1)。
|
|
1125
|
+
* 仅在动作成功后追加一行 gui-trace.jsonl;回放期间(withTraceSuspended)与 MOCODE_FLOW_TRACE=false 时不录。
|
|
1126
|
+
*/
|
|
1127
|
+
export const computerTool = {
|
|
1128
|
+
...computerToolBase,
|
|
1129
|
+
async execute(args, ctx) {
|
|
1130
|
+
let entry = null;
|
|
1131
|
+
if (traceEnabled() && !isTraceSuspended() && typeof args.action === 'string' && isRecordedAction(args.action)) {
|
|
1132
|
+
try {
|
|
1133
|
+
entry = buildTraceForCall(args);
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
entry = null;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const result = await computerToolBase.execute(args, ctx);
|
|
1140
|
+
if (entry && typeof result !== 'string' && result.status === 'success') {
|
|
1141
|
+
entry.changed = outputIndicatesChange(entry.action, result.output);
|
|
1142
|
+
appendTrace(entry);
|
|
1143
|
+
}
|
|
1144
|
+
return result;
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
// ── flow 回放接入点(src/flows/runner.ts 的 RunnerDeps 由 run_flow 工具接到这里) ─────────────
|
|
1148
|
+
/**
|
|
1149
|
+
* 把录制的 selector 现场解析成当前快照里的 ref:按 processName(+titleRegex) 选窗 → 读该窗口 UIA 树 →
|
|
1150
|
+
* matchSelector(歧义即失败)→ 目标必须在展示集中(与 inspect 同口径),并把整棵展示集登记为新快照,
|
|
1151
|
+
* 这样随后的 click_element/set_value 带 ref 走原有"执行前按 hwnd+path 重定位并校验 role/name"路径。
|
|
1152
|
+
*/
|
|
1153
|
+
export async function resolveSelectorToRef(selector, signal) {
|
|
1154
|
+
try {
|
|
1155
|
+
const raw = await getUiaService().windows(signal);
|
|
1156
|
+
const picked = pickWindowForReplay(raw, {
|
|
1157
|
+
processName: selector.window.processName,
|
|
1158
|
+
titleRegex: selector.window.titleRegex,
|
|
1159
|
+
});
|
|
1160
|
+
if (!picked.ok)
|
|
1161
|
+
return { ok: false, error: picked.error };
|
|
1162
|
+
if (picked.window.minimized) {
|
|
1163
|
+
return {
|
|
1164
|
+
ok: false,
|
|
1165
|
+
error: `window ${JSON.stringify(picked.window.title.slice(0, 40))} is minimized; a focus_window step must run first`,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const tree = await getUiaService().tree({ hwnd: picked.window.hwnd }, signal);
|
|
1169
|
+
const node = matchSelector(tree, selector);
|
|
1170
|
+
if (!node) {
|
|
1171
|
+
return { ok: false, error: 'no unique element matches the recorded selector in the current window' };
|
|
1172
|
+
}
|
|
1173
|
+
const { items } = selectDisplayNodes(tree, tree.nodes.length);
|
|
1174
|
+
const hit = items.find((it) => it.node.id === node.id);
|
|
1175
|
+
if (!hit) {
|
|
1176
|
+
return {
|
|
1177
|
+
ok: false,
|
|
1178
|
+
error: `the matched ${node.role} ${JSON.stringify(node.name.slice(0, 40))} is not an interactive, visible element right now`,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
snapshotGeneration += 1;
|
|
1182
|
+
elementSnapshot = {
|
|
1183
|
+
generation: snapshotGeneration,
|
|
1184
|
+
tree,
|
|
1185
|
+
items: new Map(items.map((it) => [it.ref, it])),
|
|
1186
|
+
};
|
|
1187
|
+
setElementRefNames(items);
|
|
1188
|
+
return { ok: true, ref: hit.ref };
|
|
1189
|
+
}
|
|
1190
|
+
catch (error) {
|
|
1191
|
+
if (signal?.aborted)
|
|
1192
|
+
throw error;
|
|
1193
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* focus_window 回放:按 processName(+titleRegex) 现场选窗,刷新 wN 快照并返回 computer 能接受的 {window:'wN'}。
|
|
1198
|
+
*/
|
|
1199
|
+
export async function resolveWindowForReplay(win, signal) {
|
|
1200
|
+
try {
|
|
1201
|
+
const raw = await getUiaService().windows(signal);
|
|
1202
|
+
const picked = pickWindowForReplay(raw, win);
|
|
1203
|
+
if (!picked.ok)
|
|
1204
|
+
return { ok: false, error: picked.error };
|
|
1205
|
+
const { screen } = await resolveScreen(signal);
|
|
1206
|
+
const { items } = formatWindowList(raw, screen);
|
|
1207
|
+
const item = items.find((i) => i.hwnd === picked.window.hwnd);
|
|
1208
|
+
if (!item)
|
|
1209
|
+
return { ok: false, error: 'the matching window is not in the visible window list' };
|
|
1210
|
+
windowSnapshot = items;
|
|
1211
|
+
return { ok: true, args: { window: item.ref } };
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
if (signal?.aborted)
|
|
1215
|
+
throw error;
|
|
1216
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1217
|
+
}
|
|
1218
|
+
}
|