tools: add user-silent offline rpa window mode
This commit is contained in:
129
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal file
129
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal file
@@ -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 用户会话里做,不能放当前用户桌面长期运行。
|
||||
@@ -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
|
||||
$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
|
||||
}
|
||||
else {
|
||||
[ISphereOfflineWindowApi]::MoveWindow($hwnd, $MainX, $MainY, $MainWidth, $MainHeight, $true) | Out-Null
|
||||
$insertAfter = if ($MakeTopMost) { $topMostHandle } else { $hwndTop }
|
||||
$flags = $swpShowWindow
|
||||
if (-not $Activate) {
|
||||
$flags = $flags -bor $swpNoActivate
|
||||
}
|
||||
[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
|
||||
}
|
||||
$mainWindow = $window
|
||||
}
|
||||
}
|
||||
@@ -176,8 +211,13 @@ if (-not $KeepExisting) {
|
||||
|
||||
$process = $null
|
||||
if (-not $NoLaunch) {
|
||||
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))
|
||||
$scan = $null
|
||||
@@ -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,9 +256,18 @@ 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
|
||||
$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"
|
||||
$windowsAfterMove = @($scanAfterMove.data.windows | Where-Object {
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user