mocode-ai 1.7.2 → 1.7.5
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 +3 -3
- package/README.zh-CN.md +3 -3
- 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/compact.js +1 -1
- 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/read-file.js +54 -3
- package/dist/tools/builtins/run-flow.js +123 -0
- package/dist/tools/constants.js +8 -2
- 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
|
@@ -137,42 +137,38 @@ const PS_INJECTOR_SCRIPT = [
|
|
|
137
137
|
' [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }',
|
|
138
138
|
' [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }',
|
|
139
139
|
' [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }',
|
|
140
|
+
' public static void Key(ushort vk, ushort scan, uint flags) {',
|
|
141
|
+
' INPUT[] a = new INPUT[1];',
|
|
142
|
+
' a[0].type = 1;',
|
|
143
|
+
' a[0].U.ki.wVk = vk; a[0].U.ki.wScan = scan; a[0].U.ki.dwFlags = flags;',
|
|
144
|
+
' SendInput(1, a, Marshal.SizeOf(typeof(INPUT)));',
|
|
145
|
+
' }',
|
|
140
146
|
'}',
|
|
141
147
|
'"@',
|
|
142
148
|
// DPI 感知:SetCursorPos/GetCursorPos 始终按物理像素解释,而进程默认 DPI UNAWARE。
|
|
143
149
|
// 声明 PER_MONITOR_AWARE_V2(失败回落 SYSTEM_AWARE)后 GetCursorPos 与调用方的物理坐标口径一致,
|
|
144
150
|
// 避免 cursor_position 读回的坐标与主屏物理分辨率不同源。
|
|
145
151
|
'try { if (-not [Inject]::SetProcessDpiAwarenessContext([IntPtr](-4))) { [void][Inject]::SetProcessDPIAware() } } catch { }',
|
|
146
|
-
|
|
147
|
-
'
|
|
148
|
-
'
|
|
149
|
-
'
|
|
150
|
-
' $
|
|
151
|
-
' $inp.U.ki.wVk = $vk',
|
|
152
|
-
' $inp.U.ki.dwFlags = 0',
|
|
153
|
-
' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
|
|
152
|
+
// 剪贴板 API(paste op 用)要求 STA 线程,进程以 -STA 启动;剪贴板偶发被其它进程占用,重试几次。
|
|
153
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
154
|
+
'function Set-ClipText([string]$s) {',
|
|
155
|
+
' for ($i = 0; $i -lt 5; $i++) {',
|
|
156
|
+
' try { [System.Windows.Forms.Clipboard]::SetText($s); return } catch { Start-Sleep -Milliseconds 50 }',
|
|
154
157
|
' }',
|
|
158
|
+
' throw "clipboard is busy"',
|
|
159
|
+
'}',
|
|
160
|
+
// 键盘事件的 INPUT 必须在 C# 里构造:PowerShell 对嵌套值类型(`$inp.U.ki.wVk = ...`)的赋值作用在副本上,
|
|
161
|
+
// 结果是发出全零的空事件(实测 wVk 读回 0)。Inject.Key 在 C# 内就地填充后调用 SendInput。
|
|
162
|
+
'function Send-VkCombo([int[]]$vks) {',
|
|
163
|
+
' foreach ($vk in $vks) { [Inject]::Key([uint16]$vk, [uint16]0, [uint32]0) }',
|
|
155
164
|
' [array]::Reverse($vks)',
|
|
156
|
-
' foreach ($vk in $vks) {',
|
|
157
|
-
' $inp = New-Object "Inject+INPUT"',
|
|
158
|
-
' $inp.type = 1',
|
|
159
|
-
' $inp.U.ki.wVk = $vk',
|
|
160
|
-
' $inp.U.ki.dwFlags = 2',
|
|
161
|
-
' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
|
|
162
|
-
' }',
|
|
165
|
+
' foreach ($vk in $vks) { [Inject]::Key([uint16]$vk, [uint16]0, [uint32]2) }',
|
|
163
166
|
'}',
|
|
164
167
|
'function Send-UnicodeText([string]$text) {',
|
|
165
|
-
' $size = [Runtime.InteropServices.Marshal]::SizeOf([type]"Inject+INPUT")',
|
|
166
168
|
' for ($i = 0; $i -lt $text.Length; $i++) {',
|
|
167
|
-
' $code = [
|
|
168
|
-
'
|
|
169
|
-
'
|
|
170
|
-
' $inp.type = 1',
|
|
171
|
-
' $inp.U.ki.wVk = 0',
|
|
172
|
-
' $inp.U.ki.wScan = $code',
|
|
173
|
-
' $inp.U.ki.dwFlags = $flag',
|
|
174
|
-
' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
|
|
175
|
-
' }',
|
|
169
|
+
' $code = [uint16][char]$text[$i]',
|
|
170
|
+
' [Inject]::Key([uint16]0, $code, [uint32]4)',
|
|
171
|
+
' [Inject]::Key([uint16]0, $code, [uint32]6)',
|
|
176
172
|
' }',
|
|
177
173
|
'}',
|
|
178
174
|
'while ($null -ne ($line = [Console]::In.ReadLine())) {',
|
|
@@ -201,6 +197,19 @@ const PS_INJECTOR_SCRIPT = [
|
|
|
201
197
|
' [Inject]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero); $resp.ok = $true',
|
|
202
198
|
' }',
|
|
203
199
|
' "type" { Send-UnicodeText([string]$req.text); $resp.ok = $true }',
|
|
200
|
+
' "paste" {',
|
|
201
|
+
// 剪贴板粘贴:保存文本剪贴板 → 写入 → ctrl+v → 等目标应用读完 → 还原。
|
|
202
|
+
// 原剪贴板是图片/文件等非文本时无法还原,如实上报 overwritten,不假装还原。
|
|
203
|
+
' $hadText = [System.Windows.Forms.Clipboard]::ContainsText()',
|
|
204
|
+
' $old = if ($hadText) { [System.Windows.Forms.Clipboard]::GetText() } else { $null }',
|
|
205
|
+
' $nonText = (-not $hadText) -and ([System.Windows.Forms.Clipboard]::ContainsImage() -or [System.Windows.Forms.Clipboard]::ContainsFileDropList() -or [System.Windows.Forms.Clipboard]::ContainsAudio())',
|
|
206
|
+
' Set-ClipText ([string]$req.text)',
|
|
207
|
+
' Send-VkCombo([int[]]@(17, 86))',
|
|
208
|
+
' Start-Sleep -Milliseconds 150',
|
|
209
|
+
' if ($hadText) { Set-ClipText $old } elseif (-not $nonText) { [System.Windows.Forms.Clipboard]::Clear() }',
|
|
210
|
+
' $resp.overwritten = [bool]$nonText',
|
|
211
|
+
' $resp.ok = $true',
|
|
212
|
+
' }',
|
|
204
213
|
' "key" { Send-VkCombo([int[]]$req.vks); $resp.ok = $true }',
|
|
205
214
|
' "scroll" {',
|
|
206
215
|
' $amt = [int]$req.amount * 120',
|
|
@@ -244,7 +253,7 @@ class PowerShellInjector {
|
|
|
244
253
|
ensureProcess() {
|
|
245
254
|
if (this.child && this.child.exitCode === null && !this.child.killed)
|
|
246
255
|
return this.child;
|
|
247
|
-
this.child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', PS_INJECTOR_SCRIPT], { env: filterEnv(process.env), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
256
|
+
this.child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', PS_INJECTOR_SCRIPT], { env: filterEnv(process.env), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
248
257
|
this.stdoutBuf = '';
|
|
249
258
|
this.lastError = '';
|
|
250
259
|
this.child.stdout.on('data', (chunk) => this.onStdout(chunk.toString('utf8')));
|
|
@@ -355,6 +364,10 @@ class PowerShellInjector {
|
|
|
355
364
|
async typeText(text) {
|
|
356
365
|
await this.call('type', { text }, this.signal);
|
|
357
366
|
}
|
|
367
|
+
async paste(text) {
|
|
368
|
+
const resp = await this.call('paste', { text }, this.signal);
|
|
369
|
+
return { clipboardOverwritten: resp.overwritten === true };
|
|
370
|
+
}
|
|
358
371
|
async pressKey(combo) {
|
|
359
372
|
const vks = mapKeyComboToVk(combo);
|
|
360
373
|
await this.call('key', { vks }, this.signal);
|
package/dist/runtime/shutdown.js
CHANGED
|
@@ -14,12 +14,14 @@ export async function shutdownRuntime() {
|
|
|
14
14
|
try {
|
|
15
15
|
// 动态 import:未用过 computer 工具时不该把这两个常驻进程拉进来。
|
|
16
16
|
// 常驻 PowerShell 进程不随主进程自动退出,必须显式回收(否则每次会话留一组孤儿)。
|
|
17
|
-
const [{ disposeInputInjector }, { disposeCaptureService }] = await Promise.all([
|
|
17
|
+
const [{ disposeInputInjector }, { disposeCaptureService }, { disposeUiaService }] = await Promise.all([
|
|
18
18
|
import('./input-injector.js'),
|
|
19
19
|
import('./screen-capture-service.js'),
|
|
20
|
+
import('./uia-service.js'),
|
|
20
21
|
]);
|
|
21
22
|
await disposeInputInjector();
|
|
22
23
|
await disposeCaptureService();
|
|
24
|
+
await disposeUiaService();
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
25
27
|
// 从未启动过:忽略。
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UIA 元素树的纯函数层(design-notes/computer-use-rpa.md §2.2–2.4)。
|
|
3
|
+
*
|
|
4
|
+
* 只处理 JSON 化的元素树:解析/规整 PowerShell 侧输出、选择器生成与匹配、inspect 文本渲染、
|
|
5
|
+
* ref → 名称登记表(供权限层展示与敏感判定)。不碰进程、屏幕或 COM,跨平台可单测。
|
|
6
|
+
*
|
|
7
|
+
* 树形约定:nodes[0] 是窗口根元素,id 即数组下标,parent=-1 表示根;path 是从窗口根出发
|
|
8
|
+
* 按 ControlViewWalker 子序号走下来的路径(如 "0.3.1"),供 PowerShell 侧重新定位元素。
|
|
9
|
+
*/
|
|
10
|
+
import { physicalToNorm } from './screen-pipeline.js';
|
|
11
|
+
// ── 解析 PowerShell 输出 ────────────────────────────────────────────────
|
|
12
|
+
const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
13
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
14
|
+
function toRect(v) {
|
|
15
|
+
const o = (v && typeof v === 'object' ? v : {});
|
|
16
|
+
return {
|
|
17
|
+
x: Math.round(num(o.x)),
|
|
18
|
+
y: Math.round(num(o.y)),
|
|
19
|
+
w: Math.max(0, Math.round(num(o.w))),
|
|
20
|
+
h: Math.max(0, Math.round(num(o.h))),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** PowerShell 5.1 的 ConvertTo-Json 会把单元素数组压成标量,这里统一还原成数组。 */
|
|
24
|
+
function toArray(v) {
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return v;
|
|
27
|
+
if (v === undefined || v === null || v === '')
|
|
28
|
+
return [];
|
|
29
|
+
return [v];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 把 PowerShell `tree` op 的响应规整为 UiaRawTree。缺字段按保守默认补齐;
|
|
33
|
+
* 节点 id 以数组下标为准,parent 越界视为根的子节点。没有任何节点时抛错。
|
|
34
|
+
*/
|
|
35
|
+
export function parseRawTree(resp) {
|
|
36
|
+
const w = (resp.window && typeof resp.window === 'object' ? resp.window : {});
|
|
37
|
+
const rawNodes = toArray(resp.nodes);
|
|
38
|
+
if (rawNodes.length === 0)
|
|
39
|
+
throw new Error('UIA returned an empty element tree');
|
|
40
|
+
const nodes = rawNodes.map((item, i) => {
|
|
41
|
+
const o = (item && typeof item === 'object' ? item : {});
|
|
42
|
+
const parent = i === 0 ? -1 : Math.round(num(o.parent));
|
|
43
|
+
const isPassword = o.isPassword === true;
|
|
44
|
+
const node = {
|
|
45
|
+
id: i,
|
|
46
|
+
parent: i === 0 ? -1 : parent >= 0 && parent < i ? parent : 0,
|
|
47
|
+
path: str(o.path),
|
|
48
|
+
depth: Math.max(0, Math.round(num(o.depth))),
|
|
49
|
+
role: str(o.role) || 'Custom',
|
|
50
|
+
name: str(o.name),
|
|
51
|
+
automationId: str(o.automationId),
|
|
52
|
+
className: str(o.className),
|
|
53
|
+
rect: toRect(o.rect),
|
|
54
|
+
enabled: o.enabled !== false,
|
|
55
|
+
offscreen: o.offscreen === true,
|
|
56
|
+
isPassword,
|
|
57
|
+
patterns: toArray(o.patterns).map(str).filter(Boolean),
|
|
58
|
+
};
|
|
59
|
+
// 双保险:即便 PowerShell 侧漏判,密码节点的 value 也绝不进入 TS 侧结构。
|
|
60
|
+
if (!isPassword && typeof o.value === 'string' && o.value.length > 0)
|
|
61
|
+
node.value = o.value;
|
|
62
|
+
return node;
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
window: {
|
|
66
|
+
hwnd: Math.round(num(w.hwnd)),
|
|
67
|
+
title: str(w.title),
|
|
68
|
+
processName: str(w.processName),
|
|
69
|
+
pid: Math.round(num(w.pid)),
|
|
70
|
+
rect: toRect(w.rect),
|
|
71
|
+
},
|
|
72
|
+
nodes,
|
|
73
|
+
truncated: resp.truncated === true,
|
|
74
|
+
elapsedMs: Math.round(num(resp.elapsedMs)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// ── 可交互判定 ──────────────────────────────────────────────────────────
|
|
78
|
+
const INTERACTIVE_ROLES = new Set([
|
|
79
|
+
'Button',
|
|
80
|
+
'Edit',
|
|
81
|
+
'ComboBox',
|
|
82
|
+
'CheckBox',
|
|
83
|
+
'RadioButton',
|
|
84
|
+
'MenuItem',
|
|
85
|
+
'TabItem',
|
|
86
|
+
'ListItem',
|
|
87
|
+
'TreeItem',
|
|
88
|
+
'Hyperlink',
|
|
89
|
+
'Document',
|
|
90
|
+
'SplitButton',
|
|
91
|
+
'DataItem',
|
|
92
|
+
'Slider',
|
|
93
|
+
'Spinner',
|
|
94
|
+
]);
|
|
95
|
+
const ACTIONABLE_PATTERNS = new Set(['invoke', 'value', 'toggle', 'select', 'expand']);
|
|
96
|
+
export function isInteractive(n) {
|
|
97
|
+
return INTERACTIVE_ROLES.has(n.role) || n.patterns.some((p) => ACTIONABLE_PATTERNS.has(p));
|
|
98
|
+
}
|
|
99
|
+
/** 自增数字 id / GUID 形态的 AutomationId 在重启或重排后会变,不能作为选择器锚点。 */
|
|
100
|
+
export function isStableAutomationId(id) {
|
|
101
|
+
const s = id.trim();
|
|
102
|
+
if (!s)
|
|
103
|
+
return false;
|
|
104
|
+
if (/^\d+$/.test(s))
|
|
105
|
+
return false;
|
|
106
|
+
if (/^[{(]?[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}[})]?$/i.test(s))
|
|
107
|
+
return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// ── 选择器匹配 ──────────────────────────────────────────────────────────
|
|
111
|
+
function childrenMap(tree) {
|
|
112
|
+
const kids = new Map();
|
|
113
|
+
for (const n of tree.nodes) {
|
|
114
|
+
if (n.parent < 0)
|
|
115
|
+
continue;
|
|
116
|
+
const list = kids.get(n.parent);
|
|
117
|
+
if (list)
|
|
118
|
+
list.push(n.id);
|
|
119
|
+
else
|
|
120
|
+
kids.set(n.parent, [n.id]);
|
|
121
|
+
}
|
|
122
|
+
return kids;
|
|
123
|
+
}
|
|
124
|
+
/** ctx 的全部后代,文档序(先序 DFS)。 */
|
|
125
|
+
function descendants(tree, kids, ctx) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const stack = [...(kids.get(ctx) ?? [])].reverse();
|
|
128
|
+
while (stack.length) {
|
|
129
|
+
const id = stack.pop();
|
|
130
|
+
out.push(tree.nodes[id]);
|
|
131
|
+
const ch = kids.get(id);
|
|
132
|
+
if (ch)
|
|
133
|
+
for (let i = ch.length - 1; i >= 0; i--)
|
|
134
|
+
stack.push(ch[i]);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function stepMatches(n, step) {
|
|
139
|
+
if (step.role !== undefined && n.role !== step.role)
|
|
140
|
+
return false;
|
|
141
|
+
if (step.automationId !== undefined && n.automationId !== step.automationId)
|
|
142
|
+
return false;
|
|
143
|
+
if (step.className !== undefined && n.className !== step.className)
|
|
144
|
+
return false;
|
|
145
|
+
if (step.name !== undefined && n.name !== step.name)
|
|
146
|
+
return false;
|
|
147
|
+
if (step.nameRegex !== undefined) {
|
|
148
|
+
try {
|
|
149
|
+
if (!new RegExp(step.nameRegex).test(n.name))
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
function windowMatches(w, sel) {
|
|
159
|
+
if (sel.processName && w.processName.toLowerCase() !== sel.processName.toLowerCase())
|
|
160
|
+
return false;
|
|
161
|
+
if (sel.titleRegex) {
|
|
162
|
+
try {
|
|
163
|
+
if (!new RegExp(sel.titleRegex).test(w.title))
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 在树中按选择器定位元素。每一步在上一步命中节点的**全部后代**里找(不要求直接子节点);
|
|
174
|
+
* 有 index 取第 index 个候选,无 index 时必须唯一——多个候选视为歧义返回 null,宁可失败也不点错。
|
|
175
|
+
*/
|
|
176
|
+
export function matchSelector(tree, sel) {
|
|
177
|
+
if (!tree.nodes.length || !sel.path.length)
|
|
178
|
+
return null;
|
|
179
|
+
if (!windowMatches(tree.window, sel.window))
|
|
180
|
+
return null;
|
|
181
|
+
const kids = childrenMap(tree);
|
|
182
|
+
let ctx = 0;
|
|
183
|
+
for (const step of sel.path) {
|
|
184
|
+
const cands = descendants(tree, kids, ctx).filter((n) => stepMatches(n, step));
|
|
185
|
+
const pick = step.index !== undefined ? cands[step.index] : cands.length === 1 ? cands[0] : undefined;
|
|
186
|
+
if (!pick)
|
|
187
|
+
return null;
|
|
188
|
+
ctx = pick.id;
|
|
189
|
+
}
|
|
190
|
+
return tree.nodes[ctx];
|
|
191
|
+
}
|
|
192
|
+
// ── 选择器生成 ──────────────────────────────────────────────────────────
|
|
193
|
+
function baseStep(n) {
|
|
194
|
+
if (isStableAutomationId(n.automationId))
|
|
195
|
+
return { role: n.role, automationId: n.automationId };
|
|
196
|
+
return { role: n.role, name: n.name };
|
|
197
|
+
}
|
|
198
|
+
/** 对给定祖先链逐步生成 step,在不唯一处补 index。链中节点必须按深度递增且互为祖孙。 */
|
|
199
|
+
function stepsForChain(tree, kids, chain) {
|
|
200
|
+
const steps = [];
|
|
201
|
+
let ctx = 0;
|
|
202
|
+
for (const n of chain) {
|
|
203
|
+
const step = baseStep(n);
|
|
204
|
+
const cands = descendants(tree, kids, ctx).filter((c) => stepMatches(c, step));
|
|
205
|
+
const idx = cands.findIndex((c) => c.id === n.id);
|
|
206
|
+
if (idx < 0)
|
|
207
|
+
return null;
|
|
208
|
+
if (cands.length > 1)
|
|
209
|
+
step.index = idx;
|
|
210
|
+
steps.push(step);
|
|
211
|
+
ctx = n.id;
|
|
212
|
+
}
|
|
213
|
+
return steps;
|
|
214
|
+
}
|
|
215
|
+
const indexCount = (steps) => steps.filter((s) => s.index !== undefined).length;
|
|
216
|
+
/**
|
|
217
|
+
* 为 nodeId 生成选择器:先写出从窗口根到目标的完整祖先链,再贪心省略对定位无区分度的祖先
|
|
218
|
+
* (省略后仍能唯一命中目标、且 index 数不增加才接受)——路径越短,对布局变化越宽容。
|
|
219
|
+
* 不在热路径上调用:inspect 只登记 ref,真正点击时才按需生成。
|
|
220
|
+
*/
|
|
221
|
+
export function buildSelector(tree, nodeId) {
|
|
222
|
+
const target = tree.nodes[nodeId];
|
|
223
|
+
if (!target || nodeId === 0)
|
|
224
|
+
return null;
|
|
225
|
+
let chain = [];
|
|
226
|
+
for (let cur = target; cur && cur.id !== 0; cur = tree.nodes[cur.parent]) {
|
|
227
|
+
chain.unshift(cur);
|
|
228
|
+
if (cur.parent < 0)
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
const kids = childrenMap(tree);
|
|
232
|
+
const window = { processName: tree.window.processName };
|
|
233
|
+
let steps = stepsForChain(tree, kids, chain);
|
|
234
|
+
if (!steps)
|
|
235
|
+
return null;
|
|
236
|
+
let i = 0;
|
|
237
|
+
while (i < chain.length - 1) {
|
|
238
|
+
const trialChain = chain.filter((_, k) => k !== i);
|
|
239
|
+
const trial = stepsForChain(tree, kids, trialChain);
|
|
240
|
+
if (trial &&
|
|
241
|
+
indexCount(trial) <= indexCount(steps) &&
|
|
242
|
+
matchSelector(tree, { window, path: trial })?.id === nodeId) {
|
|
243
|
+
chain = trialChain;
|
|
244
|
+
steps = trial;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
i++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { window, path: steps };
|
|
251
|
+
}
|
|
252
|
+
/** 选出给模型看的节点:可交互、可见、矩形非空;按文档序编号 e1..eN。 */
|
|
253
|
+
export function selectDisplayNodes(tree, maxNodes) {
|
|
254
|
+
const visible = tree.nodes.slice(1).filter((n) => isInteractive(n) && !n.offscreen && n.rect.w > 0 && n.rect.h > 0);
|
|
255
|
+
const items = visible.slice(0, maxNodes).map((node, i) => ({ ref: `e${i + 1}`, node }));
|
|
256
|
+
return { items, truncated: visible.length > maxNodes };
|
|
257
|
+
}
|
|
258
|
+
/** 物理矩形 → 主屏 norm1000 [x, y, w, h]。 */
|
|
259
|
+
export function rectToNorm(rect, g) {
|
|
260
|
+
const [x, y] = physicalToNorm(rect.x - g.originX, rect.y - g.originY, g.physW, g.physH);
|
|
261
|
+
const [x1, y1] = physicalToNorm(rect.x + rect.w - g.originX, rect.y + rect.h - g.originY, g.physW, g.physH);
|
|
262
|
+
return [x, y, Math.max(0, x1 - x), Math.max(0, y1 - y)];
|
|
263
|
+
}
|
|
264
|
+
/** 矩形中心(物理像素,虚拟桌面坐标)——click_element 的落点。 */
|
|
265
|
+
export function rectCenter(rect) {
|
|
266
|
+
return [Math.round(rect.x + rect.w / 2), Math.round(rect.y + rect.h / 2)];
|
|
267
|
+
}
|
|
268
|
+
const BROWSER_PROCESSES = new Set(['chrome', 'msedge', 'firefox', 'brave', 'opera', 'vivaldi']);
|
|
269
|
+
function clip(s, max) {
|
|
270
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
271
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
272
|
+
}
|
|
273
|
+
/** 节点数低于此值视为稀疏树(游戏、自绘 UI、未开无障碍的 Electron)。 */
|
|
274
|
+
export const SPARSE_TREE_THRESHOLD = 3;
|
|
275
|
+
/** inspect 输出前缀;session/gui-actions.ts 靠它识别台账条目,改动须同步。 */
|
|
276
|
+
export const INSPECT_OUTPUT_PREFIX = 'UI elements of window ';
|
|
277
|
+
export function formatSnapshot(tree, items, opts) {
|
|
278
|
+
const w = tree.window;
|
|
279
|
+
const proc = w.processName ? `${w.processName}.exe` : 'unknown process';
|
|
280
|
+
const lines = [
|
|
281
|
+
`${INSPECT_OUTPUT_PREFIX}${JSON.stringify(clip(w.title, 80))} (${proc}), generation ${opts.generation}, ` +
|
|
282
|
+
`${items.length} nodes (interactive only).`,
|
|
283
|
+
'Coordinates are normalized 0-1000 [x, y, w, h] over the primary screen. Use click_element/set_value with ref; ' +
|
|
284
|
+
'refs expire on the next inspect.',
|
|
285
|
+
];
|
|
286
|
+
const minDepth = items.reduce((m, it) => Math.min(m, it.node.depth), Number.POSITIVE_INFINITY);
|
|
287
|
+
for (const { ref, node } of items) {
|
|
288
|
+
const indent = ' '.repeat(Math.min(6, Math.max(0, node.depth - minDepth)));
|
|
289
|
+
const value = node.value !== undefined ? ` value=${JSON.stringify(clip(node.value, 60))}` : '';
|
|
290
|
+
const rect = rectToNorm(node.rect, opts.geometry);
|
|
291
|
+
const flags = [...node.patterns.filter((p) => ACTIONABLE_PATTERNS.has(p))];
|
|
292
|
+
if (!node.enabled)
|
|
293
|
+
flags.push('disabled');
|
|
294
|
+
if (node.isPassword)
|
|
295
|
+
flags.push('password');
|
|
296
|
+
lines.push(`${indent}${ref} ${node.role} ${JSON.stringify(clip(node.name, 80))}${value} [${rect.join(', ')}]` +
|
|
297
|
+
(flags.length ? ` ${flags.join(' ')}` : ''));
|
|
298
|
+
}
|
|
299
|
+
if (opts.truncated)
|
|
300
|
+
lines.push(`(truncated at ${opts.maxNodes} nodes — narrow scope or use screenshot)`);
|
|
301
|
+
if (tree.truncated)
|
|
302
|
+
lines.push('(element tree walk hit its size/time limit; some elements may be missing)');
|
|
303
|
+
if (items.length < SPARSE_TREE_THRESHOLD) {
|
|
304
|
+
lines.push('UIA tree is sparse for this window; fall back to screenshot + coordinates.');
|
|
305
|
+
}
|
|
306
|
+
if (BROWSER_PROCESSES.has(w.processName.toLowerCase())) {
|
|
307
|
+
lines.push('This is a web browser: for page content prefer the browser tool over UI elements.');
|
|
308
|
+
}
|
|
309
|
+
return lines.join('\n');
|
|
310
|
+
}
|
|
311
|
+
// ── ref 名称登记表(权限层只读)──────────────────────────────────────────
|
|
312
|
+
const refNames = new Map();
|
|
313
|
+
/** inspect 后整体替换:旧 generation 的 ref 立即失效。 */
|
|
314
|
+
export function setElementRefNames(items) {
|
|
315
|
+
refNames.clear();
|
|
316
|
+
for (const { ref, node } of items)
|
|
317
|
+
refNames.set(ref, { role: node.role, name: node.name });
|
|
318
|
+
}
|
|
319
|
+
export function lookupElementRefName(ref) {
|
|
320
|
+
return refNames.get(ref);
|
|
321
|
+
}
|
|
322
|
+
export function clearElementRefNames() {
|
|
323
|
+
refNames.clear();
|
|
324
|
+
}
|