diff --git a/docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md b/docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md new file mode 100644 index 0000000..04eefa6 --- /dev/null +++ b/docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md @@ -0,0 +1,129 @@ +# A-route RPA 用户无感运行说明 + +日期:2026-07-11 + +## 目标 + +A-route 当前看重窗口和 UIA 控件,而不是背后的网络逻辑。这里的“用户无感”指: + +1. 不遮挡当前用户桌面。 +2. 不抢焦点。 +3. 不接管鼠标键盘。 +4. 数字员工仍然能通过 HWND/UIA 找到目标窗口和控件。 +5. 所有动作可记录、可复现、可回收。 + +## 当前已实现 + +脚本: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent +``` + +效果: + +- 从完整离线客户端目录启动 `IMPlatformClient.exe`。 +- 使用 `-WindowStyle Minimized` 降低启动阶段闪窗概率。 +- 找到真实 `IMPlatformClient` 窗口后移动到屏幕外: + - 主窗:`x=-32000, y=-32000` + - 提示窗:同样移到屏幕外 +- 设置 `SWP_NOACTIVATE`,不调用前台激活。 +- 不生成屏幕截图,避免因为窗口在屏幕外得到无意义图片。 +- 继续输出 UIA dump,供 RPA 选择器验证。 + +本轮验证结果: + +- 新启动进程:`IMPlatformClient.exe` +- PID:`20060` +- 主窗口:`0x340330` +- UIA 根控件: + - `automation_id=frmLogin` + - `framework_id=WinForm` + - `class_name=WindowsForms10.Window.8.app.0.d3a00f_r7_ad1` +- UIA dump 仍可读,即使窗口处于屏幕外: + - `is_offscreen=true` + - 子控件 `skinState`、`skinLoadPanel` 可见于控件树 + +## 推荐无感等级 + +### L1:当前用户会话内屏幕外运行 + +适合当前开发机验证: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent +``` + +优点: + +- 实现最快。 +- 不影响当前屏幕。 +- UIA 读控件可继续工作。 + +限制: + +- 如果某些发送动作必须依赖真实鼠标点击或 OCR 截图,屏幕外模式不适合。 +- 发送路径应优先使用 `ValuePattern`、`InvokePattern`、`WM_SETTEXT`、`BM_CLICK` 等 HWND/UIA/Win32 方式。 + +### L2:当前用户会话内可见但不激活 + +适合排查窗口状态: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -WindowMode Visible -NoActivate +``` + +优点: + +- 人能看到窗口。 +- 尽量不抢焦点。 + +限制: + +- 仍可能遮挡用户桌面。 +- 只适合调试,不适合长期运行。 + +### L3:独立 Windows 用户会话运行 + +这是生产上更稳的“真正无感”方案: + +- 给数字员工单独建一个 Windows 用户。 +- 在该用户会话里登录 iSphere。 +- RPA/WinHelper/MCP 都运行在同一个独立会话。 +- 业务用户使用自己的桌面,看不到数字员工窗口。 + +优点: + +- 不影响业务用户桌面。 +- 不抢业务用户焦点。 +- 可以保留真实可见窗口,兼容 OCR/坐标兜底。 + +限制: + +- 需要部署层面的账户和会话管理。 +- WinHelper 必须和目标窗口在同一交互式桌面会话里运行。 + +### L4:不用 RPA,走 B-route/API/sidecar + +这是最终最无感的方向: + +- 没窗口。 +- 不依赖桌面。 +- 不受焦点、分辨率、UI 改版影响。 + +但当前分支是 A-route RPA,所以本轮先把 L1/L2 做出来。 + +## 结论 + +当前已经具备 L1 无感基础能力: + +- 真实客户端可打开。 +- 可屏幕外运行。 +- 不需要前台焦点。 +- UIA 控件树仍可读取。 + +下一轮应把发送/搜索动作约束在非焦点方式上: + +1. 优先 UIA `ValuePattern`/`InvokePattern`。 +2. 其次 HWND 消息 `WM_SETTEXT`/`BM_CLICK`。 +3. 最后才考虑坐标/OCR;坐标/OCR 只能放到独立 Windows 用户会话里做,不能放当前用户桌面长期运行。 diff --git a/scripts/open-offline-real-client-window.ps1 b/scripts/open-offline-real-client-window.ps1 index 291480c..3677a6e 100644 --- a/scripts/open-offline-real-client-window.ps1 +++ b/scripts/open-offline-real-client-window.ps1 @@ -9,10 +9,14 @@ param( [int]$Y = 120, [int]$Width = 270, [int]$Height = 570, + [ValidateSet("Visible", "Offscreen", "Minimized")] + [string]$WindowMode = "Visible", [switch]$SkipExtract, [switch]$NoLaunch, [switch]$KeepExisting, [switch]$TopMost, + [switch]$NoActivate, + [switch]$UserSilent, [switch]$NoScreenshot ) @@ -20,6 +24,20 @@ $ErrorActionPreference = "Stop" $repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") $InfoPromptTitle = -join ([char[]](0x4FE1, 0x606F, 0x63D0, 0x793A)) +if ($UserSilent) { + $WindowMode = "Offscreen" + $NoActivate = $true + $NoScreenshot = $true + $TopMost = $false +} + +$effectiveX = $X +$effectiveY = $Y +if ($WindowMode -eq "Offscreen") { + $effectiveX = -32000 + $effectiveY = -32000 +} + function Resolve-RepoPath([string]$PathValue) { if ([System.IO.Path]::IsPathRooted($PathValue)) { return $PathValue @@ -89,8 +107,11 @@ public static class ISphereOfflineWindowApi { "@ -ErrorAction SilentlyContinue } -function Move-ClientWindows([object[]]$Windows, [int]$MainX, [int]$MainY, [int]$MainWidth, [int]$MainHeight, [bool]$MakeTopMost) { +function Move-ClientWindows([object[]]$Windows, [int]$MainX, [int]$MainY, [int]$MainWidth, [int]$MainHeight, [bool]$MakeTopMost, [bool]$Activate, [string]$Mode) { + $hwndTop = [IntPtr]::Zero $topMostHandle = [IntPtr]::new(-1) + $swpShowWindow = 0x0040 + $swpNoActivate = 0x0010 $mainWindow = $null $promptWindow = $null foreach ($window in $Windows) { @@ -101,18 +122,32 @@ function Move-ClientWindows([object[]]$Windows, [int]$MainX, [int]$MainY, [int]$ $hwnd = [IntPtr]::new($hwndValue) [ISphereOfflineWindowApi]::ShowWindow($hwnd, 9) | Out-Null if ([string]$window.title -eq $InfoPromptTitle) { - [ISphereOfflineWindowApi]::MoveWindow($hwnd, ($MainX + $MainWidth + 40), ($MainY + 40), 300, 190, $true) | Out-Null - [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null + $promptX = if ($Mode -eq "Offscreen") { $MainX } else { $MainX + $MainWidth + 40 } + $promptY = if ($Mode -eq "Offscreen") { $MainY + $MainHeight + 40 } else { $MainY + 40 } + $flags = $swpShowWindow + if (-not $Activate) { $flags = $flags -bor $swpNoActivate } + [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $hwndTop, $promptX, $promptY, 300, 190, [uint32]$flags) | Out-Null + if ($Activate) { + [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null + } + if ($Mode -eq "Minimized") { + [ISphereOfflineWindowApi]::ShowWindow($hwnd, 6) | Out-Null + } $promptWindow = $window } else { - if ($MakeTopMost) { - [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $topMostHandle, $MainX, $MainY, $MainWidth, $MainHeight, 0x0040) | Out-Null + $insertAfter = if ($MakeTopMost) { $topMostHandle } else { $hwndTop } + $flags = $swpShowWindow + if (-not $Activate) { + $flags = $flags -bor $swpNoActivate } - else { - [ISphereOfflineWindowApi]::MoveWindow($hwnd, $MainX, $MainY, $MainWidth, $MainHeight, $true) | Out-Null + [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $insertAfter, $MainX, $MainY, $MainWidth, $MainHeight, [uint32]$flags) | Out-Null + if ($Activate) { + [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null + } + if ($Mode -eq "Minimized") { + [ISphereOfflineWindowApi]::ShowWindow($hwnd, 6) | Out-Null } - [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null $mainWindow = $window } } @@ -176,7 +211,12 @@ if (-not $KeepExisting) { $process = $null if (-not $NoLaunch) { - $process = Start-Process -FilePath $clientExe -WorkingDirectory $clientDir -PassThru + if ($UserSilent -or $WindowMode -ne "Visible" -or $NoActivate) { + $process = Start-Process -FilePath $clientExe -WorkingDirectory $clientDir -WindowStyle Minimized -PassThru + } + else { + $process = Start-Process -FilePath $clientExe -WorkingDirectory $clientDir -PassThru + } } $deadline = (Get-Date).AddSeconds([Math]::Max(1, $WaitSeconds)) @@ -197,7 +237,8 @@ if ($windows.Count -eq 0) { throw "no visible IMPlatformClient window found. Last scan: $($scan | ConvertTo-Json -Depth 12 -Compress)" } -$moveResult = Move-ClientWindows -Windows $windows -MainX $X -MainY $Y -MainWidth $Width -MainHeight $Height -MakeTopMost ([bool]$TopMost) +$activateWindows = -not [bool]$NoActivate +$moveResult = Move-ClientWindows -Windows $windows -MainX $effectiveX -MainY $effectiveY -MainWidth $Width -MainHeight $Height -MakeTopMost ([bool]$TopMost) -Activate $activateWindows -Mode $WindowMode Start-Sleep -Seconds 1 $scanAfterMove = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-scan-after-move" @@ -215,8 +256,17 @@ if ($latePromptWindows.Count -gt 0) { $promptHwndValue = [Convert]::ToInt64(($latePromptWindow.hwnd -replace "^0x", ""), 16) $promptHwnd = [IntPtr]::new($promptHwndValue) [ISphereOfflineWindowApi]::ShowWindow($promptHwnd, 9) | Out-Null - [ISphereOfflineWindowApi]::MoveWindow($promptHwnd, ($X + $Width + 40), ($Y + 40), 300, 190, $true) | Out-Null - [ISphereOfflineWindowApi]::SetForegroundWindow($promptHwnd) | Out-Null + $latePromptX = if ($WindowMode -eq "Offscreen") { $effectiveX } else { $effectiveX + $Width + 40 } + $latePromptY = if ($WindowMode -eq "Offscreen") { $effectiveY + $Height + 40 } else { $effectiveY + 40 } + $latePromptFlags = 0x0040 + if (-not $activateWindows) { $latePromptFlags = $latePromptFlags -bor 0x0010 } + [ISphereOfflineWindowApi]::SetWindowPos($promptHwnd, [IntPtr]::Zero, $latePromptX, $latePromptY, 300, 190, [uint32]$latePromptFlags) | Out-Null + if ($activateWindows) { + [ISphereOfflineWindowApi]::SetForegroundWindow($promptHwnd) | Out-Null + } + if ($WindowMode -eq "Minimized") { + [ISphereOfflineWindowApi]::ShowWindow($promptHwnd, 6) | Out-Null + } } Start-Sleep -Milliseconds 500 $scanAfterMove = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-scan-after-prompt-move" @@ -245,14 +295,23 @@ if ($mainWindow) { } $screenshotFile = $null -if (-not $NoScreenshot) { +if (-not $NoScreenshot -and $WindowMode -eq "Visible") { $screenshotFile = Join-Path $runOut ("open-offline-real-client-{0}.png" -f ((Get-Date).ToString("yyyyMMdd-HHmmss"))) $captureWidth = if ($promptWindow) { $Width + 420 } else { $Width + 80 } - Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $X -ScreenY $Y -CaptureWidth $captureWidth -CaptureHeight ($Height + 80) + Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $effectiveX -ScreenY $effectiveY -CaptureWidth $captureWidth -CaptureHeight ($Height + 80) } [pscustomobject]@{ ok = $true + user_silent = [bool]$UserSilent + window_mode = $WindowMode + no_activate = [bool]$NoActivate + effective_bounds = @{ + x = $effectiveX + y = $effectiveY + width = $Width + height = $Height + } archive = $archiveFull extracted_dir = $extractFull client_root = $ClientRoot