feat: 新增本地UI模型AI拾取与识别实验台

This commit is contained in:
yu 2026-11-02 11:51:55 +08:00
parent ee60435e2b
commit e956ec9055
27 changed files with 1848 additions and 525 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ test-output/
web-capability-audit.txt
*.traineddata
temp/

View File

@ -23,6 +23,8 @@ export default {
extraResources: [
{ from: path.join(root, "resources", "catalog.json"), to: "catalog.json" },
{ from: path.join(root, "resources", "excluded.json"), to: "excluded.json" },
{ from: path.join(root, "resources", "desktop-picker.ps1"), to: "desktop-picker.ps1" },
{ from: path.join(root, "resources", "models"), to: "models" },
{ from: path.join(root, "resources", "fixture"), to: "fixture" }
],
asar: true,

View File

@ -12,7 +12,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
input: { index: resolve("src/main/index.ts") }
input: { index: resolve("src/main/index.ts"), "local-vision/ui-detect-worker": resolve("src/main/local-vision/ui-detect-worker.ts") }
}
}
},

5
package-lock.json generated
View File

@ -22,6 +22,7 @@
"mssql": "^12.7.13",
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16",
"onnxruntime-node": "^1.31.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"pg": "^8.23.1",
@ -2362,7 +2363,7 @@
},
"node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
"integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==",
"cpu": [
"riscv64"
@ -8452,7 +8453,7 @@
},
"node_modules/onnxruntime-node": {
"version": "1.31.1",
"resolved": "https://registry.npmmirror.com/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",
"integrity": "sha512-ijnSghbDHVWaQ2nDPmVccPlCHI8kH1Mx1aJ4a/qzRP16bBcVs4E3EqZQKZCkhWnbqN1tuQWxagimFtDpbPYYQw==",
"hasInstallScript": true,
"license": "MIT",

View File

@ -31,6 +31,7 @@
"mssql": "^12.7.13",
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16",
"onnxruntime-node": "^1.31.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"pg": "^8.23.1",

View File

@ -150,10 +150,15 @@ function Set-ClickThrough($form) {
}
function Set-HighlightBox([int]$x, [int]$y, [int]$w, [int]$h) {
$hl.Location = New-Object System.Drawing.Point(($x - 2), ($y - 2))
$hl.Size = New-Object System.Drawing.Size(($w + 4), ($h + 4))
$hl.Show()
Set-ClickThrough $hl
$bounds = New-Object System.Drawing.Rectangle(($x - 2), ($y - 2), ($w + 4), ($h + 4))
if (-not $hl.Visible) {
$hl.Bounds = $bounds
$hl.Show()
Set-ClickThrough $hl
} elseif ($hl.Bounds.X -ne $bounds.X -or $hl.Bounds.Y -ne $bounds.Y -or $hl.Bounds.Width -ne $bounds.Width -or $hl.Bounds.Height -ne $bounds.Height) {
# 不重设隐藏;同一元素内移动时不重复改窗口,避免 WinForms 分层窗体重绘闪烁
$hl.Bounds = $bounds
}
}
function Hide-Highlight { $hl.Hide() }
function Get-DeepestAtPoint([double]$px, [double]$py) {
@ -187,6 +192,19 @@ $prevSpace = $false
$prevPgUp = $false
$prevPgDn = $false
$prevF2 = $false
$cacheX = [int]::MinValue
$cacheY = [int]::MinValue
$cacheEl = $null
$cacheCtrlName = ""
$cacheName = ""
$cachePaneX = 0
$cachePaneY = 0
$cachePaneW = 0
$cachePaneH = 0
$cacheAt = [DateTime]::MinValue
$lastQueryAt = [DateTime]::MinValue
$queryGapMs = 45
$cacheTtlMs = 1800
Write-PickLog "loop start"
while ($true) {
try {
@ -199,17 +217,49 @@ while ($true) {
if ($stage -eq "hover") {
$ptX = [System.Windows.Forms.Cursor]::Position.X
$ptY = [System.Windows.Forms.Cursor]::Position.Y
$el = Get-DeepestAtPoint $ptX $ptY
$now = [DateTime]::UtcNow
$samePoint = ($ptX -eq $cacheX -and $ptY -eq $cacheY)
$insideCache = $cacheEl -ne $null -and $ptX -ge $cachePaneX -and $ptX -lt ($cachePaneX + $cachePaneW) -and $ptY -ge $cachePaneY -and $ptY -lt ($cachePaneY + $cachePaneH)
$cacheAlive = ($null -ne $cacheEl -and ($now - $cacheAt).TotalMilliseconds -le $cacheTtlMs)
# 鼠标还在当前元素矩形内部时不丢弃缓存;UIA 查询完成后才切换,避免移动过程先隐藏再显示
if (-not $samePoint -and -not $insideCache) { $cacheEl = $null }
if (-not $cacheAlive -and ($now - $lastQueryAt).TotalMilliseconds -ge $queryGapMs) {
$queryX = $ptX
$queryY = $ptY
$lastQueryAt = [DateTime]::UtcNow
try { $queried = Get-DeepestAtPoint $queryX $queryY } catch { $queried = $null; Write-PickLog ("QUERY ERR: " + $_.Exception.Message) }
$afterX = [System.Windows.Forms.Cursor]::Position.X
$afterY = [System.Windows.Forms.Cursor]::Position.Y
if ($afterX -ne $queryX -or $afterY -ne $queryY) {
$queried = $null
Write-PickLog "query stale: cursor moved"
}
if ($null -ne $queried) {
$qc = $queried.Current
$qr = $qc.BoundingRectangle
$cacheEl = $queried
$cacheX = $queryX
$cacheY = $queryY
$cacheAt = [DateTime]::UtcNow
$cacheCtrlName = $qc.ControlType.ProgrammaticName.Replace("ControlType.", "")
$cacheName = [string]$qc.Name
$cachePaneX = [int]$qr.X
$cachePaneY = [int]$qr.Y
$cachePaneW = [int]$qr.Width
$cachePaneH = [int]$qr.Height
} else {
$cacheEl = $null
$cacheCtrlName = ""
$cacheName = ""
}
}
$el = $cacheEl
if ($el) {
$c = $el.Current
$ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "")
$r = $c.BoundingRectangle
$paneX = [int]$r.X; $paneY = [int]$r.Y; $paneW = [int]$r.Width; $paneH = [int]$r.Height
Set-HighlightBox $paneX $paneY $paneW $paneH
$aiHint = if ($ctrlName -eq "Pane") { " [F2]AI识别" } else { "" }
$infoLabel.Text = "名称: " + $c.Name + " 类型: " + $ctrlName + " [空格]锁定" + $aiHint + " [Esc]取消"
Set-HighlightBox $cachePaneX $cachePaneY $cachePaneW $cachePaneH
$aiHint = if ($cacheCtrlName -eq "Pane") { " [F2]AI识别" } else { "" }
$infoLabel.Text = "名称: " + $cacheName + " 类型: " + $cacheCtrlName + " [空格]锁定" + $aiHint + " [Esc]取消"
} else { Hide-Highlight }
if ($f2 -and -not $prevF2 -and $el -and $ctrlName -eq "Pane") {
if ($f2 -and -not $prevF2 -and $el -and $cacheCtrlName -eq "Pane") {
Write-PickLog "f2 ai"
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$cur = $el
@ -218,7 +268,7 @@ while ($true) {
$topTitle = [string]$cur.Current.Name
$cur = $walker.GetParent($cur)
}
$result = @{ aiRequested = $true; cursorX = $ptX; cursorY = $ptY; window = $topTitle; name = [string]$c.Name; controlType = $ctrlName; paneX = $paneX; paneY = $paneY; paneW = $paneW; paneH = $paneH }
$result = @{ aiRequested = $true; cursorX = $ptX; cursorY = $ptY; window = $topTitle; name = $cacheName; controlType = $cacheCtrlName; paneX = $cachePaneX; paneY = $cachePaneY; paneW = $cachePaneW; paneH = $cachePaneH }
break
}
if ($space -and -not $prevSpace -and $el) {
@ -226,7 +276,7 @@ while ($true) {
$stage = "locked"
$chain = New-Object System.Collections.ArrayList
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$cur = $el
$cur = $cacheEl
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
$c = $cur.Current
$r = $c.BoundingRectangle

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"do_normalize": false,
"do_rescale": true,
"do_resize": true,
"feature_extractor_type": "ImageFeatureExtractor",
"resample": 2,
"rescale_factor": 0.00392156862745098,
"size_divisor": 16,
"size": {
"longest_edge": 640
}
}

View File

@ -115,9 +115,13 @@ if (!fs.existsSync(path.join(builderUnpacked, "RPA Studio.exe"))) {
}
const packagedCatalogPath = path.join(builderUnpacked, "resources", "catalog.json");
const packagedFixturePath = path.join(builderUnpacked, "resources", "fixture", "index.html");
const packagedPickerPath = path.join(builderUnpacked, "resources", "desktop-picker.ps1");
if (!fs.existsSync(packagedCatalogPath) || !fs.existsSync(packagedFixturePath)) {
throw new Error("打包产物缺少运行资源:resources/catalog.json 或 resources/fixture/index.html");
}
if (!fs.existsSync(packagedPickerPath)) {
throw new Error("打包产物缺少桌面拾取脚本:resources/desktop-picker.ps1 未随包发布");
}
const packagedCatalog = JSON.parse(fs.readFileSync(packagedCatalogPath, "utf8"));
if (!Array.isArray(packagedCatalog.components) || packagedCatalog.components.length === 0) {
throw new Error("打包产物 resources/catalog.json 无效:组件目录为空");
@ -216,6 +220,9 @@ if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) {
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "catalog.json"))) {
throw new Error("NSIS 安装包冒烟失败:catalog.json 未安装");
}
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "desktop-picker.ps1"))) {
throw new Error("NSIS 安装包冒烟失败:desktop-picker.ps1 未安装");
}
await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed");
removeTree(installSmokeRoot);

View File

@ -233,6 +233,18 @@ export class BrowserSession implements BrowserLike {
return !name || accessibleName.includes(name);
}) || null;
}
if (spec.kind === 'css') {
const segments = String(spec.value).split('>>>').map((item) => item.trim()).filter(Boolean);
let root = document;
for (let index = 0; index < segments.length; index += 1) {
const found = root.querySelector(segments[index]);
if (!found) return null;
if (index === segments.length - 1) return found;
root = found.shadowRoot;
if (!root) return null;
}
return null;
}
return document.querySelector(spec.value);
};
const el = find();
@ -269,33 +281,46 @@ export class BrowserSession implements BrowserLike {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
};
const cssPath = (node) => {
if (node.id) {
const candidate = '#' + cssEscape(node.id);
if (isUnique(candidate)) return candidate;
}
const testId = node.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate;
}
const parts = [];
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase();
if (current.id) {
part += '#' + cssEscape(current.id);
parts.unshift(part);
break;
}
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return parts.join(' > ');
return trees.join(' >>> ');
};
const describe = (node) => ({
kind: 'css',
@ -1143,33 +1168,46 @@ export class BrowserSession implements BrowserLike {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
};
const cssPath = (node) => {
if (node.id) {
const candidate = '#' + cssEscape(node.id);
if (isUnique(candidate)) return candidate;
}
const testId = node.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate;
}
const parts = [];
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase();
if (current.id) {
part += '#' + cssEscape(current.id);
parts.unshift(part);
break;
}
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return parts.join(' > ');
return trees.join(' >>> ');
};
const describe = (node) => {
const rect = node.getBoundingClientRect();
@ -1200,9 +1238,33 @@ export class BrowserSession implements BrowserLike {
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 30) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
].join(',');
const closestInteractive = (start) => {
let node = start;
while (node && node.nodeType === 1) {
try { if (node.matches(interactiveSelector)) return node; } catch {}
if (node.parentElement) {
node = node.parentElement;
continue;
}
const root = node.getRootNode();
node = root instanceof ShadowRoot ? root.host : null;
}
return start;
};
const firstElement = (event) => {
for (const item of event.composedPath()) if (item instanceof Element) return item;
return event.target instanceof Element ? event.target : null;
let deepest = null;
for (const item of event.composedPath()) {
if (item instanceof Element) {
deepest = item;
break;
}
}
if (!deepest && event.target instanceof Element) deepest = event.target;
return closestInteractive(deepest);
};
const move = (event) => {
if (finished) return;

View File

@ -34,16 +34,29 @@ export function requireAiConfig(options: { vision?: boolean } = {}): ResolvedAiC
return config;
}
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number; vision?: boolean } = {}): Promise<string> {
export interface ChatCompletionOptions {
temperature?: number;
vision?: boolean;
/** 交互式请求(用户正在等待,如 AI 拾取):默认短超时 45s、最多 2 次,超时不重试 */
interactive?: boolean;
/** 单次请求超时毫秒数;显式指定后覆盖交互式/后台默认值 */
timeoutMs?: number;
/** 最大尝试次数;显式指定后覆盖交互式/后台默认值 */
maxAttempts?: number;
}
export async function chatCompletion(messages: ChatMessage[], options: ChatCompletionOptions = {}): Promise<string> {
const config = requireAiConfig({ vision: options.vision });
const base = config.baseUrl.replace(/\/+$/, "");
const maxAttempts = 3;
const interactive = options.interactive === true;
const timeoutMs = Math.max(1000, Math.round(options.timeoutMs ?? (interactive ? 45000 : 170000)));
const maxAttempts = Math.max(1, Math.round(options.maxAttempts ?? (interactive ? 2 : 3)));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response: Response;
try {
response = await fetch(base + "/chat/completions", {
signal: AbortSignal.timeout(170000),
signal: AbortSignal.timeout(timeoutMs),
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
body: JSON.stringify({
@ -53,6 +66,10 @@ export async function chatCompletion(messages: ChatMessage[], options: { tempera
})
});
} catch (error) {
// 超时(AbortSignal.timeout)不重试:重发只会成倍拉长等待
if ((error as { name?: string }).name === "TimeoutError") {
throw new RuntimeError("ai_request_timeout", `AI 请求超时(${timeoutMs}ms),请检查网络或稍后重试`);
}
if (attempt === maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, 1200 * attempt));
continue;

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
const detectMock = vi.hoisted(() => vi.fn(async () => ({
texts: [{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] }]
})));
vi.mock("./desktop", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
import { getHandler } from "../engine/registry";
import "./cv-vision";
describe("CV vision capabilities", () => {
beforeEach(() => {
powershellMock.mockClear();
detectMock.mockClear();
});
it.each(["CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick"])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy();
});
it("rejects an empty CV selector", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n1", componentId: "CV_GetText" } as any, {}, {} as any))
.rejects.toThrow("CV 目标为空");
});
it("locates OCR text and clicks its center with offset", async () => {
const handler = getHandler("CV_MouseClick")!;
await expect(handler({ log: vi.fn() } as any, { id: "n1", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
offsetX: 5,
offsetY: 2
}, {} as any)).resolves.toBe(true);
expect(detectMock).toHaveBeenCalledTimes(1);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(15, 7)");
expect(script).toContain("mouse_event(0x0002");
});
it("returns matched OCR text", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n2", componentId: "CV_GetText" } as any, {
cvSelector: "保存"
}, {} as any)).resolves.toBe("确认保存");
expect(powershellMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,5 +1,5 @@
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell } from "./desktop";
import fs from "node:fs";
import os from "node:os";
@ -57,4 +57,95 @@ export async function locateTextOnScreen(target: string, nodeId: string): Promis
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
return { target: keyword, text: hit.text, cx, cy };
}
}
function screenUser32(): string {
return [
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);' -Name RpaCvInput -Namespace RpaCv | Out-Null;",
"try { [RpaCv.RpaCvInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}"
].join("\n");
}
function coordinateScript(x: number, y: number, action: "hover" | "click" | "doubleClick" | "paste", value = ""): string {
const lines = [
screenUser32(),
"if (-not [RpaCv.RpaCvInput]::SetCursorPos(" + x + ", " + y + ")) { throw 'cursor_move_failed' }",
"Start-Sleep -Milliseconds 100"
];
if (action !== "hover") {
lines.push(
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
"Start-Sleep -Milliseconds 40",
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
);
}
if (action === "doubleClick") {
lines.push(
"Start-Sleep -Milliseconds 70",
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
"Start-Sleep -Milliseconds 40",
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
);
}
if (action === "paste") {
lines.push(
"Add-Type -AssemblyName System.Windows.Forms",
"Start-Sleep -Milliseconds 220",
"$saved = $null",
"try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}",
"try { [System.Windows.Forms.Clipboard]::SetText(" + JSON.stringify(value) + ") } catch { throw 'clipboard_failed' }",
"Start-Sleep -Milliseconds 80",
"[System.Windows.Forms.SendKeys]::SendWait('^a')",
"Start-Sleep -Milliseconds 60",
"[System.Windows.Forms.SendKeys]::SendWait('^v')",
"Start-Sleep -Milliseconds 280",
"try { if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) } else { [System.Windows.Forms.Clipboard]::Clear() } } catch {}"
);
}
lines.push("Write-Output (@{ ok = $true; x = " + x + "; y = " + y + " } | ConvertTo-Json -Compress)");
return lines.join("\n");
}
async function locateFor(args: Record<string, unknown>, node: { id: string; componentId: string }) {
const target = cvTarget(args, node.id);
const located = await locateTextOnScreen(target, node.id);
const offsetX = Number(args.offsetX ?? args.offset_x ?? 0) || 0;
const offsetY = Number(args.offsetY ?? args.offset_y ?? 0) || 0;
return { located, x: Math.round(located.cx + offsetX), y: Math.round(located.cy + offsetY) };
}
const getTextGround: CapabilityHandler = async (ctx, node, args) => {
const target = cvTarget(args, node.id);
const result = await locateTextOnScreen(target, node.id);
ctx.log("CV_GetText found: " + result.text, "info", node.id);
return result.text;
};
const setTextGround: CapabilityHandler = async (ctx, node, args) => {
const value = String(args.value ?? args.text ?? "");
if (!value) throw new RuntimeError("validation_error", "CV 文本输入内容为空", { nodeId: node.id, componentId: node.componentId });
const { x, y } = await locateFor(args, node);
await powershell(coordinateScript(x, y, "paste", value), 30000);
ctx.log("CV_SetText input at " + x + "," + y, "info", node.id);
return true;
};
const mouseHoverGround: CapabilityHandler = async (ctx, node, args) => {
const { x, y } = await locateFor(args, node);
await powershell(coordinateScript(x, y, "hover"), 15000);
ctx.log("CV_MouseHover hover at " + x + "," + y, "info", node.id);
return true;
};
const mouseClickGround: CapabilityHandler = async (ctx, node, args) => {
const { x, y } = await locateFor(args, node);
const clickway = String(args.clickway ?? "0");
const action = clickway === "1" || /double/i.test(clickway) ? "doubleClick" as const : "click" as const;
await powershell(coordinateScript(x, y, action), 15000);
ctx.log("CV_MouseClick " + action + " at " + x + "," + y, "info", node.id);
return true;
};
register("CV_GetText", getTextGround);
register("CV_SetText", setTextGround);
register("CV_MouseHover", mouseHoverGround);
register("CV_MouseClick", mouseClickGround);

