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
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Automation 常驻服务(Windows):computer 工具的「元素之眼」(design-notes/computer-use-rpa.md §2.1)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成进程而不是塞进 input-injector:
|
|
5
|
+
* 1. 元素树遍历可能要几百 ms 到数秒,注入器是串行管道,混在一起会让 click 排在慢 tree 后面;
|
|
6
|
+
* 2. UIAutomationClient 程序集加载是一次性成本,只有真用到元素能力时才该付;
|
|
7
|
+
* 3. 故障隔离:目标应用无响应时 UIA 的跨进程 COM 调用会挂住,超时只杀本进程,不影响注入与抓屏。
|
|
8
|
+
*
|
|
9
|
+
* 协议与 input-injector.ts / screen-capture-service.ts 同构:stdin NDJSON `{id, op, ...}`,
|
|
10
|
+
* stdout NDJSON `{id, ok, detail?, ...}`;IdleGuard 空闲回收;单 op 超时即 failAll 杀进程、下次惰性重建。
|
|
11
|
+
*
|
|
12
|
+
* 只提供两个原语,其余语义(选择器、ref、norm1000)全在 TS 侧(uia-selector.ts,可单测):
|
|
13
|
+
* - tree:遍历窗口元素树(CacheRequest 批量取属性,避免每属性一次跨进程调用);
|
|
14
|
+
* - act:按「hwnd + 子序号路径」重新定位元素,校验 role/name 未变后执行 pattern 动作或取实时矩形。
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { filterEnv } from '../sandbox/index.js';
|
|
18
|
+
import { IdleGuard, envInt } from './idle-guard.js';
|
|
19
|
+
import { parseRawTree } from './uia-selector.js';
|
|
20
|
+
import { parseRawWindows } from './window-list.js';
|
|
21
|
+
/** 元素已消失/已变化(界面重排、窗口关闭)。computer 工具据此提示模型重新 inspect。 */
|
|
22
|
+
export class UiaElementGoneError extends Error {
|
|
23
|
+
}
|
|
24
|
+
/** 目标窗口已关闭。computer 工具据此提示模型重新 list_windows。 */
|
|
25
|
+
export class UiaWindowGoneError extends Error {
|
|
26
|
+
}
|
|
27
|
+
const DEFAULT_TREE_NODES = 800;
|
|
28
|
+
const DEFAULT_TREE_DEPTH = 25;
|
|
29
|
+
/** PowerShell 侧自限 6s,TS 侧进程级超时 10s 兜底(ConvertTo-Json 大树本身也要时间)。 */
|
|
30
|
+
const DEFAULT_TREE_TIME_MS = 6000;
|
|
31
|
+
const TREE_OP_TIMEOUT_MS = 10000;
|
|
32
|
+
const ACT_OP_TIMEOUT_MS = 5000;
|
|
33
|
+
/** windows / focus / capwin:都是单次 Win32 调用,目标应用卡住时 5s 足够判定为无响应。 */
|
|
34
|
+
const WINDOW_OP_TIMEOUT_MS = 5000;
|
|
35
|
+
/** capwin 要建位图 + PrintWindow + PNG 编码,4K 大窗口比单次 Win32 调用慢,放宽到 8s。 */
|
|
36
|
+
const CAPWIN_OP_TIMEOUT_MS = 8000;
|
|
37
|
+
/** PowerShell 响应里的 rect 对象 → UiaRect;字段缺失/非数值一律取 0。 */
|
|
38
|
+
function toRect(raw) {
|
|
39
|
+
const r = (raw && typeof raw === 'object' ? raw : {});
|
|
40
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 0);
|
|
41
|
+
return { x: n(r.x), y: n(r.y), w: Math.max(0, n(r.w)), h: Math.max(0, n(r.h)) };
|
|
42
|
+
}
|
|
43
|
+
const DEFAULT_IDLE_MS = 3 * 60 * 1000;
|
|
44
|
+
/** 名称截断长度:与 PowerShell 侧 Clip-Name 一致(expectName 比较依赖它)。 */
|
|
45
|
+
export const UIA_NAME_MAX = 200;
|
|
46
|
+
const PS_UIA_SCRIPT = [
|
|
47
|
+
'$ErrorActionPreference = "Stop"',
|
|
48
|
+
'[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
|
|
49
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
50
|
+
'Add-Type -TypeDefinition @"',
|
|
51
|
+
'using System;',
|
|
52
|
+
'using System.Runtime.InteropServices;',
|
|
53
|
+
'public class MoUia {',
|
|
54
|
+
' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
|
|
55
|
+
' [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);',
|
|
56
|
+
' [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();',
|
|
57
|
+
// ── 窗口 API(list_windows / focus_window / 窗口截图,design-notes/computer-use-rpa.md §4)──
|
|
58
|
+
// 结构体读写与回调全放 C# 里:PowerShell 对嵌套值类型赋值落在副本上(见 input-injector.ts 的教训)。
|
|
59
|
+
' public struct RECT { public int L; public int T; public int R; public int B; }',
|
|
60
|
+
' public delegate bool EnumProc(IntPtr h, IntPtr l);',
|
|
61
|
+
' [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l);',
|
|
62
|
+
' [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);',
|
|
63
|
+
' [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);',
|
|
64
|
+
' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);',
|
|
65
|
+
' [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder s, int n);',
|
|
66
|
+
' [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);',
|
|
67
|
+
' [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
|
|
68
|
+
' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
|
|
69
|
+
' [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);',
|
|
70
|
+
' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
|
|
71
|
+
' [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
|
|
72
|
+
' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);',
|
|
73
|
+
' [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);',
|
|
74
|
+
' [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach);',
|
|
75
|
+
' [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);',
|
|
76
|
+
' [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags);',
|
|
77
|
+
' [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
|
|
78
|
+
' [DllImport("dwmapi.dll", EntryPoint="DwmGetWindowAttribute")] public static extern int DwmInt(IntPtr h, int attr, out int v, int size);',
|
|
79
|
+
' [DllImport("dwmapi.dll", EntryPoint="DwmGetWindowAttribute")] public static extern int DwmRect(IntPtr h, int attr, out RECT r, int size);',
|
|
80
|
+
' public static System.Collections.Generic.List<IntPtr> Top() {',
|
|
81
|
+
' System.Collections.Generic.List<IntPtr> l = new System.Collections.Generic.List<IntPtr>();',
|
|
82
|
+
' EnumWindows(delegate(IntPtr h, IntPtr p) { if (IsWindowVisible(h)) l.Add(h); return true; }, IntPtr.Zero);',
|
|
83
|
+
' return l;',
|
|
84
|
+
' }',
|
|
85
|
+
' public static string Title(IntPtr h) {',
|
|
86
|
+
' int n = GetWindowTextLength(h);',
|
|
87
|
+
' if (n <= 0) return "";',
|
|
88
|
+
' System.Text.StringBuilder sb = new System.Text.StringBuilder(n + 1);',
|
|
89
|
+
' GetWindowText(h, sb, sb.Capacity);',
|
|
90
|
+
' return sb.ToString();',
|
|
91
|
+
' }',
|
|
92
|
+
' public static uint Pid(IntPtr h) { uint p; GetWindowThreadProcessId(h, out p); return p; }',
|
|
93
|
+
' public static int[] WinRect(IntPtr h) { RECT r; GetWindowRect(h, out r); return new int[] { r.L, r.T, r.R, r.B }; }',
|
|
94
|
+
// 可见边框(DWMWA_EXTENDED_FRAME_BOUNDS=9):GetWindowRect 在 Win10+ 含 ~8px 不可见阴影边,模型看到的是去掉它的矩形。
|
|
95
|
+
' public static int[] FrameRect(IntPtr h) {',
|
|
96
|
+
' RECT r;',
|
|
97
|
+
' if (DwmRect(h, 9, out r, 16) == 0) return new int[] { r.L, r.T, r.R, r.B };',
|
|
98
|
+
' return WinRect(h);',
|
|
99
|
+
' }',
|
|
100
|
+
' public static bool ToolWin(IntPtr h) { return (GetWindowLong(h, -20) & 0x80) != 0; }',
|
|
101
|
+
' public static bool Cloaked(IntPtr h) { int v; return DwmInt(h, 14, out v, 4) == 0 && v != 0; }',
|
|
102
|
+
' public static bool Move(IntPtr h, int x, int y) { return SetWindowPos(h, IntPtr.Zero, x, y, 0, 0, 0x15); }',
|
|
103
|
+
// 前台锁绕过:挂接到当前前台窗口的输入线程再 SetForegroundWindow;仍失败才点一下 Alt 重试
|
|
104
|
+
// (Alt 点按可能激活原前台窗口的菜单栏,所以只作兜底)。
|
|
105
|
+
' public static bool Focus(IntPtr h) {',
|
|
106
|
+
' IntPtr fg = GetForegroundWindow();',
|
|
107
|
+
' uint dummy;',
|
|
108
|
+
' uint fgT = fg == IntPtr.Zero ? 0 : GetWindowThreadProcessId(fg, out dummy);',
|
|
109
|
+
' uint me = GetCurrentThreadId();',
|
|
110
|
+
' bool attached = false;',
|
|
111
|
+
' if (fgT != 0 && fgT != me) attached = AttachThreadInput(me, fgT, true);',
|
|
112
|
+
' try {',
|
|
113
|
+
' BringWindowToTop(h);',
|
|
114
|
+
' SetForegroundWindow(h);',
|
|
115
|
+
' if (GetForegroundWindow() != h) {',
|
|
116
|
+
' keybd_event(0x12, 0, 0, UIntPtr.Zero); keybd_event(0x12, 0, 2, UIntPtr.Zero);',
|
|
117
|
+
' SetForegroundWindow(h);',
|
|
118
|
+
' }',
|
|
119
|
+
' } finally {',
|
|
120
|
+
' if (attached) AttachThreadInput(me, fgT, false);',
|
|
121
|
+
' }',
|
|
122
|
+
' return GetForegroundWindow() == h;',
|
|
123
|
+
' }',
|
|
124
|
+
'}',
|
|
125
|
+
'"@',
|
|
126
|
+
// DPI 感知必须先于 UIA 加载:BoundingRectangle 按调用进程的 awareness 返回坐标,
|
|
127
|
+
// 不声明的话 150% 缩放下矩形是逻辑像素,与 SetCursorPos 的物理像素差一个缩放比。
|
|
128
|
+
'try { if (-not [MoUia]::SetProcessDpiAwarenessContext([IntPtr](-4))) { [void][MoUia]::SetProcessDPIAware() } } catch { }',
|
|
129
|
+
'Add-Type -AssemblyName UIAutomationClient',
|
|
130
|
+
'Add-Type -AssemblyName UIAutomationTypes',
|
|
131
|
+
'$AE = [System.Windows.Automation.AutomationElement]',
|
|
132
|
+
'$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker',
|
|
133
|
+
'$cr = New-Object System.Windows.Automation.CacheRequest',
|
|
134
|
+
'foreach ($p in @($AE::NameProperty, $AE::ControlTypeProperty, $AE::AutomationIdProperty, $AE::ClassNameProperty,',
|
|
135
|
+
' $AE::BoundingRectangleProperty, $AE::IsEnabledProperty, $AE::IsOffscreenProperty, $AE::IsPasswordProperty,',
|
|
136
|
+
' $AE::ProcessIdProperty, $AE::IsInvokePatternAvailableProperty, $AE::IsValuePatternAvailableProperty,',
|
|
137
|
+
' $AE::IsTogglePatternAvailableProperty, $AE::IsSelectionItemPatternAvailableProperty,',
|
|
138
|
+
' $AE::IsExpandCollapsePatternAvailableProperty, $AE::IsScrollPatternAvailableProperty,',
|
|
139
|
+
' [System.Windows.Automation.ValuePattern]::ValueProperty)) { $cr.Add($p) }',
|
|
140
|
+
'$cr.TreeScope = [System.Windows.Automation.TreeScope]::Element',
|
|
141
|
+
`function Clip-Name([string]$s) { if ($null -eq $s) { return "" }; if ($s.Length -gt ${UIA_NAME_MAX}) { return $s.Substring(0, ${UIA_NAME_MAX}) }; return $s }`,
|
|
142
|
+
'function To-Rect($r) {',
|
|
143
|
+
' if ($r.IsEmpty -or [double]::IsInfinity($r.Width) -or [double]::IsInfinity($r.Height)) { return @{ x = 0; y = 0; w = 0; h = 0 } }',
|
|
144
|
+
' return @{ x = [int]$r.X; y = [int]$r.Y; w = [int]$r.Width; h = [int]$r.Height }',
|
|
145
|
+
'}',
|
|
146
|
+
'function Role-Of($ct) { if ($null -eq $ct) { return "Custom" }; return ($ct.ProgrammaticName -replace "^ControlType\\.", "") }',
|
|
147
|
+
'function Node-Info($el, [int]$parent, [int]$depth, [string]$path) {',
|
|
148
|
+
' $c = $el.Cached',
|
|
149
|
+
' $pats = @()',
|
|
150
|
+
' if ($el.GetCachedPropertyValue($AE::IsInvokePatternAvailableProperty)) { $pats += "invoke" }',
|
|
151
|
+
' if ($el.GetCachedPropertyValue($AE::IsValuePatternAvailableProperty)) { $pats += "value" }',
|
|
152
|
+
' if ($el.GetCachedPropertyValue($AE::IsTogglePatternAvailableProperty)) { $pats += "toggle" }',
|
|
153
|
+
' if ($el.GetCachedPropertyValue($AE::IsSelectionItemPatternAvailableProperty)) { $pats += "select" }',
|
|
154
|
+
' if ($el.GetCachedPropertyValue($AE::IsExpandCollapsePatternAvailableProperty)) { $pats += "expand" }',
|
|
155
|
+
' if ($el.GetCachedPropertyValue($AE::IsScrollPatternAvailableProperty)) { $pats += "scroll" }',
|
|
156
|
+
' $isPwd = [bool]$c.IsPassword',
|
|
157
|
+
' $n = @{ parent = $parent; depth = $depth; path = $path; role = (Role-Of $c.ControlType); name = (Clip-Name ([string]$c.Name));',
|
|
158
|
+
' automationId = [string]$c.AutomationId; className = [string]$c.ClassName; rect = (To-Rect $c.BoundingRectangle);',
|
|
159
|
+
' enabled = [bool]$c.IsEnabled; offscreen = [bool]$c.IsOffscreen; isPassword = $isPwd; patterns = $pats }',
|
|
160
|
+
// 密码框永不读 Value(TS 侧 parseRawTree 还有一道双保险)。
|
|
161
|
+
' if (-not $isPwd -and ($pats -contains "value")) {',
|
|
162
|
+
' $v = $el.GetCachedPropertyValue([System.Windows.Automation.ValuePattern]::ValueProperty)',
|
|
163
|
+
' if ($v -is [string] -and $v.Length -gt 0) { if ($v.Length -gt 60) { $v = $v.Substring(0, 60) }; $n.value = $v }',
|
|
164
|
+
' }',
|
|
165
|
+
' return $n',
|
|
166
|
+
'}',
|
|
167
|
+
'function Resolve-Hwnd($req) {',
|
|
168
|
+
' if ($req.hwnd) { return [IntPtr]::new([long]$req.hwnd) }',
|
|
169
|
+
' $h = [MoUia]::GetForegroundWindow()',
|
|
170
|
+
' if ($h -eq [IntPtr]::Zero) { throw "no foreground window" }',
|
|
171
|
+
' return $h',
|
|
172
|
+
'}',
|
|
173
|
+
'while ($null -ne ($line = [Console]::In.ReadLine())) {',
|
|
174
|
+
' $resp = @{ id = 0; ok = $false; detail = "" }',
|
|
175
|
+
' try {',
|
|
176
|
+
' $req = $line | ConvertFrom-Json',
|
|
177
|
+
' $resp.id = $req.id',
|
|
178
|
+
' switch ($req.op) {',
|
|
179
|
+
' "ping" { $resp.ok = $true }',
|
|
180
|
+
' "tree" {',
|
|
181
|
+
' $hwnd = Resolve-Hwnd $req',
|
|
182
|
+
' $root = $AE::FromHandle($hwnd).GetUpdatedCache($cr)',
|
|
183
|
+
' $sw = [Diagnostics.Stopwatch]::StartNew()',
|
|
184
|
+
' $maxN = [int]$req.maxNodes; $maxD = [int]$req.maxDepth; $maxMs = [int]$req.timeMs',
|
|
185
|
+
' $nodes = New-Object System.Collections.ArrayList',
|
|
186
|
+
' [void]$nodes.Add((Node-Info $root (-1) 0 ""))',
|
|
187
|
+
' $queue = New-Object System.Collections.Queue',
|
|
188
|
+
' $queue.Enqueue(@($root, 0, 0, ""))',
|
|
189
|
+
' $trunc = $false',
|
|
190
|
+
// BFS:保证 parent 下标恒小于子节点下标(parseRawTree 的约定);path 记 ControlView 子序号。
|
|
191
|
+
' while ($queue.Count -gt 0 -and -not $trunc) {',
|
|
192
|
+
' $item = $queue.Dequeue(); $el = $item[0]; $id = [int]$item[1]; $d = [int]$item[2]; $pp = [string]$item[3]',
|
|
193
|
+
' if ($d -ge $maxD) { continue }',
|
|
194
|
+
' $k = 0',
|
|
195
|
+
' try { $ch = $walker.GetFirstChild($el, $cr) } catch { $ch = $null }',
|
|
196
|
+
' while ($null -ne $ch) {',
|
|
197
|
+
' if ($nodes.Count -ge $maxN -or $sw.ElapsedMilliseconds -gt $maxMs) { $trunc = $true; break }',
|
|
198
|
+
' $cp = if ($pp) { "$pp.$k" } else { "$k" }',
|
|
199
|
+
' try {',
|
|
200
|
+
' [void]$nodes.Add((Node-Info $ch $id ($d + 1) $cp))',
|
|
201
|
+
' $queue.Enqueue(@($ch, ($nodes.Count - 1), ($d + 1), $cp))',
|
|
202
|
+
' } catch { }',
|
|
203
|
+
' $k++',
|
|
204
|
+
' try { $ch = $walker.GetNextSibling($ch, $cr) } catch { $ch = $null }',
|
|
205
|
+
' }',
|
|
206
|
+
' }',
|
|
207
|
+
' $procId = [int]$root.Cached.ProcessId',
|
|
208
|
+
' $pname = ""',
|
|
209
|
+
' try { $pname = (Get-Process -Id $procId).ProcessName } catch { }',
|
|
210
|
+
' $resp.window = @{ hwnd = $hwnd.ToInt64(); title = (Clip-Name ([string]$root.Cached.Name)); processName = $pname; pid = $procId; rect = $nodes[0].rect }',
|
|
211
|
+
' $resp.nodes = $nodes',
|
|
212
|
+
' $resp.truncated = $trunc',
|
|
213
|
+
' $resp.elapsedMs = $sw.ElapsedMilliseconds',
|
|
214
|
+
' $resp.ok = $true',
|
|
215
|
+
' }',
|
|
216
|
+
' "act" {',
|
|
217
|
+
' $el = $AE::FromHandle([IntPtr]::new([long]$req.hwnd))',
|
|
218
|
+
' if ($req.path) {',
|
|
219
|
+
' foreach ($seg in ([string]$req.path).Split(".")) {',
|
|
220
|
+
' $ch = $walker.GetFirstChild($el)',
|
|
221
|
+
' for ($i = 0; $i -lt [int]$seg -and $null -ne $ch; $i++) { $ch = $walker.GetNextSibling($ch) }',
|
|
222
|
+
' if ($null -eq $ch) { throw "ELEMENT_GONE: element path no longer exists" }',
|
|
223
|
+
' $el = $ch',
|
|
224
|
+
' }',
|
|
225
|
+
' }',
|
|
226
|
+
' $role = Role-Of $el.Current.ControlType',
|
|
227
|
+
' if ($req.expectRole -and $role -ne [string]$req.expectRole) { throw "ELEMENT_GONE: expected $($req.expectRole), found $role" }',
|
|
228
|
+
' if ($null -ne $req.expectName -and (Clip-Name ([string]$el.Current.Name)) -ne [string]$req.expectName) { throw "ELEMENT_GONE: element name changed" }',
|
|
229
|
+
' switch ([string]$req.kind) {',
|
|
230
|
+
' "invoke" { $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() }',
|
|
231
|
+
' "toggle" { $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Toggle() }',
|
|
232
|
+
' "select" { $el.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() }',
|
|
233
|
+
' "expand" {',
|
|
234
|
+
' $ec = $el.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)',
|
|
235
|
+
' if ($ec.Current.ExpandCollapseState -eq [System.Windows.Automation.ExpandCollapseState]::Expanded) { $ec.Collapse() } else { $ec.Expand() }',
|
|
236
|
+
' }',
|
|
237
|
+
' "setvalue" {',
|
|
238
|
+
' if ($el.Current.IsPassword) { throw "refusing to set value of a password field" }',
|
|
239
|
+
' $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)',
|
|
240
|
+
' if ($vp.Current.IsReadOnly) { throw "element is read-only" }',
|
|
241
|
+
' $vp.SetValue([string]$req.text)',
|
|
242
|
+
' }',
|
|
243
|
+
' "focus" { $el.SetFocus() }',
|
|
244
|
+
' "rect" { }',
|
|
245
|
+
' default { throw "unknown act kind: $($req.kind)" }',
|
|
246
|
+
' }',
|
|
247
|
+
' $resp.rect = To-Rect $el.Current.BoundingRectangle',
|
|
248
|
+
' $resp.ok = $true',
|
|
249
|
+
' }',
|
|
250
|
+
// ── 窗口 op(design-notes/computer-use-rpa.md §4)──
|
|
251
|
+
// windows:枚举顶层可见窗口(z-order 前→后),过滤/编号/norm 换算在 TS 侧(window-list.ts)。
|
|
252
|
+
' "windows" {',
|
|
253
|
+
' $fg = [MoUia]::GetForegroundWindow()',
|
|
254
|
+
' $list = New-Object System.Collections.ArrayList',
|
|
255
|
+
' $pcache = @{}',
|
|
256
|
+
' foreach ($h in [MoUia]::Top()) {',
|
|
257
|
+
' $t = [MoUia]::Title($h)',
|
|
258
|
+
' if (-not $t) { continue }',
|
|
259
|
+
' $wpid = [int][MoUia]::Pid($h)',
|
|
260
|
+
' if (-not $pcache.ContainsKey($wpid)) { $pn = ""; try { $pn = (Get-Process -Id $wpid).ProcessName } catch { }; $pcache[$wpid] = $pn }',
|
|
261
|
+
' $r = [MoUia]::FrameRect($h)',
|
|
262
|
+
' [void]$list.Add(@{ hwnd = $h.ToInt64(); title = (Clip-Name $t); processName = $pcache[$wpid]; pid = $wpid;',
|
|
263
|
+
' rect = @{ x = $r[0]; y = $r[1]; w = ($r[2] - $r[0]); h = ($r[3] - $r[1]) };',
|
|
264
|
+
' minimized = [MoUia]::IsIconic($h); foreground = ($h -eq $fg); toolWindow = [MoUia]::ToolWin($h); cloaked = [MoUia]::Cloaked($h) })',
|
|
265
|
+
' }',
|
|
266
|
+
' $resp.windows = $list',
|
|
267
|
+
' $resp.ok = $true',
|
|
268
|
+
' }',
|
|
269
|
+
// focus:还原最小化 → (可选)挪到主屏 → 绕前台锁置前。foreground=false 表示系统仍拒绝了切换。
|
|
270
|
+
' "focus" {',
|
|
271
|
+
' $h = [IntPtr]::new([long]$req.hwnd)',
|
|
272
|
+
' if (-not [MoUia]::IsWindow($h)) { throw "WINDOW_GONE: the window no longer exists" }',
|
|
273
|
+
' if ([MoUia]::IsIconic($h)) { [void][MoUia]::ShowWindow($h, 9) }',
|
|
274
|
+
' if ($req.moveToPrimary) { [void][MoUia]::Move($h, [int]$req.x, [int]$req.y) }',
|
|
275
|
+
' $okf = [MoUia]::Focus($h)',
|
|
276
|
+
' Start-Sleep -Milliseconds 80',
|
|
277
|
+
' $r = [MoUia]::FrameRect($h)',
|
|
278
|
+
' $resp.foreground = $okf',
|
|
279
|
+
' $resp.rect = @{ x = $r[0]; y = $r[1]; w = ($r[2] - $r[0]); h = ($r[3] - $r[1]) }',
|
|
280
|
+
' $resp.ok = $true',
|
|
281
|
+
' }',
|
|
282
|
+
// capwin:PrintWindow(PW_RENDERFULLCONTENT) 截窗口(可被遮挡),裁掉不可见阴影边;失败回落屏幕裁剪。
|
|
283
|
+
' "capwin" {',
|
|
284
|
+
' Add-Type -AssemblyName System.Drawing',
|
|
285
|
+
' $h = [IntPtr]::new([long]$req.hwnd)',
|
|
286
|
+
' if (-not [MoUia]::IsWindow($h)) { throw "WINDOW_GONE: the window no longer exists" }',
|
|
287
|
+
' if ([MoUia]::IsIconic($h)) { throw "the window is minimized; call focus_window first" }',
|
|
288
|
+
' $wr = [MoUia]::WinRect($h); $fr = [MoUia]::FrameRect($h)',
|
|
289
|
+
' $ww = $wr[2] - $wr[0]; $wh = $wr[3] - $wr[1]',
|
|
290
|
+
' if ($ww -le 0 -or $wh -le 0) { throw "the window has an empty rectangle" }',
|
|
291
|
+
' $full = New-Object System.Drawing.Bitmap($ww, $wh)',
|
|
292
|
+
' $g = [System.Drawing.Graphics]::FromImage($full)',
|
|
293
|
+
' $hdc = $g.GetHdc()',
|
|
294
|
+
' $okp = $false',
|
|
295
|
+
' try { $okp = [MoUia]::PrintWindow($h, $hdc, 2) } finally { $g.ReleaseHdc($hdc); $g.Dispose() }',
|
|
296
|
+
' $cx = [Math]::Max(0, $fr[0] - $wr[0]); $cy = [Math]::Max(0, $fr[1] - $wr[1])',
|
|
297
|
+
' $cw = [Math]::Min($ww - $cx, $fr[2] - $fr[0]); $ch = [Math]::Min($wh - $cy, $fr[3] - $fr[1])',
|
|
298
|
+
' if ($cw -le 0 -or $ch -le 0) { $cx = 0; $cy = 0; $cw = $ww; $ch = $wh }',
|
|
299
|
+
' $method = "printwindow"',
|
|
300
|
+
' if ($okp) {',
|
|
301
|
+
' $out = $full.Clone((New-Object System.Drawing.Rectangle($cx, $cy, $cw, $ch)), $full.PixelFormat)',
|
|
302
|
+
' } else {',
|
|
303
|
+
' $method = "screen"',
|
|
304
|
+
' $out = New-Object System.Drawing.Bitmap($cw, $ch)',
|
|
305
|
+
' $g2 = [System.Drawing.Graphics]::FromImage($out)',
|
|
306
|
+
' $g2.CopyFromScreen(($wr[0] + $cx), ($wr[1] + $cy), 0, 0, (New-Object System.Drawing.Size($cw, $ch)))',
|
|
307
|
+
' $g2.Dispose()',
|
|
308
|
+
' }',
|
|
309
|
+
' $full.Dispose()',
|
|
310
|
+
' $out.Save([string]$req.path, [System.Drawing.Imaging.ImageFormat]::Png)',
|
|
311
|
+
' $out.Dispose()',
|
|
312
|
+
' $resp.method = $method',
|
|
313
|
+
' $resp.width = $cw; $resp.height = $ch',
|
|
314
|
+
' $resp.rect = @{ x = ($wr[0] + $cx); y = ($wr[1] + $cy); w = $cw; h = $ch }',
|
|
315
|
+
' $resp.ok = $true',
|
|
316
|
+
' }',
|
|
317
|
+
' default { $resp.detail = "unknown op: $($req.op)" }',
|
|
318
|
+
' }',
|
|
319
|
+
' } catch {',
|
|
320
|
+
' $resp.ok = $false',
|
|
321
|
+
' $resp.detail = $_.Exception.Message',
|
|
322
|
+
' }',
|
|
323
|
+
' [Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress -Depth 6))',
|
|
324
|
+
' [Console]::Out.Flush()',
|
|
325
|
+
'}',
|
|
326
|
+
].join('\n');
|
|
327
|
+
/** UIPI / COM 拒绝访问的典型报文:给模型一句能行动的解释,而不是裸 COM 异常。 */
|
|
328
|
+
function explainUiaError(detail) {
|
|
329
|
+
if (/access.?denied|0x80070005|E_ACCESSDENIED/i.test(detail)) {
|
|
330
|
+
return (`${detail} (the target window likely runs elevated; a non-admin mocode cannot inspect or ` +
|
|
331
|
+
'operate it — this is a Windows UIPI restriction)');
|
|
332
|
+
}
|
|
333
|
+
return detail;
|
|
334
|
+
}
|
|
335
|
+
class PowerShellUiaService {
|
|
336
|
+
child = null;
|
|
337
|
+
nextId = 1;
|
|
338
|
+
pending = new Map();
|
|
339
|
+
stdoutBuf = '';
|
|
340
|
+
lastError = '';
|
|
341
|
+
idle;
|
|
342
|
+
constructor(idleMs) {
|
|
343
|
+
this.idle = new IdleGuard(idleMs, () => {
|
|
344
|
+
void this.dispose();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
ensureProcess() {
|
|
348
|
+
if (this.child && this.child.exitCode === null && !this.child.killed)
|
|
349
|
+
return this.child;
|
|
350
|
+
this.child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', PS_UIA_SCRIPT], { env: filterEnv(process.env), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
351
|
+
this.stdoutBuf = '';
|
|
352
|
+
this.lastError = '';
|
|
353
|
+
this.child.stdout.on('data', (chunk) => this.onStdout(chunk.toString('utf8')));
|
|
354
|
+
this.child.stderr.on('data', (chunk) => {
|
|
355
|
+
this.lastError = (this.lastError + chunk.toString('utf8')).slice(-2000);
|
|
356
|
+
});
|
|
357
|
+
this.child.on('exit', () => this.failAll(new Error(`UIA process exited${this.lastError ? `: ${this.lastError.trim()}` : ''}`)));
|
|
358
|
+
this.child.on('error', (err) => this.failAll(err));
|
|
359
|
+
return this.child;
|
|
360
|
+
}
|
|
361
|
+
onStdout(text) {
|
|
362
|
+
this.stdoutBuf += text;
|
|
363
|
+
let idx;
|
|
364
|
+
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
365
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
366
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
367
|
+
if (!line)
|
|
368
|
+
continue;
|
|
369
|
+
let msg;
|
|
370
|
+
try {
|
|
371
|
+
msg = JSON.parse(line);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
continue; // 非协议行(Add-Type 警告等),忽略
|
|
375
|
+
}
|
|
376
|
+
const id = typeof msg.id === 'number' ? msg.id : -1;
|
|
377
|
+
const op = this.pending.get(id);
|
|
378
|
+
if (!op)
|
|
379
|
+
continue;
|
|
380
|
+
this.pending.delete(id);
|
|
381
|
+
clearTimeout(op.timer);
|
|
382
|
+
op.resolve(msg);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
failAll(error) {
|
|
386
|
+
for (const [, op] of this.pending) {
|
|
387
|
+
clearTimeout(op.timer);
|
|
388
|
+
op.reject(error);
|
|
389
|
+
}
|
|
390
|
+
this.pending.clear();
|
|
391
|
+
if (this.child) {
|
|
392
|
+
try {
|
|
393
|
+
this.child.kill();
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* 进程已退出 */
|
|
397
|
+
}
|
|
398
|
+
this.child = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
call(op, params, timeoutMs, signal) {
|
|
402
|
+
const child = this.ensureProcess();
|
|
403
|
+
const id = this.nextId++;
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
// UIA 挂起(目标应用无响应)是常态故障:超时直接杀进程,下次惰性重建。
|
|
406
|
+
const timer = setTimeout(() => {
|
|
407
|
+
this.pending.delete(id);
|
|
408
|
+
this.failAll(new Error(`UIA op "${op}" timed out after ${timeoutMs}ms`));
|
|
409
|
+
reject(new Error(`UIA op "${op}" timed out after ${timeoutMs}ms (target application may be unresponsive)`));
|
|
410
|
+
}, timeoutMs);
|
|
411
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
412
|
+
const onAbort = () => {
|
|
413
|
+
this.pending.delete(id);
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
this.failAll(new Error('aborted'));
|
|
416
|
+
reject(new Error('UIA operation aborted.'));
|
|
417
|
+
};
|
|
418
|
+
if (signal) {
|
|
419
|
+
if (signal.aborted)
|
|
420
|
+
return onAbort();
|
|
421
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`, 'utf8');
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
this.pending.delete(id);
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
430
|
+
}
|
|
431
|
+
}).then((resp) => {
|
|
432
|
+
this.idle.touch();
|
|
433
|
+
if (resp.ok !== true) {
|
|
434
|
+
const detail = String(resp.detail ?? 'unknown error');
|
|
435
|
+
if (detail.includes('ELEMENT_GONE'))
|
|
436
|
+
throw new UiaElementGoneError(detail.replace(/^.*ELEMENT_GONE:\s*/, ''));
|
|
437
|
+
if (detail.includes('WINDOW_GONE'))
|
|
438
|
+
throw new UiaWindowGoneError(detail.replace(/^.*WINDOW_GONE:\s*/, ''));
|
|
439
|
+
throw new Error(`UIA op "${op}" failed: ${explainUiaError(detail)}`);
|
|
440
|
+
}
|
|
441
|
+
return resp;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async tree(req, signal) {
|
|
445
|
+
const resp = await this.call('tree', {
|
|
446
|
+
hwnd: req.hwnd ?? 0,
|
|
447
|
+
maxNodes: req.maxNodes ?? DEFAULT_TREE_NODES,
|
|
448
|
+
maxDepth: req.maxDepth ?? DEFAULT_TREE_DEPTH,
|
|
449
|
+
timeMs: req.timeMs ?? DEFAULT_TREE_TIME_MS,
|
|
450
|
+
}, TREE_OP_TIMEOUT_MS, signal);
|
|
451
|
+
return parseRawTree(resp);
|
|
452
|
+
}
|
|
453
|
+
async act(req, signal) {
|
|
454
|
+
const resp = await this.call('act', {
|
|
455
|
+
hwnd: req.hwnd,
|
|
456
|
+
path: req.path,
|
|
457
|
+
kind: req.kind,
|
|
458
|
+
text: req.text ?? '',
|
|
459
|
+
expectRole: req.expectRole ?? '',
|
|
460
|
+
expectName: req.expectName ?? null,
|
|
461
|
+
}, ACT_OP_TIMEOUT_MS, signal);
|
|
462
|
+
const r = (resp.rect && typeof resp.rect === 'object' ? resp.rect : {});
|
|
463
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 0);
|
|
464
|
+
return { rect: { x: n(r.x), y: n(r.y), w: Math.max(0, n(r.w)), h: Math.max(0, n(r.h)) } };
|
|
465
|
+
}
|
|
466
|
+
async windows(signal) {
|
|
467
|
+
const resp = await this.call('windows', {}, WINDOW_OP_TIMEOUT_MS, signal);
|
|
468
|
+
return parseRawWindows(resp);
|
|
469
|
+
}
|
|
470
|
+
async focusWindow(req, signal) {
|
|
471
|
+
const resp = await this.call('focus', {
|
|
472
|
+
hwnd: req.hwnd,
|
|
473
|
+
moveToPrimary: req.moveTo !== undefined,
|
|
474
|
+
x: req.moveTo?.x ?? 0,
|
|
475
|
+
y: req.moveTo?.y ?? 0,
|
|
476
|
+
}, WINDOW_OP_TIMEOUT_MS, signal);
|
|
477
|
+
return { foreground: resp.foreground === true, rect: toRect(resp.rect) };
|
|
478
|
+
}
|
|
479
|
+
async captureWindow(req, signal) {
|
|
480
|
+
const resp = await this.call('capwin', { hwnd: req.hwnd, path: req.path }, CAPWIN_OP_TIMEOUT_MS, signal);
|
|
481
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 0);
|
|
482
|
+
return {
|
|
483
|
+
method: resp.method === 'screen' ? 'screen' : 'printwindow',
|
|
484
|
+
width: n(resp.width),
|
|
485
|
+
height: n(resp.height),
|
|
486
|
+
rect: toRect(resp.rect),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async dispose() {
|
|
490
|
+
this.idle.stop();
|
|
491
|
+
this.failAll(new Error('UIA service disposed'));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
let singleton = null;
|
|
495
|
+
/** 非 Windows 显式抛错(与 createInputInjector 一致的诚实降级)。 */
|
|
496
|
+
export function getUiaService() {
|
|
497
|
+
if (process.platform !== 'win32') {
|
|
498
|
+
throw new Error(`UI Automation element inspection is only supported on Windows (current platform: ${process.platform}); ` +
|
|
499
|
+
'use screenshot + coordinates instead.');
|
|
500
|
+
}
|
|
501
|
+
singleton ??= new PowerShellUiaService(envInt('MOCODE_CU_UIA_IDLE_MS', DEFAULT_IDLE_MS));
|
|
502
|
+
return singleton;
|
|
503
|
+
}
|
|
504
|
+
/** 关闭常驻 UIA 进程(REPL 退出 / /cu off 时调用)。 */
|
|
505
|
+
export async function disposeUiaService() {
|
|
506
|
+
if (singleton) {
|
|
507
|
+
await singleton.dispose();
|
|
508
|
+
singleton = null;
|
|
509
|
+
}
|
|
510
|
+
}
|