feat: 新增桌面AI拾取功能,支持视觉识别控件
This commit is contained in:
1 parent
f74d1865da
commit
85dcb94056
12 files changed
+910
-53
No files matched your search
@@ -1,4 +1,12 @@
|
|||||||
param([string]$Mode = "element")
|
param(
|
||||||
|
[string]$Mode = "element",
|
||||||
|
[int]$CaptureX = 0,
|
||||||
|
[int]$CaptureY = 0,
|
||||||
|
[int]$CaptureWidth = 1000,
|
||||||
|
[int]$CaptureHeight = 700,
|
||||||
|
[string]$CapturePath = "",
|
||||||
|
[switch]$CaptureExact
|
||||||
|
)
|
||||||
$ErrorActionPreference = "Continue";
|
$ErrorActionPreference = "Continue";
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
||||||
$logPath = "D:/RPA/test-output/desktop-picker.log";
|
$logPath = "D:/RPA/test-output/desktop-picker.log";
|
||||||
@@ -14,6 +22,80 @@ Add-Type -AssemblyName System.Windows.Forms
|
|||||||
Write-PickLog "assemblies ok"
|
Write-PickLog "assemblies ok"
|
||||||
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int key); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder sb, int max); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);' -Name PickW32 -Namespace RpaPick | Out-Null
|
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int key); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder sb, int max); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);' -Name PickW32 -Namespace RpaPick | Out-Null
|
||||||
Write-PickLog "w32 ok"
|
Write-PickLog "w32 ok"
|
||||||
|
if ($Mode -eq "capture") {
|
||||||
|
try { [RpaPick.PickDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex);' -Name PickMetrics -Namespace RpaPickCapture | Out-Null
|
||||||
|
$vsX = [RpaPickCapture.PickMetrics]::GetSystemMetrics(76)
|
||||||
|
$vsY = [RpaPickCapture.PickMetrics]::GetSystemMetrics(77)
|
||||||
|
$vsW = [RpaPickCapture.PickMetrics]::GetSystemMetrics(78)
|
||||||
|
$vsH = [RpaPickCapture.PickMetrics]::GetSystemMetrics(79)
|
||||||
|
$cropW = [Math]::Min($CaptureWidth, $vsW)
|
||||||
|
$cropH = [Math]::Min($CaptureHeight, $vsH)
|
||||||
|
if ($CaptureExact) {
|
||||||
|
$cropX = [Math]::Max($vsX, [Math]::Min($CaptureX, ($vsX + $vsW - $cropW)))
|
||||||
|
$cropY = [Math]::Max($vsY, [Math]::Min($CaptureY, ($vsY + $vsH - $cropH)))
|
||||||
|
} else {
|
||||||
|
$cropX = [Math]::Max($vsX, [Math]::Min($CaptureX - [int]($cropW / 2), ($vsX + $vsW - $cropW)))
|
||||||
|
$cropY = [Math]::Max($vsY, [Math]::Min($CaptureY - [int]($cropH / 2), ($vsY + $vsH - $cropH)))
|
||||||
|
}
|
||||||
|
$markerX = $CaptureX - $cropX
|
||||||
|
$markerY = $CaptureY - $cropY
|
||||||
|
if ($CapturePath) { $imagePath = $CapturePath } else { $imagePath = Join-Path $env:TEMP ("ai-pick-" + [Guid]::NewGuid().ToString("N") + ".png") }
|
||||||
|
$imageDir = Split-Path -Parent $imagePath
|
||||||
|
if (-not (Test-Path -LiteralPath $imageDir)) { New-Item -ItemType Directory -Path $imageDir -Force | Out-Null }
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $cropW, $cropH
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.CopyFromScreen($cropX, $cropY, 0, 0, $bitmap.Size)
|
||||||
|
$pen = New-Object System.Drawing.Pen([System.Drawing.Color]::Red, 3)
|
||||||
|
try {
|
||||||
|
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||||
|
if (-not $CaptureExact) {
|
||||||
|
$graphics.DrawLine($pen, ($markerX - 20), $markerY, ($markerX + 20), $markerY)
|
||||||
|
$graphics.DrawLine($pen, $markerX, ($markerY - 20), $markerX, ($markerY + 20))
|
||||||
|
$graphics.DrawRectangle($pen, ($markerX - 9), ($markerY - 9), 18, 18)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$pen.Dispose(); $graphics.Dispose()
|
||||||
|
}
|
||||||
|
$bitmap.Save($imagePath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bitmap.Dispose()
|
||||||
|
$windowTitle = ""
|
||||||
|
$uiaName = ""
|
||||||
|
$uiaType = ""
|
||||||
|
try {
|
||||||
|
$pt = New-Object System.Windows.Point($CaptureX, $CaptureY)
|
||||||
|
$ae = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
|
||||||
|
if ($ae) {
|
||||||
|
$ac = $ae.Current
|
||||||
|
$uiaName = [string]$ac.Name
|
||||||
|
$uiaType = $ac.ControlType.ProgrammaticName.Replace("ControlType.", "")
|
||||||
|
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||||
|
$cur = $ae
|
||||||
|
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
|
||||||
|
$windowTitle = [string]$cur.Current.Name
|
||||||
|
$cur = $walker.GetParent($cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
$result = @{
|
||||||
|
aiCapture = $true
|
||||||
|
cursorX = $CaptureX
|
||||||
|
cursorY = $CaptureY
|
||||||
|
cropX = $cropX
|
||||||
|
cropY = $cropY
|
||||||
|
cropW = $cropW
|
||||||
|
cropH = $cropH
|
||||||
|
markerX = $markerX
|
||||||
|
markerY = $markerY
|
||||||
|
window = $windowTitle
|
||||||
|
uiaName = $uiaName
|
||||||
|
uiaType = $uiaType
|
||||||
|
imagePath = $imagePath
|
||||||
|
}
|
||||||
|
Write-Output (ConvertTo-Json $result -Compress)
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
if ($Mode -eq "windows") {
|
if ($Mode -eq "windows") {
|
||||||
$wins = @()
|
$wins = @()
|
||||||
foreach ($p in Get-Process) { if ($p.MainWindowHandle -ne [IntPtr]::Zero -and $p.MainWindowTitle) { $wins += @{ title = $p.MainWindowTitle; process = $p.ProcessName } } }
|
foreach ($p in Get-Process) { if ($p.MainWindowHandle -ne [IntPtr]::Zero -and $p.MainWindowTitle) { $wins += @{ title = $p.MainWindowTitle; process = $p.ProcessName } } }
|
||||||
@@ -104,6 +186,7 @@ $selIdx = -1
|
|||||||
$prevSpace = $false
|
$prevSpace = $false
|
||||||
$prevPgUp = $false
|
$prevPgUp = $false
|
||||||
$prevPgDn = $false
|
$prevPgDn = $false
|
||||||
|
$prevF2 = $false
|
||||||
Write-PickLog "loop start"
|
Write-PickLog "loop start"
|
||||||
while ($true) {
|
while ($true) {
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +194,7 @@ while ($true) {
|
|||||||
$esc = (([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8001) -ne 0)
|
$esc = (([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8001) -ne 0)
|
||||||
$pgUp = (([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8001) -ne 0)
|
$pgUp = (([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8001) -ne 0)
|
||||||
$pgDn = (([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8001) -ne 0)
|
$pgDn = (([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8001) -ne 0)
|
||||||
|
$f2 = (([RpaPick.PickW32]::GetAsyncKeyState(113) -band 0x8001) -ne 0)
|
||||||
if ($esc) { Write-PickLog "esc"; break }
|
if ($esc) { Write-PickLog "esc"; break }
|
||||||
if ($stage -eq "hover") {
|
if ($stage -eq "hover") {
|
||||||
$ptX = [System.Windows.Forms.Cursor]::Position.X
|
$ptX = [System.Windows.Forms.Cursor]::Position.X
|
||||||
@@ -119,9 +203,24 @@ while ($true) {
|
|||||||
if ($el) {
|
if ($el) {
|
||||||
$c = $el.Current
|
$c = $el.Current
|
||||||
$ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "")
|
$ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "")
|
||||||
$r = $c.BoundingRectangle; Set-HighlightBox ([int]$r.X) ([int]$r.Y) ([int]$r.Width) ([int]$r.Height)
|
$r = $c.BoundingRectangle
|
||||||
$infoLabel.Text = "名称: " + $c.Name + " 类型: " + $ctrlName + " [空格]锁定 [Esc]取消"
|
$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]取消"
|
||||||
} else { Hide-Highlight }
|
} else { Hide-Highlight }
|
||||||
|
if ($f2 -and -not $prevF2 -and $el -and $ctrlName -eq "Pane") {
|
||||||
|
Write-PickLog "f2 ai"
|
||||||
|
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||||
|
$cur = $el
|
||||||
|
$topTitle = ""
|
||||||
|
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
|
||||||
|
$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 }
|
||||||
|
break
|
||||||
|
}
|
||||||
if ($space -and -not $prevSpace -and $el) {
|
if ($space -and -not $prevSpace -and $el) {
|
||||||
Write-PickLog "space lock"
|
Write-PickLog "space lock"
|
||||||
$stage = "locked"
|
$stage = "locked"
|
||||||
@@ -162,6 +261,7 @@ while ($true) {
|
|||||||
$prevSpace = $space
|
$prevSpace = $space
|
||||||
$prevPgUp = $pgUp
|
$prevPgUp = $pgUp
|
||||||
$prevPgDn = $pgDn
|
$prevPgDn = $pgDn
|
||||||
|
$prevF2 = $f2
|
||||||
Start-Sleep -Milliseconds 60
|
Start-Sleep -Milliseconds 60
|
||||||
}
|
}
|
||||||
$info.Close()
|
$info.Close()
|
||||||
|
|||||||
@@ -1,33 +1,87 @@
|
|||||||
import { loadSettings } from "../settings";
|
import { loadSettings } from "../settings";
|
||||||
import { RuntimeError } from "../engine/types";
|
import { RuntimeError } from "../engine/types";
|
||||||
|
|
||||||
export interface ChatMessage { role: "system" | "user" | "assistant"; content: string }
|
export type ChatContentPart =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image_url"; image_url: { url: string } };
|
||||||
|
|
||||||
export function getAiConfig(): { baseUrl: string; apiKey: string; model: string } {
|
export interface ChatMessage {
|
||||||
return loadSettings().aiConfig;
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | ChatContentPart[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAiConfig() {
|
export interface ResolvedAiConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
visionModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiConfig(): ResolvedAiConfig {
|
||||||
|
const config = loadSettings().aiConfig;
|
||||||
|
return { ...config, visionModel: config.visionModel || config.model };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAiConfig(options: { vision?: boolean } = {}): ResolvedAiConfig {
|
||||||
const config = getAiConfig();
|
const config = getAiConfig();
|
||||||
if (!config.baseUrl || !config.model) {
|
if (!config.baseUrl || !config.model || (options.vision && !config.visionModel)) {
|
||||||
throw new RuntimeError("ai_not_configured", "AI 服务未配置", { recommendation: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)" });
|
throw new RuntimeError("ai_not_configured", "AI 服务未配置", {
|
||||||
|
recommendation: options.vision
|
||||||
|
? "到 设置 → AI 服务 填写 Base URL 与多模态视觉模型(兼容 OpenAI 协议)"
|
||||||
|
: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number } = {}): Promise<string> {
|
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number; vision?: boolean } = {}): Promise<string> {
|
||||||
const config = requireAiConfig();
|
const config = requireAiConfig({ vision: options.vision });
|
||||||
const base = config.baseUrl.replace(/\/+$/, "");
|
const base = config.baseUrl.replace(/\/+$/, "");
|
||||||
const response = await fetch(base + "/chat/completions", {
|
const maxAttempts = 3;
|
||||||
signal: AbortSignal.timeout(170000),
|
|
||||||
method: "POST",
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
let response: Response;
|
||||||
body: JSON.stringify({ model: config.model, messages, temperature: options.temperature ?? 0.3 })
|
try {
|
||||||
});
|
response = await fetch(base + "/chat/completions", {
|
||||||
if (!response.ok) {
|
signal: AbortSignal.timeout(170000),
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: options.vision ? config.visionModel : config.model,
|
||||||
|
messages,
|
||||||
|
temperature: options.temperature ?? 0.3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxAttempts) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1200 * attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
return data.choices?.[0]?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
const detail = (await response.text()).slice(0, 300);
|
const detail = (await response.text()).slice(0, 300);
|
||||||
|
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
||||||
|
if (retryable && attempt < maxAttempts) {
|
||||||
|
const retryAfter = Number(response.headers.get("retry-after"));
|
||||||
|
const fallbackDelay = 1400 * attempt;
|
||||||
|
const delay = Number.isFinite(retryAfter) && retryAfter >= 0 && retryAfter <= 15
|
||||||
|
? retryAfter * 1000
|
||||||
|
: fallbackDelay;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
const suffix = detail ? " 详情:" + detail.slice(0, 180) : "";
|
||||||
|
throw new RuntimeError("ai_rate_limited", "AI 服务限流或配额不足(HTTP 429),请稍后重试或更换可用视觉模型。" + suffix);
|
||||||
|
}
|
||||||
|
|
||||||
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
|
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
|
||||||
return data.choices?.[0]?.message?.content ?? "";
|
throw new RuntimeError("ai_request_failed", "AI 请求失败:已重试仍未成功");
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,14 @@ describe("desktop UIA ground capabilities", () => {
|
|||||||
expect(s.name).toBe("确定");
|
expect(s.name).toBe("确定");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects selector without any condition", () => {
|
it("parses AI screen coordinate selector", () => {
|
||||||
expect(() => parseUiaSelector("window=只有窗口")).toThrow();
|
const s = parseUiaSelector("screen=100,200,320,80;window= MyApp;ai=1");
|
||||||
|
expect(s.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
|
||||||
|
expect(s.window).toBe("MyApp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid selector", () => {
|
||||||
expect(() => parseUiaSelector("")).toThrow();
|
expect(() => parseUiaSelector("")).toThrow();
|
||||||
|
expect(() => parseUiaSelector("screen=1,2,3")).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ interface UiaSelector {
|
|||||||
id?: string;
|
id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
screen?: { x: number; y: number; w: number; h: number };
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,12 +42,19 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
|
|||||||
else if (key === "id" || key === "automationid") selector.id = 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.type = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||||
else if (key === "class" || key === "classname") selector.className = value;
|
else if (key === "class" || key === "classname") selector.className = value;
|
||||||
|
else if (key === "screen") {
|
||||||
|
const nums = value.split(/[,,]/).map(item => Number(item.trim()));
|
||||||
|
if (nums.length !== 4 || nums.some(n => !Number.isFinite(n))) {
|
||||||
|
throw new RuntimeError("validation_error", "screen 坐标格式无效:" + value, { recommendation: "使用 screen=x,y,width,height" });
|
||||||
|
}
|
||||||
|
selector.screen = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
||||||
|
}
|
||||||
else if (key === "index") {
|
else if (key === "index") {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
|
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!selector.name && !selector.id && !selector.className && !selector.type) {
|
if (!selector.name && !selector.id && !selector.className && !selector.type && !selector.screen) {
|
||||||
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
|
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
|
||||||
}
|
}
|
||||||
return selector;
|
return selector;
|
||||||
@@ -119,6 +127,73 @@ function target(args: Record<string, unknown>, node: { id: string; componentId:
|
|||||||
return { selector: parseUiaSelector(raw), args, node };
|
return { selector: parseUiaSelector(raw), args, node };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 } {
|
||||||
|
return selector.screen!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickScreenScript(t: UiaTarget): string {
|
||||||
|
const rect = screenCenter(t.selector);
|
||||||
|
const x = Math.round(rect.x + rect.w / 2);
|
||||||
|
const y = Math.round(rect.y + rect.h / 2);
|
||||||
|
const clickway = String(t.args.clickway ?? "0");
|
||||||
|
const doubleClick = clickway === "1" || /double/i.test(clickway);
|
||||||
|
return `${SCREEN_USER32}
|
||||||
|
if (-not [RpaScreen.RpaScreenInput]::SetCursorPos(${x}, ${y})) { throw "cursor_move_failed" }
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 40
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||||||
|
if (${doubleClick}) {
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 40
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||||||
|
}
|
||||||
|
Write-Output (@{ clicked = $true; method = 'screen_coordinate'; x = ${x}; y = ${y} } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValueScreenScript(t: UiaTarget, value: string): string {
|
||||||
|
const rect = screenCenter(t.selector);
|
||||||
|
const x = Math.round(rect.x + rect.w / 2);
|
||||||
|
const y = Math.round(rect.y + rect.h / 2);
|
||||||
|
return `${SCREEN_USER32}
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
[RpaScreen.RpaScreenInput]::SetCursorPos(${x}, ${y}) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 40
|
||||||
|
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 220
|
||||||
|
$saved = $null
|
||||||
|
try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}
|
||||||
|
try { [System.Windows.Forms.Clipboard]::SetText(${psString(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 {}
|
||||||
|
Write-Output (@{ set = $true; method = 'screen_coordinate_paste'; x = ${x}; y = ${y} } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueScreenScript(t: UiaTarget): string {
|
||||||
|
const rect = screenCenter(t.selector);
|
||||||
|
const x = Math.round(rect.x + rect.w / 2);
|
||||||
|
const y = Math.round(rect.y + rect.h / 2);
|
||||||
|
return `${UIA_USING}
|
||||||
|
Add-Type -AssemblyName WindowsBase
|
||||||
|
$pt = New-Object System.Windows.Point(${x}, ${y})
|
||||||
|
$el = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
|
||||||
|
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 {}
|
||||||
|
Write-Output (@{ value = $value; name = $el.Current.Name } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
|
||||||
function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string {
|
function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string {
|
||||||
const s = t.selector;
|
const s = t.selector;
|
||||||
return `${UIA_USING}
|
return `${UIA_USING}
|
||||||
|
|||||||
+619
-11
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, screen } from "electron";
|
import { app, BrowserWindow, screen } from "electron";
|
||||||
import { RuntimeError } from "./engine/types";
|
import { RuntimeError } from "./engine/types";
|
||||||
|
import { chatCompletion, requireAiConfig } from "./capabilities/ai-service";
|
||||||
|
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ function pickerScriptPath(): string {
|
|||||||
if (!found) throw new RuntimeError("picker_unavailable", "桌面拾取脚本缺失", { recommendation: "重新安装应用" });
|
if (!found) throw new RuntimeError("picker_unavailable", "桌面拾取脚本缺失", { recommendation: "重新安装应用" });
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopPickResult {
|
export interface DesktopPickResult {
|
||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
window?: string;
|
window?: string;
|
||||||
@@ -24,36 +26,641 @@ export interface DesktopPickResult {
|
|||||||
automationId?: string;
|
automationId?: string;
|
||||||
controlType?: string;
|
controlType?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
source?: "uia" | "ai";
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
confidence?: number;
|
||||||
|
screenshotPath?: string;
|
||||||
|
aiRequested?: boolean;
|
||||||
selector: string;
|
selector: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pickDesktopElement(): Promise<DesktopPickResult> {
|
interface UiaPickOutput {
|
||||||
|
cancelled?: boolean;
|
||||||
|
aiRequested?: boolean;
|
||||||
|
cursorX?: number;
|
||||||
|
cursorY?: number;
|
||||||
|
paneX?: number;
|
||||||
|
paneY?: number;
|
||||||
|
paneW?: number;
|
||||||
|
paneH?: number;
|
||||||
|
window?: string;
|
||||||
|
name?: string;
|
||||||
|
automationId?: string;
|
||||||
|
controlType?: string;
|
||||||
|
className?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiCandidate {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
name: string;
|
||||||
|
controlType: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiCaptureOutput {
|
||||||
|
cursorX: number;
|
||||||
|
cursorY: number;
|
||||||
|
cropX: number;
|
||||||
|
cropY: number;
|
||||||
|
cropW: number;
|
||||||
|
cropH: number;
|
||||||
|
markerX: number;
|
||||||
|
markerY: number;
|
||||||
|
window?: string;
|
||||||
|
uiaName?: string;
|
||||||
|
uiaType?: string;
|
||||||
|
imagePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonLine<T>(stdout: string, startsWith: "{" | "["): T {
|
||||||
|
const line = stdout.trim().split(/\r?\n/).reverse().find(item => item.trim().startsWith(startsWith));
|
||||||
|
if (!line) throw new RuntimeError("picker_failed", "桌面拾取未返回结果");
|
||||||
|
return JSON.parse(line) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
||||||
for (const w of appWindows) w.minimize();
|
for (const w of appWindows) w.minimize();
|
||||||
try {
|
try {
|
||||||
const { stdout } = await run("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath()], { timeout: 180000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
if (options.ai) return await runAiPick();
|
||||||
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("{"));
|
const { stdout } = await run(
|
||||||
if (!jsonLine) throw new RuntimeError("picker_failed", "拾取未返回结果");
|
"powershell.exe",
|
||||||
const parsed = JSON.parse(jsonLine) as Omit<DesktopPickResult, "selector">;
|
["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath()],
|
||||||
|
{ timeout: 180000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
||||||
|
);
|
||||||
|
const parsed = jsonLine<UiaPickOutput>(stdout, "{");
|
||||||
if (parsed.cancelled) return { ...parsed, selector: "" };
|
if (parsed.cancelled) return { ...parsed, selector: "" };
|
||||||
|
const paneAiReady = [parsed.paneX, parsed.paneY, parsed.paneW, parsed.paneH]
|
||||||
|
.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);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (parsed.window) parts.push("window=" + parsed.window);
|
if (parsed.window) parts.push("window=" + parsed.window);
|
||||||
if (parsed.name) parts.push("name=" + parsed.name);
|
if (parsed.name) parts.push("name=" + parsed.name);
|
||||||
if (parsed.controlType) parts.push("type=" + parsed.controlType);
|
if (parsed.controlType) parts.push("type=" + parsed.controlType);
|
||||||
if (parsed.automationId) parts.push("id=" + parsed.automationId);
|
if (parsed.automationId) parts.push("id=" + parsed.automationId);
|
||||||
return { ...parsed, selector: parts.join(";") };
|
return { ...parsed, source: "uia", selector: parts.join(";") };
|
||||||
} finally {
|
} finally {
|
||||||
for (const w of appWindows) {
|
for (const w of appWindows) {
|
||||||
if (!w.isDestroyed()) w.restore();
|
if (!w.isDestroyed()) w.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function listVisibleWindows(): Promise<Array<{ title: string; process: string }>> {
|
|
||||||
const { stdout } = await run("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(), "-Mode", "windows"], { timeout: 30000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
function paneAiOverlayHtml(): string {
|
||||||
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("["));
|
return `<!DOCTYPE html><html><head><meta charset='utf-8'><style>
|
||||||
if (!jsonLine) return [];
|
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;cursor:default;font-family:'Microsoft YaHei',Arial,sans-serif}
|
||||||
return JSON.parse(jsonLine) as Array<{ title: string; process: string }>;
|
#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}}
|
||||||
|
</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>
|
||||||
|
<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')}
|
||||||
|
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.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;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneAiRect(context: UiaPickOutput) {
|
||||||
|
const x = Math.round(Number(context.paneX));
|
||||||
|
const y = Math.round(Number(context.paneY));
|
||||||
|
const w = Math.round(Number(context.paneW));
|
||||||
|
const h = Math.round(Number(context.paneH));
|
||||||
|
if (![x, y, w, h].every(Number.isFinite) || w <= 4 || h <= 4) {
|
||||||
|
throw new RuntimeError("ai_pane_invalid", "Pane 区域无效");
|
||||||
|
}
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPaneAiOverlay(rect: { x: number; y: number; w: number; h: number }): Promise<BrowserWindow> {
|
||||||
|
const dipPoint = screen.screenToDipPoint({ x: rect.x, y: rect.y });
|
||||||
|
const display = displayForPoint(dipPoint.x, dipPoint.y);
|
||||||
|
const scale = display.scaleFactor || 1;
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
x: Math.round(dipPoint.x),
|
||||||
|
y: Math.round(dipPoint.y),
|
||||||
|
width: Math.max(24, Math.round(rect.w / scale)),
|
||||||
|
height: Math.max(24, Math.round(rect.h / scale)),
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
movable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
hasShadow: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
show: false,
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
|
||||||
|
});
|
||||||
|
win.setAlwaysOnTop(true, "screen-saver");
|
||||||
|
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||||
|
if (message.includes("pane-ai")) console.log("[pane-ai]", message, sourceId + ":" + line);
|
||||||
|
});
|
||||||
|
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(paneAiOverlayHtml()));
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
"window.__paneW=" + JSON.stringify(rect.w) + ";window.__paneH=" + JSON.stringify(rect.h) +
|
||||||
|
";window.__cancelled=false;window.__done=false;window.__selected=null;window.__hover=null;window.__hoverCandidate=null;startScan();"
|
||||||
|
);
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!result.length) throw new RuntimeError("ai_response_invalid", "AI 没有识别到可用候选区域");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPaneAiPick(context: UiaPickOutput): Promise<DesktopPickResult> {
|
||||||
|
requireAiConfig({ vision: true });
|
||||||
|
const pane = paneAiRect(context);
|
||||||
|
const win = await createPaneAiOverlay(pane);
|
||||||
|
|
||||||
|
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 理解中…');");
|
||||||
|
|
||||||
|
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") } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
], { 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));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let aiText: string;
|
||||||
|
try {
|
||||||
|
aiText = await Promise.race([aiRequest, cancelWatcher]) as string;
|
||||||
|
} finally {
|
||||||
|
aiSettled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + ");");
|
||||||
|
|
||||||
|
const deadline = Date.now() + 180000;
|
||||||
|
let selectedIndex: number | 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
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; selected: number | 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 (selectedIndex === null || !candidates[selectedIndex]) throw new RuntimeError("pick_cancelled", "AI 拾取超时");
|
||||||
|
|
||||||
|
const box = candidates[selectedIndex];
|
||||||
|
const x = capture.cropX + box.x;
|
||||||
|
const y = capture.cropY + box.y;
|
||||||
|
const selectorParts = [
|
||||||
|
"screen=" + [x, y, box.width, box.height].map(Math.round).join(","),
|
||||||
|
context.window ? "window=" + safeSelectorText(context.window) : "",
|
||||||
|
"name=" + safeSelectorText(box.name),
|
||||||
|
"type=" + safeSelectorText(box.controlType),
|
||||||
|
"ai=1"
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
source: "ai",
|
||||||
|
window: context.window,
|
||||||
|
name: box.name,
|
||||||
|
controlType: box.controlType,
|
||||||
|
x, y, w: box.width, h: box.height,
|
||||||
|
confidence: box.confidence,
|
||||||
|
screenshotPath: capture.imagePath,
|
||||||
|
selector: selectorParts.join(";")
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
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;
|
||||||
|
return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height;
|
||||||
|
}) || 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",
|
||||||
|
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(), "-Mode", "windows"],
|
||||||
|
{ timeout: 30000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
||||||
|
);
|
||||||
|
return jsonLine<Array<{ title: string; process: string }>>(stdout, "[");
|
||||||
|
}
|
||||||
|
|
||||||
export async function pickScreenRegion(): Promise<{ x: number; y: number; width: number; height: number; imagePath: string }> {
|
export async function pickScreenRegion(): Promise<{ x: number; y: number; width: number; height: number; imagePath: string }> {
|
||||||
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
||||||
for (const w of appWindows) w.minimize();
|
for (const w of appWindows) w.minimize();
|
||||||
@@ -80,6 +687,7 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
|
|||||||
"</script></body></html>"
|
"</script></body></html>"
|
||||||
];
|
];
|
||||||
void win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(htmlParts.join("")));
|
void win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(htmlParts.join("")));
|
||||||
|
win.show();
|
||||||
const deadline = Date.now() + 60000;
|
const deadline = Date.now() + 60000;
|
||||||
let region: { x: number; y: number; width: number; height: number } | null = null;
|
let region: { x: number; y: number; width: number; height: number } | null = null;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@ function registerIpc(): void {
|
|||||||
}
|
}
|
||||||
return session.pickElement();
|
return session.pickElement();
|
||||||
});
|
});
|
||||||
ipcMain.handle("desktop:pick-element", () => pickDesktopElement());
|
ipcMain.handle("desktop:pick-element", (_event, options?: { ai?: boolean }) => pickDesktopElement(options));
|
||||||
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
|
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
|
||||||
ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
|
ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
|
||||||
ipcMain.handle("flows:list", () => listFlows());
|
ipcMain.handle("flows:list", () => listFlows());
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ describe("app settings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns the default element wait when no settings file exists", () => {
|
it("returns the default element wait when no settings file exists", () => {
|
||||||
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists and clamps a user-adjustable element wait", async () => {
|
it("persists and clamps a user-adjustable element wait", async () => {
|
||||||
const saved = updateSettings({ browserElementWaitMs: 12500 });
|
const saved = updateSettings({ browserElementWaitMs: 12500 });
|
||||||
expect(saved).toEqual({ browserElementWaitMs: 12500, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
expect(saved).toEqual({ browserElementWaitMs: 12500, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
|
||||||
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
|
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
|
||||||
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
|
||||||
expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ import type { AppSettings } from "../shared/types";
|
|||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
browserElementWaitMs: 5000,
|
browserElementWaitMs: 5000,
|
||||||
aiConfig: { baseUrl: "", apiKey: "", model: "" }
|
aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache: AppSettings | null = null;
|
let cache: AppSettings | null = null;
|
||||||
@@ -25,7 +25,8 @@ function normalizeSettings(value: unknown): AppSettings {
|
|||||||
aiConfig: {
|
aiConfig: {
|
||||||
baseUrl: String(ai.baseUrl ?? ""),
|
baseUrl: String(ai.baseUrl ?? ""),
|
||||||
apiKey: String(ai.apiKey ?? ""),
|
apiKey: String(ai.apiKey ?? ""),
|
||||||
model: String(ai.model ?? "")
|
model: String(ai.model ?? ""),
|
||||||
|
visionModel: String(ai.visionModel ?? "")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const api = {
|
|||||||
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
|
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
|
||||||
},
|
},
|
||||||
desktop: {
|
desktop: {
|
||||||
pickElement: () => ipcRenderer.invoke('desktop:pick-element'),
|
pickElement: (options?: { ai?: boolean }) => ipcRenderer.invoke("desktop:pick-element", options || {}),
|
||||||
pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'),
|
pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'),
|
||||||
pickRegion: () => ipcRenderer.invoke('desktop:pick-region')
|
pickRegion: () => ipcRenderer.invoke('desktop:pick-region')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
|||||||
const [waitDraft, setWaitDraft] = useState("5000");
|
const [waitDraft, setWaitDraft] = useState("5000");
|
||||||
const [waitSaving, setWaitSaving] = useState(false);
|
const [waitSaving, setWaitSaving] = useState(false);
|
||||||
const [waitMessage, setWaitMessage] = useState<string | null>(null);
|
const [waitMessage, setWaitMessage] = useState<string | null>(null);
|
||||||
const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "" });
|
const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "", visionModel: "" });
|
||||||
const [aiSaving, setAiSaving] = useState(false);
|
const [aiSaving, setAiSaving] = useState(false);
|
||||||
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -24,7 +24,12 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
|||||||
void window.rpa.settings.get().then((value) => {
|
void window.rpa.settings.get().then((value) => {
|
||||||
setSettings(value);
|
setSettings(value);
|
||||||
setWaitDraft(String(value.browserElementWaitMs));
|
setWaitDraft(String(value.browserElementWaitMs));
|
||||||
setAiDraft(value.aiConfig ?? { baseUrl: "", apiKey: "", model: "" });
|
setAiDraft({
|
||||||
|
baseUrl: value.aiConfig?.baseUrl ?? "",
|
||||||
|
apiKey: value.aiConfig?.apiKey ?? "",
|
||||||
|
model: value.aiConfig?.model ?? "",
|
||||||
|
visionModel: value.aiConfig?.visionModel ?? ""
|
||||||
|
});
|
||||||
}).catch(() => setSettings(null));
|
}).catch(() => setSettings(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -152,6 +157,11 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
|||||||
<label className="field-label" htmlFor="ai-model">模型名称</label>
|
<label className="field-label" htmlFor="ai-model">模型名称</label>
|
||||||
<input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" />
|
<input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="ai-vision-model">视觉模型名称</label>
|
||||||
|
<input id="ai-vision-model" className="text-input" value={aiDraft.visionModel ?? ""} onChange={(event) => setAiDraft({ ...aiDraft, visionModel: event.target.value })} placeholder="gpt-4o-mini / qwen-vl-plus / 留空复用模型名称" />
|
||||||
|
<span className="field-hint">桌面 AI 拾取必须使用多模态视觉模型,并会上传鼠标附近的局部截图。</span>
|
||||||
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}>
|
<button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}>
|
||||||
<Save className="icon" />
|
<Save className="icon" />
|
||||||
|
|||||||
@@ -471,10 +471,10 @@ function FieldControl({
|
|||||||
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`);
|
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickDesktop = async () => {
|
const pickDesktop = async (ai = false) => {
|
||||||
setPicking(true);
|
setPicking(true);
|
||||||
try {
|
try {
|
||||||
const picked = await window.rpa.desktop.pickElement();
|
const picked = await window.rpa.desktop.pickElement({ ai });
|
||||||
if (picked.cancelled) {
|
if (picked.cancelled) {
|
||||||
onToast("已取消拾取");
|
onToast("已取消拾取");
|
||||||
return;
|
return;
|
||||||
@@ -653,9 +653,14 @@ function FieldControl({
|
|||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
/>
|
/>
|
||||||
{isGroundTarget ? (
|
{isGroundTarget ? (
|
||||||
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)">
|
<>
|
||||||
{picking ? "桌面拾取中" : "桌面拾取"}
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop(false)} title="从桌面拾取元素(移到目标按空格)">
|
||||||
</button>
|
{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="从运行浏览器拾取元素">
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">
|
||||||
{picking ? "拾取中" : "拾取"}
|
{picking ? "拾取中" : "拾取"}
|
||||||
|
|||||||
+2
-4
@@ -184,6 +184,8 @@ export interface AiServiceConfig {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
/** 桌面 AI 拾取使用的视觉模型;留空时复用 model。 */
|
||||||
|
visionModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
@@ -191,10 +193,6 @@ export interface AppSettings {
|
|||||||
aiConfig: AiServiceConfig;
|
aiConfig: AiServiceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
|
||||||
browserElementWaitMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElementPickResult {
|
export interface ElementPickResult {
|
||||||
selector: string;
|
selector: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
|
|||||||
Reference in new issue
Block a user