View File

@ -1,7 +1,16 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
vi.mock("./desktop", () => ({
powershell: powershellMock,
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
}));
import { getHandler } from "../engine/registry";
import { parseUiaSelector } from "./desktop-uia";
describe("desktop UIA ground capabilities", () => {
it.each([
"Element_ClickGround",
@ -21,7 +30,10 @@ describe("desktop UIA ground capabilities", () => {
"Element_MouseToGround",
"Element_ScrollIntoViewGround",
"Element_ChildrenGround",
"Element_DesktopChildren"
"Element_DesktopChildren",
"Element_RestoreWindowGround",
"Element_ActivateWindowGround",
"Element_MoveWindowGround"
])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy();
});
@ -57,3 +69,61 @@ describe("desktop UIA ground capabilities", () => {
expect(() => parseUiaSelector("screen=1,2,3")).toThrow();
});
});
describe("desktop UIA selector compatibility", () => {
beforeEach(() => {
powershellMock.mockClear();
powershellMock.mockImplementation(async () => '{"clicked":true,"value":"cached"}');
});
it("maps compound .NET ControlType names explicitly and preserves raw input", () => {
expect(parseUiaSelector("type=combobox")).toMatchObject({ type: "ComboBox", typeRaw: "combobox" });
expect(parseUiaSelector("type=listitem")).toMatchObject({ type: "ListItem", typeRaw: "listitem" });
expect(parseUiaSelector("type=datagrid")).toMatchObject({ type: "DataGrid", typeRaw: "datagrid" });
});
it("parses a stable ancestor path", () => {
const selector = parseUiaSelector("window=App;path=Pane>Edit[name=用户名;index=2]>Button[name=确定]");
expect(selector.path).toEqual([
{ type: "Pane" },
{ type: "Edit", name: "用户名", index: 2 },
{ type: "Button", name: "确定" }
]);
});
const context = { log: vi.fn() } as any;
const node = { id: "n1", componentId: "Element_ClickGround" } as any;
it("dispatches screen selectors to coordinate execution instead of UIA lookup", async () => {
const args = {
selector: "screen=100,200,320,80;ai=1",
clickway: "1"
};
await expect(getHandler("Element_ClickGround")!(context, node, args, {} as any)).resolves.toEqual({ clicked: true, value: "cached" });
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
expect(script).toContain("RpaScreenInput");
expect(script).toContain("SetCursorPos(260, 240)");
expect(script).not.toContain("Find-RpaWindow");
});
it("dispatches screen setValue to coordinate paste", async () => {
await getHandler("Element_SetValueGround")!(context, node, {
selector: "screen=10,20,30,40",
value: "hello"
}, {} as any);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
expect(script).toContain("screen_coordinate_paste");
expect(script).toContain("hello");
expect(script).not.toContain("Find-RpaWindow");
});
it("treats a picked screen rectangle as present for exists/wait", async () => {
await expect(getHandler("Element_ExistsGround")!(context, node, {
selector: "screen=1,2,3,4"
}, {} as any)).resolves.toBe(true);
await expect(getHandler("Element_WaitElementGround")!(context, node, {
selector: "screen=1,2,3,4"
}, {} as any)).resolves.toBe(true);
expect(powershellMock).not.toHaveBeenCalled();
});
});

View File

@ -8,12 +8,124 @@ interface UiaSelector {
window?: string;
name?: string;
id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number;
}
/** ControlType 显式映射:type= 值 → .NET ControlType 字段名;复合名称不得用首字母大写推导 */
const UIA_CONTROL_TYPE_MAP: Record<string, string> = {
button: "Button",
calendar: "Calendar",
checkbox: "CheckBox",
combo: "ComboBox",
combobox: "ComboBox",
custom: "Custom",
datagrid: "DataGrid",
dataitem: "DataItem",
document: "Document",
edit: "Edit",
group: "Group",
header: "Header",
headeritem: "HeaderItem",
hyperlink: "Hyperlink",
image: "Image",
list: "List",
listbox: "List",
listitem: "ListItem",
menu: "Menu",
menubar: "MenuBar",
menuitem: "MenuItem",
pane: "Pane",
progressbar: "ProgressBar",
radio: "RadioButton",
radiobutton: "RadioButton",
scrollbar: "ScrollBar",
separator: "Separator",
slider: "Slider",
spinner: "Spinner",
splitbutton: "SplitButton",
statusbar: "StatusBar",
tab: "Tab",
tabitem: "TabItem",
table: "Table",
text: "Text",
thumb: "Thumb",
titlebar: "TitleBar",
toolbar: "ToolBar",
tooltip: "ToolTip",
tree: "Tree",
treeitem: "TreeItem",
window: "Window"
};
function normalizeUiaControlType(raw: string): string {
const key = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
return UIA_CONTROL_TYPE_MAP[key] ?? raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
}
/** 解析 path= 段:Pane>Edit[name=用户名;index=2],段间用 > 分隔,段内 [prop=value;...] */
function parseUiaSelectorPath(value: string): UiaSelector["path"] {
// 按括号外的 > 分段,避免 path=Edit[name=a>b] 这类属性值被错误拆开
const rawSegments: string[] = [];
let buffer = "";
let bracketDepth = 0;
for (const char of value) {
if (char === "[") bracketDepth += 1;
else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (char === ">" && bracketDepth === 0) {
rawSegments.push(buffer);
buffer = "";
} else {
buffer += char;
}
}
rawSegments.push(buffer);
const segments = rawSegments.map(item => item.trim()).filter(Boolean);
if (!segments.length) return undefined;
const path: NonNullable<UiaSelector["path"]> = [];
for (const segment of segments) {
const match = segment.match(/^([A-Za-z][A-Za-z0-9_]*)?\s*(?:\[(.*)\])?$/);
if (!match) {
throw new RuntimeError("validation_error", `UIA 路径段无效:${segment}`, { recommendation: "使用 Type[name=xx;id=yy;class=zz;index=1] > 下一级" });
}
const entry: NonNullable<UiaSelector["path"]>[number] = {};
if (match[1]) entry.type = normalizeUiaControlType(match[1]);
const props = match[2];
if (props) {
for (const pair of props.split(/[;,,]/).map(item => item.trim()).filter(Boolean)) {
const eq = pair.indexOf("=");
if (eq <= 0) {
throw new RuntimeError("validation_error", `UIA 路径属性无效:${pair}`, { recommendation: "使用 name= / id= / class= / index=" });
}
const key = pair.slice(0, eq).trim().toLowerCase();
const val = pair.slice(eq + 1).trim();
if (key === "name" || key === "n") entry.name = val;
else if (key === "id" || key === "automationid") entry.id = val;
else if (key === "class" || key === "classname") entry.className = val;
else if (key === "type" || key === "controltype") entry.type = normalizeUiaControlType(val);
else if (key === "index") {
const n = Number(val);
if (!Number.isFinite(n) || n < 1) throw new RuntimeError("validation_error", `UIA 路径 index 无效:${val}`);
entry.index = Math.floor(n);
}
else throw new RuntimeError("validation_error", `UIA 路径属性不支持:${key}`);
}
}
if (!entry.type && !entry.name && !entry.id && !entry.className) {
throw new RuntimeError("validation_error", `UIA 路径段缺少定位条件:${segment}`, { recommendation: "每段至少提供 type/name/id/class 之一" });
}
path.push(entry);
}
return path;
}
export function parseUiaSelector(raw: unknown): UiaSelector {
let text = "";
if (raw && typeof raw === "object") {
@ -33,7 +145,22 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
}
if (text.startsWith("uia:")) text = text.slice(4);
const selector: UiaSelector = { index: 1 };
for (const part of text.split(";")) {
// path 段内的 [name=a;b] 不应被顶层 ; 拆开
const selectorParts: string[] = [];
let partBuffer = "";
let selectorBracketDepth = 0;
for (const char of text) {
if (char === "[") selectorBracketDepth += 1;
else if (char === "]") selectorBracketDepth = Math.max(0, selectorBracketDepth - 1);
if (char === ";" && selectorBracketDepth === 0) {
selectorParts.push(partBuffer);
partBuffer = "";
} else {
partBuffer += char;
}
}
selectorParts.push(partBuffer);
for (const part of selectorParts) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const key = part.slice(0, eq).trim().toLowerCase();
@ -42,7 +169,10 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
if (key === "window" || key === "win") selector.window = value;
else if (key === "name" || key === "n") selector.name = value;
else if (key === "id" || key === "automationid") selector.id = value;
else if (key === "type" || key === "controltype") selector.type = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
else if (key === "type" || key === "controltype") {
selector.typeRaw = value;
selector.type = normalizeUiaControlType(value);
}
else if (key === "class" || key === "classname") selector.className = value;
else if (key === "screen") {
const nums = value.split(/[,,]/).map(item => Number(item.trim()));
@ -55,8 +185,11 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
const n = Number(value);
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
}
else if (key === "path" || key === "ancestors" || key === "ancestorpath") {
selector.path = parseUiaSelectorPath(value);
}
}
if (!selector.name && !selector.id && !selector.className && !selector.type && !selector.screen) {
if (!selector.name && !selector.id && !selector.className && !selector.type && !selector.screen && !selector.path) {
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
}
return selector;
@ -69,6 +202,7 @@ const UIA_USING = [
const FIND_HELPER = `
function Find-RpaWindow([string]$winName) {
# 只读查找:绝不移动/还原/缩放窗口;还原、激活、移动是独立显式动作(Restore/Activate/Move-RpaWindow)
$root = [System.Windows.Automation.AutomationElement]::RootElement
if (-not $winName) { return $root }
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
@ -78,41 +212,88 @@ function Find-RpaWindow([string]$winName) {
$script:hitHwnd = [IntPtr]::Zero
$cb = [WinRpa.RpaUser32Click+EnumProc]{ param($h, $l)
$sb = New-Object System.Text.StringBuilder 256
[WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256) | Out-Null
[void][WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256)
if ($sb.ToString() -eq $winName) { $script:hitHwnd = $h; return $false }
return $true
}
[WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null
[void][WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero)
if ($script:hitHwnd -ne [IntPtr]::Zero) {
[WinRpa.RpaUser32Click]::ShowWindow($script:hitHwnd, 9) | Out-Null
Start-Sleep -Milliseconds 500
$sw2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
$sh2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
$w2 = [Math]::Min(1400, $sw2 - 120)
$h2 = [Math]::Min(820, $sh2 - 120)
[WinRpa.RpaUser32Click]::SetWindowPos($script:hitHwnd, [IntPtr]::Zero, 60, 60, $w2, $h2, 0x0040) | Out-Null
Start-Sleep -Milliseconds 600
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if ($win) { return $win }
try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {}
}
throw "window_not_found:$winName"
}
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index) {
function Restore-RpaWindow($win) {
# 显式动作:还原窗口(最小化/最大化状态恢复),不用于查询
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
[void][WinRpa.RpaUser32Click]::ShowWindow($hwnd, 9)
Start-Sleep -Milliseconds 300
return $hwnd
}
function Activate-RpaWindow($win) {
# 显式动作:把窗口带到前台
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
$fgHwnd = [WinRpa.RpaUser32Click]::GetForegroundWindow()
if ($fgHwnd -ne $hwnd) {
$fgPid = [uint32]0
$fgThread = [WinRpa.RpaUser32Click]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
$thisThread = [WinRpa.RpaUser32Click]::GetCurrentThreadId()
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $true)
[void][WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd)
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $false)
}
Start-Sleep -Milliseconds 200
return $hwnd
}
function Move-RpaWindow($win, [int]$x, [int]$y, [int]$w, [int]$h) {
# 显式动作:移动/缩放窗口;w/h<=0 时使用安全默认尺寸
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
$sw = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
$sh = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
if ($w -le 0) { $w = [Math]::Min(1400, $sw - 120) }
if ($h -le 0) { $h = [Math]::Min(820, $sh - 120) }
[void][WinRpa.RpaUser32Click]::SetWindowPos($hwnd, [IntPtr]::Zero, $x, $y, $w, $h, 0x0040)
Start-Sleep -Milliseconds 300
return $hwnd
}
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index, [switch]$directChild) {
$conds = @()
if ($name) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) }
if ($id) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $id) }
if ($className) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty, $className) }
if ($type) {
try { $field = [System.Windows.Automation.ControlType].GetField($type); if ($field) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $field.GetValue($null)) } } catch {}
if ($ct) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct) }
# 不区分大小写匹配 .NET ControlType 字段,兼容 ComboBox/ListItem/DataGrid 等复合名称
try {
$ctField = [System.Windows.Automation.ControlType].GetFields([System.Reflection.BindingFlags]"Public,Static") | Where-Object { $_.Name -ieq $type } | Select-Object -First 1
if ($ctField) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctField.GetValue($null)) }
} catch {}
}
if ($conds.Count -eq 0) { throw "no_condition" }
$final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) }
$all = $parent.FindAll([System.Windows.Automation.TreeScope]::Descendants, $final)
$scope = if ($directChild) { [System.Windows.Automation.TreeScope]::Children } else { [System.Windows.Automation.TreeScope]::Descendants }
$all = $parent.FindAll($scope, $final)
if ($all.Count -eq 0) { return $null }
$pick = [Math]::Min([Math]::Max($index, 1), $all.Count)
return $all[$pick - 1]
}
function Find-RpaElementByPath($parent, [string]$pathJsonBase64) {
# 逐段走直接子级,用稳定属性(id/type/class 优先)解析窗口→目标祖先路径
$segments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pathJsonBase64)) | ConvertFrom-Json
$cur = $parent
foreach ($seg in @($segments)) {
$ix = 1
try { if ($seg.index) { $ix = [int]$seg.index } } catch {}
$next = Find-RpaElement $cur $seg.name $seg.id $seg.type $seg.className $ix -directChild
if ($null -eq $next) { return $null }
$cur = $next
}
return $cur
}
function Add-RpaUser32 {
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int cx, int cy, uint flags); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp); public delegate bool EnumProc(IntPtr hWnd, IntPtr lp); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder sb, int max);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null
}
@ -129,6 +310,21 @@ function target(args: Record<string, unknown>, node: { id: string; componentId:
return { selector: parseUiaSelector(raw), args, node };
}
/** 生成“解析窗口 + 解析元素(路径优先,其次平铺属性)”的 PowerShell 语句,结果写入 $el */
function elementResolveLines(s: UiaSelector): string {
const parentLine = "$parent = Find-RpaWindow " + psString(s.window ?? "");
if (s.path?.length) {
const pathB64 = Buffer.from(JSON.stringify(s.path), "utf8").toString("base64");
return parentLine + "\n$el = Find-RpaElementByPath $parent '" + pathB64 + "'";
}
return parentLine
+ "\n$el = Find-RpaElement $parent " + psString(s.name ?? "")
+ " " + psString(s.id ?? "")
+ " " + psString(s.type ?? "")
+ " " + psString(s.className ?? "")
+ " " + s.index;
}
const SCREEN_USER32 = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);' -Name RpaScreenInput -Namespace RpaScreen | Out-Null; try { [RpaScreen.RpaScreenInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}";
function screenCenter(selector: UiaSelector): { x: number; y: number; w: number; h: number } {
@ -204,8 +400,8 @@ $deadline = [DateTime]::UtcNow.AddMilliseconds(${Math.max(0, Math.round(timeoutM
$found = $null
while ($null -eq $found) {
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$found = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
${elementResolveLines(s)}
$found = $el
} catch {
$err = $_.Exception.Message
if ($err -like "window_not_found:*") { $found = $null }
@ -239,8 +435,7 @@ function clickScript(t: UiaTarget): string {
${FIND_HELPER}
$el = $null
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
${elementResolveLines(s)}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$rect = $el.Current.BoundingRectangle
@ -335,10 +530,7 @@ export function setValueScript(t: UiaTarget, value: string): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
${elementResolveLines(s)}
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = ${psString(value)}
$method = "value_pattern"
@ -372,10 +564,7 @@ function getValueScript(t: UiaTarget): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
${elementResolveLines(s)}
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = $el.Current.Name
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
@ -426,29 +615,37 @@ function requireFound(result: Record<string, unknown> | null, t: UiaTarget, acti
const clickGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
if (t.selector.screen) return runScript(clickScreenScript(t), t);
return runWithImplicitWait(t, "click", clickScript(t), runScript, args);
};
const setValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const value = String(args.value ?? args.text ?? "");
if (t.selector.screen) return runScript(setValueScreenScript(t, value), t);
return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args);
};
const existsGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
if (t.selector.screen) return true;
const found = await findElement(t, 1000, 150);
return found !== null;
};
const getValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
if (t.selector.screen) {
const result = await runScript(getValueScreenScript(t), t);
return requireFound(result, t, "getValue").value ?? "";
}
const result = await runWithImplicitWait(t, "getValue", getValueScript(t), runScript, args);
return result.value ?? "";
};
const waitElementGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
if (t.selector.screen) return true;
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
const found = await findElement(t, timeout, gap);
@ -459,6 +656,39 @@ register("Element_ClickGround", clickGround);
register("Element_SetValueGround", setValueGround);
register("Element_ExistsGround", existsGround);
register("Element_GetValueGround", getValueGround);
const restoreWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const lines = ["$hwnd = Restore-RpaWindow $el"];
if ([args.x, args.y, args.width, args.height].some((value) => value !== undefined && value !== null && String(value).trim() !== "")) {
const x = Math.round(Number(args.x ?? 60)) || 0;
const y = Math.round(Number(args.y ?? 60)) || 0;
const width = Math.round(Number(args.width ?? 0)) || 0;
const height = Math.round(Number(args.height ?? 0)) || 0;
lines.push("$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height);
}
return runWithImplicitWait(t, "restoreWindow", propertyValue([...lines, "Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
};
const activateWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "activateWindow", propertyValue([
"$hwnd = Activate-RpaWindow $el",
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const moveWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const x = Math.round(Number(args.x ?? 60)) || 0;
const y = Math.round(Number(args.y ?? 60)) || 0;
const width = Math.round(Number(args.width ?? 0)) || 0;
const height = Math.round(Number(args.height ?? 0)) || 0;
return runWithImplicitWait(t, "moveWindow", propertyValue([
"$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height,
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
register("Element_WaitElementGround", waitElementGround);
@ -486,8 +716,7 @@ function propertyValue(scriptLines: string[], t: UiaTarget): string {
${FIND_HELPER}
$el = $null
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
${elementResolveLines(s)}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
${scriptLines.join("\n")}`;
@ -709,3 +938,6 @@ register("Element_MouseToGround", mouseToGround);
register("Element_ScrollIntoViewGround", scrollIntoViewGround);
register("Element_ChildrenGround", childrenGroundHandler(false));
register("Element_DesktopChildren", childrenGroundHandler(true));
register("Element_RestoreWindowGround", restoreWindowGround);
register("Element_ActivateWindowGround", activateWindowGround);
register("Element_MoveWindowGround", moveWindowGround);

View File

@ -24,6 +24,7 @@ import "./word";
import "./word-extended";
import "./message";
import "./vision";
import "./cv-vision";
import "./extended";
import "./system-extended";
import "./office-file-extended";

View File

@ -3,9 +3,9 @@ import { promisify } from "node:util";
import fs from "node:fs";
import path from "node:path";
import { app, BrowserWindow, screen } from "electron";
import { app, BrowserWindow, desktopCapturer, globalShortcut, screen } from "electron";
import { RuntimeError } from "./engine/types";
import { chatCompletion, requireAiConfig } from "./capabilities/ai-service";
import { detectUiElements, localUiModelPath } from "./local-vision/ui-detect-service";
const run = promisify(execFile);
@ -58,6 +58,8 @@ interface UiaPickOutput {
}
interface AiCandidate {
/** 候选稳定身份:覆盖层排序后仍按 ID 回填,禁止用排序后的数组下标当身份 */
candidateId: string;
x: number;
y: number;
width: number;
@ -92,12 +94,10 @@ function safeSelectorText(value: string | undefined): string {
return String(value || "").replace(/[;\r\n]+/g, " ").trim();
}
export async function pickDesktopElement(options: { ai?: boolean } = {}): Promise<DesktopPickResult> {
if (options.ai) requireAiConfig({ vision: true });
export async function pickDesktopElement(): Promise<DesktopPickResult> {
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
for (const w of appWindows) w.minimize();
try {
if (options.ai) return await runAiPick();
const { stdout } = await run(
"powershell.exe",
["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath()],
@ -109,7 +109,9 @@ export async function pickDesktopElement(options: { ai?: boolean } = {}): Promis
.map(value => Number(value))
.every(value => Number.isFinite(value)) && Number(parsed.paneW) > 4 && Number(parsed.paneH) > 4;
if (parsed.aiRequested && paneAiReady) return await runPaneAiPick(parsed);
if (parsed.aiRequested) return await runAiPick(parsed);
if (parsed.aiRequested) {
throw new RuntimeError("ai_pane_invalid", "AI 兜底识别仅支持包含有效区域的 Pane");
}
const parts: string[] = [];
if (parsed.window) parts.push("window=" + parsed.window);
if (parsed.name) parts.push("name=" + parsed.name);
@ -125,68 +127,121 @@ export async function pickDesktopElement(options: { ai?: boolean } = {}): Promis
function paneAiOverlayHtml(): string {
return `<!DOCTYPE html><html><head><meta charset='utf-8'><style>
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;cursor:default;font-family:'Microsoft YaHei',Arial,sans-serif}
#frame{position:absolute;inset:0;display:none;border:1px solid rgba(56,189,248,.30);animation:framePulse 1.9s ease-in-out infinite;z-index:4}#frame i{position:absolute;width:30px;height:30px;border:3px solid #38bdf8;filter:drop-shadow(0 0 7px rgba(56,189,248,.85))}#frame i:nth-child(1){left:2px;top:2px;border-right:0;border-bottom:0;border-radius:10px 0 0 0}#frame i:nth-child(2){right:2px;top:2px;border-left:0;border-bottom:0;border-radius:0 10px 0 0}#frame i:nth-child(3){left:2px;bottom:2px;border-right:0;border-top:0;border-radius:0 0 0 10px}#frame i:nth-child(4){right:2px;bottom:2px;border-left:0;border-top:0;border-radius:0 0 10px 0}
#scan{position:absolute;left:0;right:0;top:-20%;height:19%;display:none;background:linear-gradient(to bottom,rgba(56,189,248,0),rgba(56,189,248,.06) 48%,rgba(125,211,252,.20) 82%,rgba(224,242,254,.16));z-index:5;animation:scan 1.65s linear infinite;will-change:top}#scan b{position:absolute;left:3%;right:3%;bottom:-2px;height:3px;border-radius:999px;background:linear-gradient(90deg,rgba(56,189,248,0),#7dd3fc 14%,#fff 50%,#7dd3fc 86%,rgba(56,189,248,0));box-shadow:0 0 10px #38bdf8,0 0 28px rgba(56,189,248,.78),0 0 54px rgba(56,189,248,.38)}#scan b:after{content:'';position:absolute;left:0;right:0;top:2px;height:9px;background:linear-gradient(to bottom,rgba(56,189,248,.18),rgba(56,189,248,0))}
#tip{position:absolute;left:50%;top:18px;transform:translateX(-50%);display:none;padding:8px 15px;border-radius:999px;color:#f8fafc;background:rgba(2,20,40,.78);border:1px solid rgba(125,211,252,.42);box-shadow:0 8px 28px rgba(0,0,0,.28),inset 0 1px 0 rgba(255,255,255,.09);font-size:13px;line-height:1;white-space:nowrap;z-index:2147483647}#tip:before{content:'';display:inline-block;width:7px;height:7px;margin-right:8px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;vertical-align:1px}body.scanning #tip:before{animation:tipPulse 1.1s ease-in-out infinite}
.candidate{position:absolute;border:1px solid rgba(255,255,255,.28);background:transparent;cursor:pointer;box-sizing:border-box}
.candidate:hover,.candidate.active{border:3px solid #ff2d2d;box-shadow:0 0 0 1px rgba(255,255,255,.45);z-index:2147483646}
.badge{position:absolute;display:none;padding:3px 7px;border-radius:5px;color:#fff;background:rgba(190,18,18,.95);border:1px solid rgba(255,220,220,.82);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.28);z-index:2147483647}.badge.active{display:block}
@keyframes scan{from{top:-20%}to{top:100%}}@keyframes framePulse{0%,100%{border-color:rgba(56,189,248,.24)}50%{border-color:rgba(56,189,248,.58)}}@keyframes tipPulse{0%,100%{opacity:.45}50%{opacity:1}}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;font-family:'Microsoft YaHei',Arial,sans-serif}
#veil{position:absolute;inset:0;background:rgba(2,10,28,.035);opacity:0;transition:opacity .18s ease;z-index:3;pointer-events:none}
body.scanning #veil{opacity:1}
#paneFrame{position:absolute;inset:0;border:2px solid #ff3b30;box-shadow:0 0 0 1px rgba(255,255,255,.34),0 0 22px rgba(255,59,48,.24);z-index:4;pointer-events:none}
#aiFrame{position:absolute;inset:6px;border:1px solid rgba(56,189,248,.26);border-radius:12px;animation:aiPulse 2s ease-in-out infinite;z-index:5;pointer-events:none}
#aiFrame i{position:absolute;width:22px;height:22px;border:2px solid #38bdf8;filter:drop-shadow(0 0 6px rgba(56,189,248,.72))}
#aiFrame i:nth-child(1){left:-1px;top:-1px;border-right:0;border-bottom:0;border-radius:10px 0 0 0}
#aiFrame i:nth-child(2){right:-1px;top:-1px;border-left:0;border-bottom:0;border-radius:0 10px 0 0}
#aiFrame i:nth-child(3){left:-1px;bottom:-1px;border-right:0;border-top:0;border-radius:0 0 0 10px}
#aiFrame i:nth-child(4){right:-1px;bottom:-1px;border-left:0;border-top:0;border-radius:0 0 10px 0}
#scan{position:absolute;left:0;right:0;top:0;height:1px;display:none;background:linear-gradient(90deg,rgba(56,189,248,0),rgba(125,211,252,.72) 16%,rgba(255,255,255,.94) 50%,rgba(125,211,252,.72) 84%,rgba(56,189,248,0));box-shadow:0 0 10px rgba(56,189,248,.78);animation:scan 1.35s cubic-bezier(.4,0,.6,1) infinite;z-index:6;pointer-events:none}
#scan:before{content:'';position:absolute;left:0;right:0;top:1px;height:58px;background:linear-gradient(to bottom,rgba(56,189,248,.13),rgba(56,189,248,0))}
#tip{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:none;min-height:30px;padding:0 13px;border-radius:16px;color:#f8fafc;background:rgba(4,18,34,.84);border:1px solid rgba(125,211,252,.34);box-shadow:0 8px 24px rgba(0,0,0,.24),inset 0 1px 0 rgba(255,255,255,.06);font-size:12.5px;line-height:28px;white-space:nowrap;z-index:2147483647;pointer-events:none}
#tip:before{content:'';display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;vertical-align:2px}
body.scanning #tip:before{animation:tipPulse 1.15s ease-in-out infinite}
body.debug .candidate{border:1px solid rgba(255,59,48,.74)}
.candidate{position:absolute;background:transparent;cursor:pointer;box-sizing:border-box;z-index:100}
.candidate{border:3px solid transparent}.candidate.active{border-color:#ff3b30;box-shadow:0 0 0 1px rgba(255,255,255,.38);z-index:101}
.badge{position:absolute;display:none;padding:3px 7px;border-radius:5px;color:#fff;background:rgba(190,18,18,.94);border:1px solid rgba(255,220,220,.78);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.24);z-index:2147483647}
.badge.active{display:block;z-index:102}
@keyframes scan{from{top:0}to{top:100%}}
@keyframes aiPulse{0%,100%{border-color:rgba(56,189,248,.18)}50%{border-color:rgba(56,189,248,.46)}}
@keyframes tipPulse{0%,100%{opacity:.4}50%{opacity:1}}
</style></head><body>
<div id=frame><i></i><i></i><i></i><i></i></div><div id=scan><b></b></div><div id=tip>AI 识别中…</div>
<div id=veil></div><div id=paneFrame></div><div id=aiFrame><i></i><i></i><i></i><i></i></div><div id=scan></div><div id=tip>AI 识别中…</div>
<script>
var paneW=1,paneH=1;
function startScan(label){paneW=window.__paneW||document.documentElement.clientWidth;paneH=window.__paneH||document.documentElement.clientHeight;document.getElementById('frame').style.display='block';document.getElementById('scan').style.display='block';document.getElementById('tip').style.display='block';document.getElementById('tip').textContent=label||'AI 识别中…';document.body.classList.add('scanning')}
var paneW=1,paneH=1,lastMouse=null,lastCid=null;
function showStatus(label){document.getElementById('tip').style.display='block';document.getElementById('tip').textContent=label||'AI 识别中…';}
function startScan(label){
paneW=window.__paneW||document.documentElement.clientWidth;paneH=window.__paneH||document.documentElement.clientHeight;
document.body.classList.add('scanning');
document.getElementById('scan').style.display='block';
document.getElementById('aiFrame').style.display='block';
showStatus(label||'AI 识别中…');
}
function clearActive(){
if(!lastCid)return;
Array.prototype.forEach.call(document.querySelectorAll('.candidate.active,.badge.active'),function(n){n.classList.remove('active')});
lastCid=null;window.__hoverCid=null;window.__hoverCandidate=null;
if(window.__candidatesReady)logHoverState('clear');
}
function activateBox(box){
var cid=box.dataset.cid;
if(lastCid===cid)return;
clearActive();
var index=Number(box.dataset.index),label=document.getElementById('badge-'+index);
box.classList.add('active');if(label)label.classList.add('active');
lastCid=cid;window.__hoverCid=cid;window.__hoverCandidate=window.__candidateMap[cid];
logHoverState('active');
}
function syncHover(){
if(!window.__candidatesReady)return;
var event=lastMouse;if(!event){clearActive();return}
// 当前框未离开前保持选中,避免重叠候选因 1px 抖动来回抢高亮。
var current=lastCid?document.querySelector('.candidate[data-cid="'+lastCid+'"]'):null;
if(current){
var r=current.getBoundingClientRect();
if(event.clientX>=r.left-2&&event.clientX<=r.right+2&&event.clientY>=r.top-2&&event.clientY<=r.bottom+2)return;
}
var hit=null,hitArea=Infinity;
Array.prototype.forEach.call(document.querySelectorAll('.candidate'),function(box){
var rect=box.getBoundingClientRect();
if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)return;
var area=rect.width*rect.height;
if(area<hitArea){hitArea=area;hit=box}
});
if(hit)activateBox(hit);else clearActive();
}
function logHoverState(state){
console.log('pane-ai hover ' + state + ' mouse=' + (lastMouse?lastMouse.clientX+','+lastMouse.clientY:'none') +
' cid=' + (lastCid||'none') + ' candidates=' + document.querySelectorAll('.candidate').length);
}
window.__toggleDebug=function(){
document.body.classList.toggle('debug');
var debug=document.body.classList.contains('debug');
showStatus(debug?'开发模式:F8 关闭候选框':(window.__candidatesReady?'AI 完成:移动高亮;单击选中,再次单击或空格回填;Esc 取消':'开发模式已开启,识别完成后显示全部候选框'));
};
function showCandidates(items){
paneW=window.__paneW||document.documentElement.clientWidth;paneH=window.__paneH||document.documentElement.clientHeight;
document.getElementById('scan').style.display='none';document.getElementById('frame').style.display='none';document.getElementById('tip').style.display='block';
document.getElementById('tip').textContent='AI 识别完成:移动鼠标高亮;单击选中,再次单击或空格回填;Esc 取消';
document.getElementById('scan').style.display='none';
document.getElementById('aiFrame').style.display='none';
document.body.classList.remove('scanning');document.body.style.cursor='pointer';
window.__hover=null;window.__selected=null;window.__confirmed=null;window.__readyAt=Date.now()+600;
items.sort(function(a,b){return a.width*a.height-b.width*b.height}).forEach(function(c,index){
var box=document.createElement('div');box.className='candidate';box.__index=index;
window.__candidateMap={};window.__candidatesReady=false;clearActive();
items.sort(function(a,b){return a.y-b.y||a.x-b.x}).forEach(function(c,index){
window.__candidateMap[c.candidateId]=c;
var box=document.createElement('div');box.className='candidate';box.dataset.cid=c.candidateId;box.dataset.index=index;
box.style.left=(c.x/paneW*100)+'%';box.style.top=(c.y/paneH*100)+'%';
box.style.width=(c.width/paneW*100)+'%';box.style.height=(c.height/paneH*100)+'%';
function activate(){
Array.prototype.forEach.call(document.querySelectorAll('.candidate,.badge'),function(n){n.classList.remove('active')});
box.classList.add('active');var label=document.getElementById('badge-'+index);if(label)label.classList.add('active');
window.__hover=index;window.__hoverCandidate=c;
}
window.confirmCandidate=function(index,c){
var tip=document.getElementById('tip');
tip.textContent='已选中:'+(c.name||'控件')+';再次单击或空格回填,Esc 取消';
window.__confirmed=index;window.__selected=index;
};
function deactivate(){
box.classList.remove('active');var label=document.getElementById('badge-'+index);if(label)label.classList.remove('active');
if(window.__hover===index){window.__hover=null;window.__hoverCandidate=null}
}
box.addEventListener('mouseenter',activate);box.addEventListener('mousemove',activate);box.addEventListener('mouseleave',deactivate);
box.addEventListener('mousedown',function(e){
e.preventDefault();e.stopPropagation();activate();
if(Date.now()<(window.__readyAt||0)){console.log('pane-ai ignore early click',index);return}
if(window.__confirmed!==index){console.log('pane-ai first select',index);window.confirmCandidate(index,c);return}
console.log('pane-ai confirm click',index);window.__selected=index;window.__done=true;
var label=document.createElement('div');label.className='badge';label.id='badge-'+index;label.textContent='AI识别 · '+(c.controlType||'控件');
label.style.left=(c.x/paneW*100)+'%';label.style.top=(Math.max(0,c.y-24)/paneH*100)+'%';
box.addEventListener('click',function(event){
event.stopPropagation();if(Date.now()<window.__readyAt)return;
activateBox(box);
if(window.__selectedCid!==c.candidateId){window.__selectedCid=c.candidateId;showStatus('已选中:'+(c.controlType||'控件')+';再次单击或空格回填');return}
window.__confirmedCid=c.candidateId;window.__done=true;
});
document.body.appendChild(box);
var yTop=c.y/paneH*100,yBottom=(c.y+c.height)/paneH*100,leftPct=Math.max(0,Math.min(72,c.x/paneW*100));
var badge=document.createElement('div');badge.id='badge-'+index;badge.className='badge';
badge.textContent='AI识别 · '+(c.name||'控件');
badge.style.left=leftPct+'%';
badge.style.top=(yTop<9?'calc('+yBottom+'% + 8px)':'calc('+yTop+'% - 32px)');
document.body.appendChild(badge);
document.body.appendChild(box);document.body.appendChild(label);
});
window.__candidatesReady=true;window.__readyAt=Date.now()+450;
showStatus(document.body.classList.contains('debug')?'开发模式:F8 关闭候选框':'AI 完成:移动高亮;单击选中,再次单击或空格回填;Esc 取消');
syncHover();
}
document.addEventListener('keydown',function(e){
if(e.key==='Escape'){window.__cancelled=true}
if(e.key===' '&&window.__hoverCandidate&&!window.__done){
e.preventDefault();
if(Date.now()<(window.__readyAt||0)){console.log('pane-ai ignore early space');return}
if(window.__confirmed!==window.__hover){window.confirmCandidate(window.__hover,window.__hoverCandidate);return}
window.__selected=window.__hover;window.__done=true;
document.addEventListener('pointermove',function(event){lastMouse=event;syncHover()});
document.addEventListener('keydown',function(event){
if(event.key==='F8'||event.code==='F8'){event.preventDefault();window.__toggleDebug();return}
if(event.key==='Escape'){window.__cancelled=true;return}
if(event.key===' '||event.code==='Space'){event.preventDefault();
if(window.__confirmedCid!==window.__hoverCid){window.__confirmedCid=window.__hoverCid;window.__done=true}
}
});
document.addEventListener('mousedown',function(event){
var candidate=event.target&&event.target.classList&&event.target.classList.contains('candidate');
if(candidate||Date.now()<(window.__readyAt||0))return;
if(window.__selectedCid&&window.__hoverCid===window.__selectedCid){window.__confirmedCid=window.__hoverCid;window.__done=true}
});
</script></body></html>`;
}
@ -236,160 +291,168 @@ async function createPaneAiOverlay(rect: { x: number; y: number; w: number; h: n
return win;
}
async function capturePaneForAi(rect: { x: number; y: number; w: number; h: number }): Promise<AiCaptureOutput> {
const outDir = path.join(app.getPath("userData"), "ai-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = path.join(outDir, "ai-pane-" + Date.now() + ".png");
const { stdout } = await run(
"powershell.exe",
[
"-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(),
"-Mode", "capture", "-CaptureExact",
"-CaptureX", String(rect.x), "-CaptureY", String(rect.y),
"-CaptureWidth", String(rect.w), "-CaptureHeight", String(rect.h),
"-CapturePath", imagePath
],
{ timeout: 30000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }
);
const parsed = jsonLine<AiCaptureOutput>(stdout, "{");
if (!parsed.imagePath || !fs.existsSync(parsed.imagePath)) {
throw new RuntimeError("ai_capture_failed", "AI 拾取截图失败");
}
return parsed;
}
async function capturePaneForAi(rect: { x: number; y: number; w: number; h: number }, overlay: BrowserWindow): Promise<AiCaptureOutput> {
const centerDip = screen.screenToDipPoint({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
const display = displayForPoint(centerDip.x, centerDip.y);
const scale = display.scaleFactor || 1;
const displayPhysicalX = Math.round(display.bounds.x * scale);
const displayPhysicalY = Math.round(display.bounds.y * scale);
// UIA/Desktop Picker 返回的是物理像素坐标;截图裁剪也必须直接使用物理像素。
// 这里不能把 cropX/cropY 再乘 actualScale,否则会重复缩放并导致候选框整体偏移。
const cropX = rect.x - displayPhysicalX;
const cropY = rect.y - displayPhysicalY;
const thumbnailWidth = Math.round(display.bounds.width * scale);
const thumbnailHeight = Math.round(display.bounds.height * scale);
function parseAiCandidates(text: string, cropW: number, cropH: number): AiCandidate[] {
const fence = String.fromCharCode(96);
const fencePattern = new RegExp(fence + "{3}(?:json)?" + fence + "{3}", "gi");
const cleaned = text.replace(fencePattern, "").trim();
const start = cleaned.indexOf("{");
const arrayStart = cleaned.indexOf("[");
const objectStart = start >= 0 && (start < arrayStart || arrayStart < 0) ? start : arrayStart;
const end = Math.max(cleaned.lastIndexOf("}"), cleaned.lastIndexOf("]"));
if (objectStart < 0 || end <= objectStart) throw new RuntimeError("ai_response_invalid", "AI 视觉模型没有返回 JSON");
const raw = JSON.parse(cleaned.slice(objectStart, end + 1)) as Record<string, unknown>;
const list = Array.isArray(raw) ? raw : Array.isArray(raw.candidates) ? raw.candidates : [];
const result: AiCandidate[] = [];
for (const item of list.slice(0, 40)) {
const record = (item || {}) as Record<string, unknown>;
const num = (...keys: string[]): number => {
for (const key of keys) {
const value = Number(record[key]);
if (Number.isFinite(value)) return value;
}
return NaN;
};
let x = num("x", "left", "x1");
let y = num("y", "top", "y1");
let width = num("width", "w");
let height = num("height", "h");
const x2 = num("x2", "right");
const y2 = num("y2", "bottom");
if (!Number.isFinite(width) && Number.isFinite(x2)) width = x2 - x;
if (!Number.isFinite(height) && Number.isFinite(y2)) height = y2 - y;
if (![x, y, width, height].every(Number.isFinite) || width < 6 || height < 6) continue;
x = Math.max(0, Math.min(cropW - width, x));
y = Math.max(0, Math.min(cropH - height, y));
width = Math.min(width, cropW - x);
height = Math.min(height, cropH - y);
if (width * height >= cropW * cropH * 0.97) continue;
const confidence = Number(record.confidence);
result.push({
x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height),
name: String(record.name ?? "AI识别控件").slice(0, 80) || "AI识别控件",
controlType: String(record.controlType ?? "Control").slice(0, 40) || "Control",
confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(1, confidence)) : 0.72
// 只为避开拾取窗口本身;推理和动画继续在后台进行。
overlay.hide();
await new Promise(resolve => setTimeout(resolve, 60));
try {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: thumbnailWidth, height: thumbnailHeight }
});
overlay.show();
const source = sources.find(item => item.display_id === String(display.id)) || sources[0];
if (!source || source.thumbnail.isEmpty()) {
throw new RuntimeError("ai_capture_failed", "AI 拾取截图失败");
}
const thumbnail = source.thumbnail;
const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width);
const x = Math.max(0, Math.min(thumbnail.getSize().width - 1, cropX));
const y = Math.max(0, Math.min(thumbnail.getSize().height - 1, cropY));
const width = Math.max(1, Math.min(thumbnail.getSize().width - x, Math.round(rect.w * actualScale)));
const height = Math.max(1, Math.min(thumbnail.getSize().height - y, Math.round(rect.h * actualScale)));
const cropped = thumbnail.crop({ x, y, width, height });
const image = cropped.toPNG();
const outDir = path.join(app.getPath("userData"), "ai-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = path.join(outDir, `ai-pane-${Date.now()}.png`);
await fs.promises.writeFile(imagePath, image);
return {
cursorX: rect.x,
cursorY: rect.y,
cropX: rect.x,
cropY: rect.y,
cropW: width,
cropH: height,
markerX: 0,
markerY: 0,
imagePath
};
} catch (error) {
overlay.show();
throw error;
}
if (!result.length) throw new RuntimeError("ai_response_invalid", "AI 没有识别到可用候选区域");
return result;
}
async function runPaneAiPick(context: UiaPickOutput): Promise<DesktopPickResult> {
requireAiConfig({ vision: true });
// 本地小模型是 Pane AI 的主链路;这里不要求用户配置远程 VLM。
const localModelPath = localUiModelPath();
console.log("[pane-ai] local model", localModelPath);
const pane = paneAiRect(context);
const win = await createPaneAiOverlay(pane);
const startedAt = Date.now();
// F8 用全局快捷键,避免键盘焦点停在目标应用时 overlay 收不到按键。
const debugHotkeyRegistered = globalShortcut.register("F8", () => {
void win.webContents.executeJavaScript("window.__toggleDebug && window.__toggleDebug()", true).catch(() => {});
});
try {
// Let the visible scan animation complete over the actual Pane before taking the screenshot.
await new Promise(resolve => setTimeout(resolve, 950));
win.hide();
await new Promise(resolve => setTimeout(resolve, 240));
const capture = await capturePaneForAi(pane);
win.show();
win.focus();
await win.webContents.executeJavaScript("startScan('AI 理解中…');");
// 稍等 overlay 完成首轮绘制,但不再等一整套“表演动画”。
await new Promise(resolve => setTimeout(resolve, 350));
const detection = capturePaneForAi(pane, win)
.then(async capture => {
const image = await fs.promises.readFile(capture.imagePath);
const inferenceStarted = Date.now();
const boxes = await detectUiElements({ image, width: capture.cropW, height: capture.cropH });
console.log("[pane-ai] local inference", boxes.length, Date.now() - inferenceStarted + "ms");
return { capture, boxes };
});
const image = await fs.promises.readFile(capture.imagePath);
let aiSettled = false;
const aiRequest = chatCompletion([
{
role: "system",
content: "你是桌面 RPA 视觉控件分析器。只输出 JSON,不输出解释或 Markdown。"
},
{
role: "user",
content: [
{
type: "text",
text: [
"这是目标 Pane 区域的截图,尺寸 " + capture.cropW + "x" + capture.cropH + " 像素。",
"请把这个 Pane 里的可用控件/内容块拆分成多个候选矩形,最多返回 30 个,按从上到下、从左到右排序。",
"优先识别按钮、输入框、列表行、卡片、菜单项、链接和明显可操作区域;不要把整个 Pane 当作候选。",
"只返回 JSON:{\"candidates\":[{\"x\":number,\"y\":number,\"width\":number,\"height\":number,\"name\":string,\"controlType\":string,\"confidence\":number}]}",
"坐标全部是相对这张截图的整数像素坐标;x/y 是左上角,width/height 也是像素。"
].join("\n")
},
{ type: "image_url", image_url: { url: "data:image/png;base64," + image.toString("base64") } }
]
let settled = false;
const tickStatus = async () => {
const elapsed = Date.now() - startedAt;
const label = elapsed < 2600
? "AI 识别中…"
: elapsed < 6000
? "AI 理解中…"
: "正在整理候选区域…";
try {
await win.webContents.executeJavaScript("showStatus(" + JSON.stringify(label) + ");", true);
} catch {
// Overlay 销毁时状态更新可以安全忽略。
}
], { vision: true, temperature: 0 }).finally(() => { aiSettled = true; });
};
const statusTimer = setInterval(() => void tickStatus(), 1500);
const cancelWatcher = (async () => {
while (!aiSettled) {
while (!settled) {
try {
const state = await win.webContents.executeJavaScript("JSON.stringify({cancelled:window.__cancelled===true})", true);
if (JSON.parse(state).cancelled) throw new RuntimeError("pick_cancelled", "已取消 AI 拾取");
} catch (error) {
if (error instanceof RuntimeError && error.code === "pick_cancelled") throw error;
}
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => setTimeout(resolve, 80));
}
})();
let aiText: string;
let detected: { capture: AiCaptureOutput; boxes: Awaited<ReturnType<typeof detectUiElements>> };
try {
aiText = await Promise.race([aiRequest, cancelWatcher]) as string;
detected = await Promise.race([detection, cancelWatcher]) as { capture: AiCaptureOutput; boxes: Awaited<ReturnType<typeof detectUiElements>> };
} finally {
aiSettled = true;
settled = true;
clearInterval(statusTimer);
}
const candidates: AiCandidate[] = detected.boxes.map((box, index) => ({
candidateId: "local-" + index,
x: box.x,
y: box.y,
width: box.width,
height: box.height,
name: box.category,
controlType: box.category,
confidence: box.confidence
}));
if (!candidates.length) {
throw new RuntimeError("ai_response_invalid", "本地 UI 模型没有识别到可用候选区域");
}
const candidates = parseAiCandidates(aiText, capture.cropW, capture.cropH);
const payload = JSON.stringify(candidates);
console.log("[pane-ai] candidates", candidates.length, candidates.map(item => ({ name: item.name, x: item.x, y: item.y, width: item.width, height: item.height })));
await win.webContents.executeJavaScript("window.__done=false;window.__cancelled=false;window.__selected=null;window.__hover=null;window.__hoverCandidate=null;window.__confirmed=null;window.__readyAt=Date.now()+600;showCandidates(" + payload + ");");
await win.show();
win.focus();
await win.webContents.executeJavaScript(
"window.__done=false;window.__cancelled=false;window.__selectedCid=null;window.__hoverCid=null;window.__hoverCandidate=null;window.__confirmedCid=null;window.__readyAt=Date.now()+500;showCandidates(" + payload + ");",
true
);
const deadline = Date.now() + 180000;
let selectedIndex: number | null = null;
let selectedId: string | null = null;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 80));
const state = await win.webContents.executeJavaScript(
"JSON.stringify({done:window.__done===true,cancelled:window.__cancelled===true,selected:window.__selected})", true
"JSON.stringify({done:window.__done===true,cancelled:window.__cancelled===true,selectedCid:window.__selectedCid||null})",
true
);
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; selected: number | null };
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; selectedCid: string | null };
if (parsed.cancelled) throw new RuntimeError("pick_cancelled", "已取消 AI 拾取");
if (parsed.done && Number.isInteger(parsed.selected)) {
const confirmedIndex = Number(parsed.selected);
console.log("[pane-ai] confirmed", confirmedIndex, candidates[confirmedIndex]);
selectedIndex = confirmedIndex;
break;
if (parsed.done && parsed.selectedCid) {
const box = candidates.find(item => item.candidateId === parsed.selectedCid);
console.log("[pane-ai] confirmed", parsed.selectedCid, box);
if (box) {
selectedId = box.candidateId;
break;
}
}
}
if (selectedIndex === null || !candidates[selectedIndex]) throw new RuntimeError("pick_cancelled", "AI 拾取超时");
if (!selectedId) throw new RuntimeError("pick_cancelled", "AI 拾取超时");
const box = candidates[selectedIndex];
const x = capture.cropX + box.x;
const y = capture.cropY + box.y;
const box = candidates.find(item => item.candidateId === selectedId)!;
const x = detected.capture.cropX + box.x;
const y = detected.capture.cropY + box.y;
const selectorParts = [
"screen=" + [x, y, box.width, box.height].map(Math.round).join(","),
context.window ? "window=" + safeSelectorText(context.window) : "",
@ -404,37 +467,15 @@ async function runPaneAiPick(context: UiaPickOutput): Promise<DesktopPickResult>
controlType: box.controlType,
x, y, w: box.width, h: box.height,
confidence: box.confidence,
screenshotPath: capture.imagePath,
screenshotPath: detected.capture.imagePath,
selector: selectorParts.join(";")
};
} finally {
if (debugHotkeyRegistered) globalShortcut.unregister("F8");
if (!win.isDestroyed()) win.destroy();
}
}
function aiOverlayHtml(): string {
return `<!DOCTYPE html><html><head><meta charset='utf-8'><style>
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:rgba(3,20,45,.12);cursor:crosshair;font-family:'Microsoft YaHei',Arial,sans-serif}
#tip{position:fixed;top:28px;left:50%;transform:translateX(-50%);padding:10px 20px;border-radius:8px;color:#f8fafc;background:rgba(2,14,34,.82);border:1px solid rgba(96,165,250,.55);font-size:15px;box-shadow:0 8px 30px rgba(0,0,0,.25)}
#scan{position:absolute;left:0;right:0;top:-240px;height:240px;display:none;background:linear-gradient(to bottom,rgba(59,130,246,0),rgba(59,130,246,.12),rgba(147,197,253,.46),rgba(59,130,246,.10),rgba(59,130,246,0));box-shadow:0 0 38px rgba(96,165,250,.88);animation:scan 1.7s linear infinite}
#point{position:absolute;width:46px;height:46px;border:2px solid #38bdf8;border-radius:8px;display:none;box-shadow:0 0 20px rgba(56,189,248,.9)}
body.scanning{background:rgba(3,20,45,.03)}
body.scanning #scan{display:block}
body.scanning #tip{font-size:13px}
@keyframes scan{from{top:-240px}to{top:100%}}
</style></head><body><div id=scan></div><div id=point></div><div id=tip>AI 拾取:单击要识别的控件;Esc 取消</div><script>
document.addEventListener('mousedown', function(e) {
if (document.body.classList.contains('scanning')) return;
var p=document.getElementById('point');
p.style.left=(e.clientX-23)+'px';p.style.top=(e.clientY-23)+'px';p.style.display='block';
document.getElementById('tip').textContent='AI 视觉识别中…自上而下扫描画面';
document.body.classList.add('scanning');
window.__point={x:e.clientX,y:e.clientY};window.__done=true;
});
document.addEventListener('keydown',function(e){if(e.key==='Escape'){window.__cancelled=true;}});
</script></body></html>`;
}
function displayForPoint(x: number, y: number) {
return screen.getAllDisplays().find(display => {
const b = display.bounds;
@ -442,216 +483,6 @@ function displayForPoint(x: number, y: number) {
}) || screen.getPrimaryDisplay();
}
async function createAiOverlay(): Promise<{ win: BrowserWindow; display: ReturnType<typeof screen.getPrimaryDisplay> }> {
const cursor = screen.getCursorScreenPoint();
const display = displayForPoint(cursor.x, cursor.y);
const win = new BrowserWindow({
x: display.bounds.x,
y: display.bounds.y,
width: display.bounds.width,
height: display.bounds.height,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
alwaysOnTop: true,
skipTaskbar: true,
show: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
});
win.setAlwaysOnTop(true, "screen-saver");
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(aiOverlayHtml()));
win.show();
win.focus();
return { win, display };
}
async function captureAiAtPoint(physicalX: number, physicalY: number): Promise<AiCaptureOutput> {
const outDir = path.join(app.getPath("userData"), "ai-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = path.join(outDir, "ai-pick-" + Date.now() + ".png");
const { stdout } = await run(
"powershell.exe",
[
"-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(),
"-Mode", "capture", "-CaptureX", String(physicalX), "-CaptureY", String(physicalY),
"-CaptureWidth", "1000", "-CaptureHeight", "700", "-CapturePath", imagePath
],
{ timeout: 30000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }
);
const parsed = jsonLine<AiCaptureOutput>(stdout, "{");
if (!parsed.imagePath || !fs.existsSync(parsed.imagePath)) {
throw new RuntimeError("ai_capture_failed", "AI 拾取截图失败");
}
return parsed;
}
function parseAiBox(text: string, cropW: number, cropH: number) {
const fence = String.fromCharCode(96);
const fencePattern = new RegExp(fence + "{3}(?:json)?" + fence + "{3}", "gi");
const cleaned = text.replace(fencePattern, "").trim();
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start < 0 || end <= start) throw new RuntimeError("ai_response_invalid", "AI 视觉模型没有返回 JSON");
const raw = JSON.parse(cleaned.slice(start, end + 1)) as Record<string, unknown>;
const num = (...keys: string[]): number => {
for (const key of keys) {
const value = Number(raw[key]);
if (Number.isFinite(value)) return value;
}
return NaN;
};
const x1 = num("x", "left", "x1");
const y1 = num("y", "top", "y1");
let width = num("width", "w");
let height = num("height", "h");
const x2 = num("x2", "right");
const y2 = num("y2", "bottom");
if (!Number.isFinite(width) && Number.isFinite(x2)) width = x2 - x1;
if (!Number.isFinite(height) && Number.isFinite(y2)) height = y2 - y1;
if (![x1, y1].every(Number.isFinite)) throw new RuntimeError("ai_response_invalid", "AI 视觉模型缺少目标坐标");
if (!Number.isFinite(width) || width <= 0) width = 24;
if (!Number.isFinite(height) || height <= 0) height = 18;
width = Math.max(4, Math.min(cropW, width));
height = Math.max(4, Math.min(cropH, height));
const localX = Math.max(0, Math.min(cropW - width, x1));
const localY = Math.max(0, Math.min(cropH - height, y1));
const confidence = Number(raw.confidence);
return {
x: localX,
y: localY,
width,
height,
name: String(raw.name ?? "AI识别控件"),
controlType: String(raw.controlType ?? "Control"),
confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(1, confidence)) : 0.7
};
}
async function runAiPick(context: UiaPickOutput = {}): Promise<DesktopPickResult> {
requireAiConfig({ vision: true });
const { win, display } = await createAiOverlay();
try {
let point: { x: number; y: number } | null = null;
let physicalX = 0;
let physicalY = 0;
const autoAi = context.aiRequested && Number.isFinite(Number(context.cursorX)) && Number.isFinite(Number(context.cursorY));
if (autoAi) {
const cursor = screen.getCursorScreenPoint();
point = { x: cursor.x - display.bounds.x, y: cursor.y - display.bounds.y };
physicalX = Math.round(Number(context.cursorX));
physicalY = Math.round(Number(context.cursorY));
await win.webContents.executeJavaScript(
"var p=document.getElementById('point');p.style.left=(window.__autoX=" + point.x + ")-23+'px';p.style.top=(window.__autoY=" + point.y + ")-23+'px';p.style.display='block';document.getElementById('tip').textContent='AI 视觉识别中…自上而下扫描画面';document.body.classList.add('scanning');window.__done=true;"
);
await new Promise(resolve => setTimeout(resolve, 120));
}
const deadline = Date.now() + 120000;
while (!autoAi && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 80));
try {
const state = await win.webContents.executeJavaScript(
"JSON.stringify({done:window.__done===true,cancelled:window.__cancelled===true,point:window.__point||null})", true
);
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; point: { x: number; y: number } | null };
if (parsed.cancelled) throw new RuntimeError("pick_cancelled", "已取消 AI 拾取");
if (parsed.done && parsed.point) {
point = parsed.point;
break;
}
} catch (error) {
if (error instanceof RuntimeError && error.code === "pick_cancelled") throw error;
}
}
if (!point) throw new RuntimeError("pick_cancelled", "AI 拾取超时");
// Hide the effect layer so the screen capture contains the real application only.
win.hide();
await new Promise(resolve => setTimeout(resolve, 180));
if (!autoAi) {
const scale = display.scaleFactor || 1;
physicalX = Math.round((display.bounds.x + point.x) * scale);
physicalY = Math.round((display.bounds.y + point.y) * scale);
}
const capture = await captureAiAtPoint(physicalX, physicalY);
win.showInactive();
await win.webContents.executeJavaScript(
"document.getElementById('tip').textContent='AI 视觉识别中…请稍候';document.body.classList.add('scanning');"
);
const image = await fs.promises.readFile(capture.imagePath);
let aiSettled = false;
const aiRequest = chatCompletion([
{
role: "system",
content: "你是桌面 RPA 视觉定位器。只输出 JSON,不输出解释或 Markdown。"
},
{
role: "user",
content: [
{
type: "text",
text: [
"截图尺寸 " + capture.cropW + "x" + capture.cropH + " 像素。",
"红黑色十字中心位于 (" + capture.markerX + ", " + capture.markerY + ")。",
"请定位十字中心处或半径 35 像素内最小的可交互 UI 控件;不要返回整个窗口或大容器。",
"只返回 JSON:{\"x\":number,\"y\":number,\"width\":number,\"height\":number,\"name\":string,\"controlType\":string,\"confidence\":number}",
"x/y 是控件左上角在截图内的像素坐标,width/height 单位也是像素。"
].join("\n")
},
{ type: "image_url", image_url: { url: "data:image/png;base64," + image.toString("base64") } }
]
}
], { vision: true, temperature: 0 }).finally(() => { aiSettled = true; });
const cancelWatcher = (async () => {
while (!aiSettled) {
try {
const state = await win.webContents.executeJavaScript("JSON.stringify({cancelled:window.__cancelled===true})", true);
if (JSON.parse(state).cancelled) throw new RuntimeError("pick_cancelled", "已取消 AI 拾取");
} catch (error) {
if (error instanceof RuntimeError && error.code === "pick_cancelled") throw error;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
})();
try {
var result = (await Promise.race([aiRequest, cancelWatcher])) as string;
} finally {
aiSettled = true;
}
const box = parseAiBox(result, capture.cropW, capture.cropH);
const x = capture.cropX + box.x;
const y = capture.cropY + box.y;
const w = box.width;
const h = box.height;
const parts = [
"screen=" + [x, y, w, h].map(Math.round).join(","),
context.window || capture.window ? "window=" + safeSelectorText(context.window || capture.window) : "",
"name=" + safeSelectorText(box.name),
"type=" + safeSelectorText(box.controlType || "Control"),
"ai=1"
].filter(Boolean);
return {
source: "ai",
window: context.window || capture.window,
name: box.name,
controlType: box.controlType,
x,
y,
w,
h,
confidence: box.confidence,
screenshotPath: capture.imagePath,
selector: parts.join(";")
};
} finally {
if (!win.isDestroyed()) win.destroy();
}
}
export async function listVisibleWindows(): Promise<Array<{ title: string; process: string }>> {
const { stdout } = await run(
"powershell.exe",
@ -722,3 +553,7 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript], { timeout: 30000, windowsHide: true });
return { ...region, imagePath };
}

View File

@ -9,7 +9,9 @@ import { capabilityCount, capabilityIds } from "./engine/registry";
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
import { loadSettings, updateSettings } from "./settings";
import { closeLocalVisionWorkers, defaultUiModelPath } from "./local-vision/ui-detect-service";
import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick";
import { detectVisionLabTarget, listVisionLabSources } from "./vision-lab";
import { logMain } from "./logger";
let mainWindow: BrowserWindow | null = null;
@ -113,9 +115,12 @@ function registerIpc(): void {
}
return session.pickElement();
});
ipcMain.handle("desktop:pick-element", (_event, options?: { ai?: boolean }) => pickDesktopElement(options));
ipcMain.handle("desktop:pick-element", () => pickDesktopElement());
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
ipcMain.handle("vision-lab:sources", () => listVisionLabSources());
ipcMain.handle("vision-lab:detect", (_event, request: Parameters<typeof detectVisionLabTarget>[0]) => detectVisionLabTarget(request));
ipcMain.handle("vision-lab:default-model", () => defaultUiModelPath());
ipcMain.handle("flows:list", () => listFlows());
ipcMain.handle("flows:create", (_event, name?: string) => {
const flow = createFlow(name);
@ -212,6 +217,7 @@ if (!gotLock) {
app.on("before-quit", () => {
void closeBrowserSession();
void stopFixtureServer();
closeLocalVisionWorkers();
});
}

View File

@ -0,0 +1,145 @@
import fs from "node:fs";
import path from "node:path";
import { Worker } from "node:worker_threads";
import { app } from "electron";
import { RuntimeError } from "../engine/types";
import type { LocalUiBox } from "./ui-detect-worker";
export interface UiDetectOptions {
image: Buffer;
width: number;
height: number;
modelPath?: string;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectRequest {
id: number;
image: Buffer;
width: number;
height: number;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectResponse {
id: number;
boxes?: LocalUiBox[];
error?: string;
elapsedMs?: number;
}
const workers = new Map<string, Worker>();
const pending = new Map<number, { resolve: (boxes: LocalUiBox[]) => void; reject: (error: unknown) => void; worker: Worker }>();
let nextRequestId = 1;
const MAX_LOADED_WORKERS = 4;
export function localUiModelPath(): string {
const candidates = [
path.join(process.resourcesPath || "", "models", "ui-icon-detect", "model.onnx"),
path.join(app.getAppPath(), "resources", "models", "ui-icon-detect", "model.onnx")
].filter(Boolean);
const found = candidates.find(candidate => fs.existsSync(candidate));
if (!found) {
throw new RuntimeError("local_vision_model_missing", "本地 UI 检测模型缺失", {
recommendation: "重新安装应用,或补齐 resources/models/ui-icon-detect/model.onnx"
});
}
return found;
}
export function defaultUiModelPath(): string {
return localUiModelPath();
}
function resolveModelPath(candidate?: string): string {
if (!candidate) return localUiModelPath();
const normalized = path.resolve(candidate);
if (!fs.existsSync(normalized) || !fs.statSync(normalized).isFile()) {
throw new RuntimeError("local_vision_model_missing", "模型文件不存在:" + normalized);
}
if (path.extname(normalized).toLowerCase() !== ".onnx") {
throw new RuntimeError("local_vision_model_invalid", "请选择 ONNX 模型文件");
}
return normalized;
}
function createWorker(modelPath: string): Worker {
const next = new Worker(path.join(__dirname, "local-vision/ui-detect-worker.js"), {
workerData: { modelPath },
resourceLimits: { maxOldGenerationSizeMb: 512 }
});
next.on("message", (message: DetectResponse) => {
const entry = pending.get(message.id);
if (!entry || entry.worker !== next) return;
pending.delete(message.id);
if (message.error) {
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测失败:" + message.error));
return;
}
entry.resolve(message.boxes || []);
});
next.on("error", error => {
for (const [id, entry] of pending) {
if (entry.worker === next) {
entry.reject(error);
pending.delete(id);
}
}
workers.delete(modelPath);
});
next.on("exit", code => {
for (const [id, entry] of pending) {
if (entry.worker === next) {
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测进程已退出:" + code));
pending.delete(id);
}
}
if (workers.get(modelPath) === next) workers.delete(modelPath);
});
return next;
}
function getWorker(modelPath: string): Worker {
const existing = workers.get(modelPath);
if (existing) return existing;
while (workers.size >= MAX_LOADED_WORKERS) {
const oldest = workers.keys().next().value;
if (!oldest) break;
const worker = workers.get(oldest);
workers.delete(oldest);
void worker?.terminate();
}
const created = createWorker(modelPath);
workers.set(modelPath, created);
return created;
}
export async function detectUiElements(input: UiDetectOptions): Promise<LocalUiBox[]> {
const modelPath = resolveModelPath(input.modelPath);
const current = getWorker(modelPath);
const nextId = nextRequestId++;
const request: DetectRequest = {
id: nextId,
image: input.image,
width: input.width,
height: input.height,
scoreThreshold: input.scoreThreshold,
iouThreshold: input.iouThreshold
};
return new Promise<LocalUiBox[]>((resolve, reject) => {
pending.set(nextId, { resolve, reject, worker: current });
current.postMessage(request);
});
}
export function closeLocalVisionWorkers(): void {
for (const [modelPath, worker] of workers) {
workers.delete(modelPath);
void worker.terminate();
}
pending.clear();
}

View File

@ -0,0 +1,205 @@
import { parentPort, workerData } from "node:worker_threads";
import { InferenceSession, Tensor } from "onnxruntime-node";
import { createCanvas, loadImage } from "@napi-rs/canvas";
export interface LocalUiBox {
x: number;
y: number;
width: number;
height: number;
category: string;
confidence: number;
}
interface DetectRequest {
id: number;
image: Buffer;
width: number;
height: number;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectResponse {
id: number;
boxes?: LocalUiBox[];
error?: string;
elapsedMs?: number;
}
type ModelOutput = { output0: Tensor };
type TensorLike = { dims: number[]; data: Float32Array };
const inputName = "images";
const outputName = "output0";
const inputSize = 640;
const defaultScoreThreshold = 0.25;
const defaultIouThreshold = 0.42;
const maxBoxes = 160;
let sessionPromise: Promise<InferenceSession> | null = null;
function loadSession(): Promise<InferenceSession> {
if (!sessionPromise) {
const modelPath = String((workerData as { modelPath?: string }).modelPath || "");
sessionPromise = InferenceSession.create(modelPath, {
executionProviders: ["cpu"],
graphOptimizationLevel: "all",
executionMode: "sequential"
});
}
return sessionPromise;
}
function iou(a: LocalUiBox, b: LocalUiBox): number {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.width, b.x + b.width);
const y2 = Math.min(a.y + a.height, b.y + b.height);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
if (intersection <= 0) return 0;
return intersection / (a.width * a.height + b.width * b.height - intersection);
}
function nms(boxes: LocalUiBox[], iouThreshold: number): LocalUiBox[] {
const sorted = [...boxes].sort((a, b) => b.confidence - a.confidence);
const kept: LocalUiBox[] = [];
for (const box of sorted) {
if (kept.every(item => iou(item, box) <= iouThreshold)) kept.push(box);
}
return kept;
}
function tensorAccessor(output: TensorLike, anchorCount: number, channelCount: number, channelFirst: boolean) {
return (channel: number, anchor: number): number => channelFirst
? output.data[channel * anchorCount + anchor]
: output.data[anchor * channelCount + channel];
}
function extractBoxes(
output: TensorLike,
sourceWidth: number,
sourceHeight: number,
scoreThreshold: number,
iouThreshold: number
): LocalUiBox[] {
// YOLO/OmniParser exports are commonly [1,C,N] or [1,N,C]. C=4+classCount.
if (output.dims.length < 2) throw new Error(`不支持的模型输出维度:${output.dims.join("x")}`);
const [, first, second] = output.dims;
const channelFirst = first <= second;
const channelCount = channelFirst ? first : second;
const anchorCount = channelFirst ? second : first;
if (channelCount < 5) throw new Error(`模型输出通道数异常:${channelCount}`);
const at = tensorAccessor(output, anchorCount, channelCount, channelFirst);
const classCount = channelCount - 4;
const padded = Math.max(sourceWidth, sourceHeight);
const scale = inputSize / padded;
const drawnWidth = Math.round(sourceWidth * scale);
const drawnHeight = Math.round(sourceHeight * scale);
const offsetX = (inputSize - drawnWidth) / 2;
const offsetY = (inputSize - drawnHeight) / 2;
const minSide = Math.max(8, Math.min(sourceWidth, sourceHeight) * 0.006);
const raw: LocalUiBox[] = [];
for (let index = 0; index < anchorCount; index++) {
let confidence = 0;
let classIndex = 0;
if (classCount === 1) {
confidence = at(4, index);
} else {
for (let candidate = 0; candidate < classCount; candidate++) {
const score = at(4 + candidate, index);
if (score > confidence) {
confidence = score;
classIndex = candidate;
}
}
}
if (!Number.isFinite(confidence) || confidence < scoreThreshold) continue;
const cx = at(0, index);
const cy = at(1, index);
const width = at(2, index);
const height = at(3, index);
let x = (cx - width / 2 - offsetX) / scale;
let y = (cy - height / 2 - offsetY) / scale;
let boxWidth = width / scale;
let boxHeight = height / scale;
x = Math.max(0, Math.min(sourceWidth - 1, x));
y = Math.max(0, Math.min(sourceHeight - 1, y));
boxWidth = Math.max(2, Math.min(sourceWidth - x, boxWidth));
boxHeight = Math.max(2, Math.min(sourceHeight - y, boxHeight));
if (boxWidth < minSide || boxHeight < minSide) continue;
const areaRatio = (boxWidth * boxHeight) / (sourceWidth * sourceHeight);
if (areaRatio > 0.92) continue;
raw.push({
x: Math.round(x),
y: Math.round(y),
width: Math.round(boxWidth),
height: Math.round(boxHeight),
category: classCount <= 1 ? "控件" : `类别${classIndex + 1}`,
confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 1000) / 1000
});
}
return nms(raw, iouThreshold)
.sort((a, b) => b.confidence - a.confidence || a.y - b.y || a.x - b.x)
.slice(0, maxBoxes)
.sort((a, b) => a.y - b.y || a.x - b.x);
}
async function detect(request: DetectRequest): Promise<LocalUiBox[]> {
const model = await loadSession();
const source = await loadImage(request.image);
const width = request.width || source.width;
const height = request.height || source.height;
const canvas = createCanvas(inputSize, inputSize);
const context = canvas.getContext("2d");
context.fillStyle = "#727272";
context.fillRect(0, 0, inputSize, inputSize);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const scale = Math.min(inputSize / width, inputSize / height);
context.drawImage(
source,
(inputSize - width * scale) / 2,
(inputSize - height * scale) / 2,
width * scale,
height * scale
);
const pixels = context.getImageData(0, 0, inputSize, inputSize).data;
const tensorData = new Float32Array(3 * inputSize * inputSize);
const planeSize = inputSize * inputSize;
for (let index = 0; index < planeSize; index++) {
tensorData[index] = pixels[index * 4] / 255;
tensorData[planeSize + index] = pixels[index * 4 + 1] / 255;
tensorData[planeSize * 2 + index] = pixels[index * 4 + 2] / 255;
}
const results = await model.run({ [inputName]: new Tensor("float32", tensorData, [1, 3, inputSize, inputSize]) });
const output = results[outputName] as unknown as TensorLike;
return extractBoxes(
output,
width,
height,
request.scoreThreshold ?? defaultScoreThreshold,
request.iouThreshold ?? defaultIouThreshold
);
}
parentPort?.on("message", async (request: DetectRequest) => {
const started = Date.now();
try {
const boxes = await detect(request);
const response: DetectResponse = { id: request.id, boxes, elapsedMs: Date.now() - started };
parentPort?.postMessage(response);
} catch (error) {
const response: DetectResponse = {
id: request.id,
error: error instanceof Error ? error.message : String(error),
elapsedMs: Date.now() - started
};
parentPort?.postMessage(response);
}
});

70
src/main/vision-lab.ts Normal file
View File

@ -0,0 +1,70 @@
import { desktopCapturer } from "electron";
import path from "node:path";
import type { VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types";
import { RuntimeError } from "./engine/types";
import { defaultUiModelPath, detectUiElements } from "./local-vision/ui-detect-service";
function sourceKind(sourceId: string): "window" | "screen" {
return sourceId.startsWith("screen:") ? "screen" : "window";
}
function clamp(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
export async function listVisionLabSources(): Promise<VisionLabSource[]> {
const sources = await desktopCapturer.getSources({
types: ["window", "screen"],
thumbnailSize: { width: 360, height: 210 },
fetchWindowIcons: true
});
return sources
.filter(source => source.thumbnail.isEmpty() === false)
.filter(source => sourceKind(source.id) === "screen" || source.name !== "RPA Studio")
.map(source => ({
id: source.id,
kind: sourceKind(source.id),
name: source.name || (sourceKind(source.id) === "screen" ? "屏幕" : "未命名窗口"),
thumbnail: source.thumbnail.toDataURL(),
icon: source.appIcon?.toDataURL()
}));
}
export async function detectVisionLabTarget(request: VisionLabDetectRequest): Promise<VisionLabDetection> {
if (!request?.sourceId) throw new RuntimeError("vision_lab_source_required", "请先选择识别目标");
const sourceType = sourceKind(request.sourceId);
const captureStarted = Date.now();
const sources = await desktopCapturer.getSources({
types: [sourceType],
thumbnailSize: { width: 1280, height: 800 }
});
const source = sources.find(item => item.id === request.sourceId);
if (!source) throw new RuntimeError("vision_lab_source_missing", "识别目标已失效,请重新选择窗口");
if (source.thumbnail.isEmpty()) throw new RuntimeError("vision_lab_capture_failed", "目标窗口没有可捕获的画面");
const image = source.thumbnail.toPNG();
const width = source.thumbnail.getSize().width;
const height = source.thumbnail.getSize().height;
const detectStarted = Date.now();
const boxes = await detectUiElements({
image,
width,
height,
modelPath: request.modelPath,
scoreThreshold: clamp(request.scoreThreshold, 0.25, 0.01, 0.95),
iouThreshold: clamp(request.iouThreshold, 0.42, 0.05, 0.9)
});
return {
image: source.thumbnail.toDataURL(),
width,
height,
boxes,
captureMs: detectStarted - captureStarted,
detectMs: Date.now() - detectStarted,
modelPath: path.resolve(request.modelPath || defaultUiModelPath()),
sourceName: source.name || (sourceType === "screen" ? "屏幕" : "未命名窗口")
};
}

View File

@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent } from "../shared/types";
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent, VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types";
const api = {
app: {
@ -9,7 +9,7 @@ const api = {
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
},
desktop: {
pickElement: (options?: { ai?: boolean }) => ipcRenderer.invoke("desktop:pick-element", options || {}),
pickElement: () => ipcRenderer.invoke("desktop:pick-element"),
pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'),
pickRegion: () => ipcRenderer.invoke('desktop:pick-region')
},
@ -32,6 +32,11 @@ const api = {
pickElement: (options?: { url?: string }): Promise<import("../shared/types").ElementPickResult> =>
ipcRenderer.invoke("browser:pick-element", options || {})
},
visionLab: {
sources: (): Promise<VisionLabSource[]> => ipcRenderer.invoke("vision-lab:sources"),
detect: (request: VisionLabDetectRequest): Promise<VisionLabDetection> => ipcRenderer.invoke("vision-lab:detect", request),
defaultModel: (): Promise<string> => ipcRenderer.invoke("vision-lab:default-model")
},
settings: {
get: (): Promise<AppSettings> => ipcRenderer.invoke("settings:get"),
update: (patch: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke("settings:update", patch || {})

View File

@ -1,16 +1,17 @@
import { useCallback, useEffect, useState } from "react";
import { Bot, CircleAlert, Loader2, ListChecks } from "lucide-react";
import { Bot, CircleAlert, Loader2, ListChecks, Microscope } from "lucide-react";
import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types";
import { useEditorStore } from "./store/editorStore";
import { useRunStore } from "./store/runStore";
import { EditorPage } from "./pages/EditorPage";
import { RunsPage } from "./pages/RunsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { VisionLabPage } from "./pages/VisionLabPage";
import { TasksPage } from "./pages/TasksPage";
import { railIcons } from "./components/icons";
import { validateFlow } from "./validation/flowValidation";
type Page = "tasks" | "editor" | "runs" | "settings";
type Page = "tasks" | "editor" | "runs" | "settings" | "visionLab";
interface ToastState {
id: number;
@ -235,6 +236,14 @@ export function App() {
<railIcons.settings className="icon" />
设置
</button>
<button
type="button"
className={`rail-item${page === "visionLab" ? " active" : ""}`}
onClick={() => setPage("visionLab")}
>
<Microscope className="icon" />
实验台
</button>
<span className="rail-spacer" />
<div className="rail-user">
<span className="rail-avatar">RA</span>
@ -259,6 +268,7 @@ export function App() {
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
{page === "runs" && <RunsPage onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
{page === "settings" && <SettingsPage catalog={catalog} />}
{page === "visionLab" && <VisionLabPage />}
</main>
{toast && (
@ -272,3 +282,4 @@ export function App() {
}

View File

@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FolderOpen, Play, Pause, RefreshCw, Sparkles } from "lucide-react";
import type { VisionLabDetection, VisionLabSource } from "@shared/types";
function fileName(value: string): string {
const normalized = value.replace(/[\\/]+$/, "");
const index = Math.max(normalized.lastIndexOf("\\"), normalized.lastIndexOf("/"));
return index >= 0 ? normalized.slice(index + 1) : normalized;
}
export function VisionLabPage() {
const [sources, setSources] = useState<VisionLabSource[]>([]);
const [sourceId, setSourceId] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [customModel, setCustomModel] = useState<string | null>(null);
const [score, setScore] = useState(0.25);
const [overlap, setOverlap] = useState(0.42);
const [intervalMs, setIntervalMs] = useState(600);
const [continuous, setContinuous] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detection, setDetection] = useState<VisionLabDetection | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const runTokenRef = useRef(0);
const selectedSource = sources.find(source => source.id === sourceId);
const activeModel = customModel || defaultModel;
const refreshSources = useCallback(async () => {
try {
const next = await window.rpa.visionLab.sources();
setSources(next);
setSourceId(current => next.some(source => source.id === current) ? current : next[0]?.id || "");
setError(null);
} catch (refreshError) {
setError(refreshError instanceof Error ? refreshError.message : String(refreshError));
}
}, []);
useEffect(() => {
void refreshSources();
void window.rpa.visionLab.defaultModel().then(setDefaultModel).catch(error => {
setError(error instanceof Error ? error.message : String(error));
});
}, [refreshSources]);
const detectOnce = useCallback(async () => {
if (!sourceId) return;
setBusy(true);
try {
const result = await window.rpa.visionLab.detect({
sourceId,
modelPath: customModel || undefined,
scoreThreshold: score,
iouThreshold: overlap
});
setDetection(result);
setError(null);
} catch (detectError) {
setError(detectError instanceof Error ? detectError.message : String(detectError));
} finally {
setBusy(false);
}
}, [customModel, overlap, score, sourceId]);
useEffect(() => {
if (!continuous || !sourceId) return;
const token = ++runTokenRef.current;
let alive = true;
const tick = async () => {
while (alive) {
const started = Date.now();
try {
const result = await window.rpa.visionLab.detect({
sourceId,
modelPath: customModel || undefined,
scoreThreshold: score,
iouThreshold: overlap
});
if (!alive || token !== runTokenRef.current) return;
setDetection(result);
setError(null);
} catch (detectError) {
if (!alive || token !== runTokenRef.current) return;
setError(detectError instanceof Error ? detectError.message : String(detectError));
}
const wait = Math.max(80, intervalMs - (Date.now() - started));
await new Promise(resolve => setTimeout(resolve, wait));
}
};
void tick();
return () => { alive = false; };
}, [continuous, customModel, intervalMs, overlap, score, sourceId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !detection) return;
const image = new Image();
image.onload = () => {
canvas.width = detection.width;
canvas.height = detection.height;
const context = canvas.getContext("2d");
if (!context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const scale = Math.max(1, detection.width / 1000);
context.lineWidth = 2 * scale;
context.strokeStyle = "#22d3ee";
context.font = `${Math.round(14 * scale)}px Microsoft YaHei, Arial, sans-serif`;
context.textBaseline = "top";
for (const box of detection.boxes) {
context.strokeRect(box.x, box.y, box.width, box.height);
const label = `${box.category} ${Math.round(box.confidence * 100)}%`;
const metrics = context.measureText(label);
const labelWidth = metrics.width + 8 * scale;
const labelHeight = 19 * scale;
const labelY = box.y >= labelHeight + 2 * scale ? box.y - labelHeight - 2 * scale : box.y + 2 * scale;
context.fillStyle = "#0e7490";
context.fillRect(box.x, labelY, labelWidth, labelHeight);
context.fillStyle = "#ffffff";
context.fillText(label, box.x + 4 * scale, labelY + 3 * scale);
}
};
image.src = detection.image;
}, [detection]);
const chooseModel = async () => {
const selected = await window.rpa.dialog.open({
title: "选择 ONNX UI 检测模型",
filters: [{ name: "ONNX 模型", extensions: ["onnx"] }]
});
if (selected) setCustomModel(selected);
};
const modelLabel = useMemo(() => customModel ? fileName(customModel) : "内置 OmniParser v2", [customModel]);
const boxes = detection?.boxes || [];
return (
<div className="vision-lab">
<aside className="panel vision-lab-side">
<div className="panel-head">
<strong><Sparkles className="icon" /> AI 识别实验台</strong>
<span className="panel-subtitle">持续识别 · 选窗口 · 选模型</span>
</div>
<div className="vision-block">
<label className="field-label" htmlFor="vision-source">识别目标</label>
<div className="vision-row">
<select
id="vision-source"
className="text-input"
value={sourceId}
onChange={event => setSourceId(event.target.value)}
>
{!sources.length && <option value="">正在读取窗口…</option>}
{sources.map(source => (
<option key={source.id} value={source.id}>
{source.kind === "screen" ? "屏幕 · " : "窗口 · "}{source.name}
</option>
))}
</select>
<button className="btn icon-btn" type="button" onClick={() => void refreshSources()} title="刷新窗口列表">
<RefreshCw className="icon" />
</button>
</div>
{selectedSource && (
<div className="source-preview">
<img src={selectedSource.thumbnail} alt={selectedSource.name} />
</div>
)}
</div>
<div className="vision-block">
<span className="field-label">识别模型</span>
<div className="vision-row">
<button
type="button"
className={`btn${customModel ? "" : " btn-primary"}`}
onClick={() => setCustomModel(null)}
>
内置 OmniParser v2
</button>
<button type="button" className={`btn${customModel ? " btn-primary" : ""}`} onClick={() => void chooseModel()}>
<FolderOpen className="icon" />
选择 ONNX
</button>
</div>
<div className="model-path" title={activeModel || ""}>{activeModel || "模型未找到"}</div>
</div>
<div className="vision-block">
<label className="field-label" htmlFor="vision-score">置信度阈值:{score.toFixed(2)}</label>
<input id="vision-score" type="range" min={0.05} max={0.9} step={0.01} value={score}
onChange={event => setScore(Number(event.target.value))} />
<label className="field-label" htmlFor="vision-overlap">重叠抑制:{overlap.toFixed(2)}</label>
<input id="vision-overlap" type="range" min={0.1} max={0.85} step={0.01} value={overlap}
onChange={event => setOverlap(Number(event.target.value))} />
<label className="field-label" htmlFor="vision-interval">识别间隔:{intervalMs} ms</label>
<input id="vision-interval" type="range" min={100} max={3000} step={50} value={intervalMs}
onChange={event => setIntervalMs(Number(event.target.value))} />
</div>
<div className="vision-actions">
<button className="btn" type="button" onClick={() => void detectOnce()} disabled={!sourceId || busy}>
识别一次
</button>
<button className={`btn${continuous ? " btn-danger" : " btn-primary"}`} type="button"
onClick={() => setContinuous(value => !value)} disabled={!sourceId}>
{continuous ? <Pause className="icon" /> : <Play className="icon" />}
{continuous ? "停止连续" : "开始连续"}
</button>
</div>
{error && <div className="vision-error">{error}</div>}
</aside>
<section className="panel vision-lab-stage">
<div className="vision-stage-head">
<strong>{detection?.sourceName || selectedSource?.name || "未选择目标"}</strong>
<span>{boxes.length} 个候选 · 采集 {detection?.captureMs ?? "-"}ms · 推理 {detection?.detectMs ?? "-"}ms</span>
</div>
<div className="vision-stage">
{detection ? (
<canvas ref={canvasRef} className="vision-canvas" />
) : (
<div className="vision-empty">选择窗口和模型后开始识别</div>
)}
</div>
</section>
</div>
);
}

View File

@ -464,10 +464,10 @@ function FieldControl({
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`);
};
const pickDesktop = async (ai = false) => {
const pickDesktop = async () => {
setPicking(true);
try {
const picked = await window.rpa.desktop.pickElement({ ai });
const picked = await window.rpa.desktop.pickElement();
if (picked.cancelled) {
onToast("已取消拾取");
return;
@ -647,12 +647,9 @@ function FieldControl({
/>
{isGroundTarget ? (
<>
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop(false)} title="从桌面拾取元素(移到目标按空格)">
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)">
{picking ? "桌面拾取中" : "桌面拾取"}
</button>
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop(true)} title="AI 视觉拾取;UIA 类型为 Pane 时也可在桌面拾取中按 F2">
{picking ? "AI识别中" : "AI拾取"}
</button>
</>
) : (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">

View File

@ -3444,3 +3444,187 @@ textarea:focus-visible {
.icon-btn.refresh-btn.refreshing .icon {
animation: spin 0.8s linear infinite;
}
.vision-lab {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 18px;
height: 100%;
min-height: 0;
padding: 18px;
}
.vision-lab-side {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: auto;
padding: 18px;
}
.vision-lab-side .panel-head {
align-items: center;
display: flex;
flex-direction: column;
gap: 4px;
}
.vision-lab-side .panel-head strong {
align-items: center;
display: flex;
font-size: 16px;
gap: 8px;
}
.vision-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.vision-row {
align-items: center;
display: flex;
gap: 8px;
}
.vision-row select {
min-width: 0;
flex: 1;
}
.vision-row .btn {
flex: none;
}
.icon-btn {
padding-left: 10px;
padding-right: 10px;
}
.source-preview {
border: 1px solid #d8e0ea;
border-radius: 10px;
overflow: hidden;
}
.source-preview img {
display: block;
height: 116px;
object-fit: cover;
width: 100%;
}
.model-path {
border-radius: 8px;
background: #f3f6fa;
color: #64748b;
font-size: 12px;
line-height: 1.4;
max-height: 52px;
overflow: auto;
padding: 7px 9px;
word-break: break-all;
}
.vision-actions {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
margin-top: auto;
}
.vision-error {
background: #fff1f1;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.45;
padding: 9px 10px;
white-space: pre-wrap;
}
.vision-lab-stage {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 18px;
}
.vision-stage-head {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
}
.vision-stage-head strong {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vision-stage-head span {
color: #64748b;
flex: none;
font-size: 12px;
}
.vision-stage {
align-items: center;
background: #102033;
border-radius: 12px;
display: flex;
flex: 1;
justify-content: center;
min-height: 0;
overflow: hidden;
position: relative;
}
.vision-image {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.vision-box {
border: 2px solid #22d3ee;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .35) inset;
position: absolute;
}
.vision-box span {
background: #0e7490;
color: white;
display: inline-block;
font-size: 11px;
line-height: 17px;
padding: 0 5px;
transform: translateY(-100%);
white-space: nowrap;
}
.vision-empty {
align-items: center;
color: #94a3b8;
display: flex;
height: 100%;
justify-content: center;
}
.vision-canvas {
display: block;
height: auto;
max-height: 100%;
max-width: 100%;
width: auto;
}

View File

@ -247,3 +247,38 @@ export interface RunEvent {
startedAt?: string;
}
export interface VisionLabSource {
id: string;
kind: "window" | "screen";
name: string;
thumbnail: string;
icon?: string;
}
export interface VisionLabBox {
x: number;
y: number;
width: number;
height: number;
category: string;
confidence: number;
}
export interface VisionLabDetectRequest {
sourceId: string;
modelPath?: string;
scoreThreshold?: number;
iouThreshold?: number;
}
export interface VisionLabDetection {
image: string;
width: number;
height: number;
boxes: VisionLabBox[];
captureMs: number;
detectMs: number;
modelPath: string;
sourceName: string;
}