minimal-agent 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +52 -5
- package/plugins/computer_use/.claude-plugin/plugin.json +6 -0
- package/plugins/computer_use/README.md +94 -0
- package/plugins/computer_use/app/_template.md +142 -0
- package/plugins/computer_use/app/chrome.md +165 -0
- package/plugins/computer_use/app/finder.md +76 -0
- package/plugins/computer_use/app/macos-system-settings.md +81 -0
- package/plugins/computer_use/app/microsoft-excel.md +80 -0
- package/plugins/computer_use/app/microsoft-powerpoint.md +80 -0
- package/plugins/computer_use/app/microsoft-word.md +79 -0
- package/plugins/computer_use/app/wechat.md +413 -0
- package/plugins/computer_use/app/weread.md +319 -0
- package/plugins/computer_use/commands/computeruse.md +25 -0
- package/plugins/computer_use/commands/cu.md +9 -0
- package/plugins/computer_use/plugin.js +26 -0
- package/plugins/computer_use/plugin.ts +30 -0
- package/plugins/computer_use/src/capture.js +90 -0
- package/plugins/computer_use/src/capture.ts +104 -0
- package/plugins/computer_use/src/commands.js +91 -0
- package/plugins/computer_use/src/commands.ts +103 -0
- package/plugins/computer_use/src/daemon.js +134 -0
- package/plugins/computer_use/src/daemon.ts +161 -0
- package/plugins/computer_use/src/manuals.js +135 -0
- package/plugins/computer_use/src/manuals.ts +159 -0
- package/plugins/computer_use/src/verify.js +168 -0
- package/plugins/computer_use/src/verify.ts +219 -0
- package/plugins/computer_use/test/commands.test.ts +73 -0
- package/plugins/cron-runner/plugin.ts +38 -0
- package/plugins/cron-runner/src/commands.ts +373 -0
- package/plugins/cron-runner/src/cronTime.ts +155 -0
- package/plugins/cron-runner/src/lock.ts +79 -0
- package/plugins/cron-runner/src/runs.ts +99 -0
- package/plugins/cron-runner/src/scheduleEngine.ts +76 -0
- package/plugins/cron-runner/src/scheduler.ts +376 -0
- package/plugins/cron-runner/src/schema.ts +139 -0
- package/plugins/cron-runner/src/service.ts +193 -0
- package/plugins/cron-runner/src/store.ts +333 -0
- package/plugins/cron-runner/src/types.ts +141 -0
- package/plugins/cron-runner/test/cronTime.test.ts +41 -0
- package/plugins/cron-runner/test/plugin-integration.test.ts +47 -0
- package/plugins/cron-runner/test/scheduleEngine.test.ts +41 -0
- package/plugins/cron-runner/test/service.test.ts +76 -0
- package/plugins/cron-runner/test/store-and-scheduler.test.ts +501 -0
- package/plugins/workflow-runner/plugin.js +56 -2
- package/plugins/workflow-runner/plugin.ts +113 -0
- package/plugins/workflow-runner/src/agentCommon.js +43 -4
- package/plugins/workflow-runner/src/agentCommon.ts +435 -0
- package/plugins/workflow-runner/src/controlFlow/branch.js +40 -0
- package/plugins/workflow-runner/src/controlFlow/branch.ts +46 -0
- package/plugins/workflow-runner/src/controlFlow/loop.js +211 -0
- package/plugins/workflow-runner/src/controlFlow/loop.ts +248 -0
- package/plugins/workflow-runner/src/controlFlow/pause.js +129 -0
- package/plugins/workflow-runner/src/controlFlow/pause.ts +141 -0
- package/plugins/workflow-runner/src/controlFlow/signal.js +21 -0
- package/plugins/workflow-runner/src/controlFlow/signal.ts +29 -0
- package/plugins/workflow-runner/src/controlFlow/types.js +18 -0
- package/plugins/workflow-runner/src/controlFlow/types.ts +63 -0
- package/plugins/workflow-runner/src/dslManifest.ts +127 -0
- package/plugins/workflow-runner/src/expressions.ts +622 -0
- package/plugins/workflow-runner/src/index.js +13 -4
- package/plugins/workflow-runner/src/index.ts +338 -0
- package/plugins/workflow-runner/src/jsonPayload.ts +84 -0
- package/plugins/workflow-runner/src/loader.js +23 -0
- package/plugins/workflow-runner/src/loader.ts +721 -0
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +64 -0
- package/plugins/workflow-runner/src/recovery.ts +149 -0
- package/plugins/workflow-runner/src/runner.js +75 -659
- package/plugins/workflow-runner/src/runner.ts +604 -0
- package/plugins/workflow-runner/src/stepExecutors/agent.ts +234 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +50 -0
- package/plugins/workflow-runner/src/stepExecutors/echo.js +31 -0
- package/plugins/workflow-runner/src/stepExecutors/echo.ts +38 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +133 -0
- package/plugins/workflow-runner/src/stepExecutors/set.js +49 -0
- package/plugins/workflow-runner/src/stepExecutors/set.ts +56 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +140 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +80 -8
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +256 -0
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +68 -0
- package/plugins/workflow-runner/src/stepPipeline.js +277 -0
- package/plugins/workflow-runner/src/stepPipeline.ts +366 -0
- package/plugins/workflow-runner/src/taskIdentity.ts +405 -0
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.js +37 -1
- package/plugins/workflow-runner/src/types.ts +602 -0
- package/plugins/workflow-runner/src/workflowState.ts +733 -0
- package/plugins/workflow-runner/test/agent.test.ts +587 -0
- package/plugins/workflow-runner/test/agentLlmError.test.ts +373 -0
- package/plugins/workflow-runner/test/bashBlacklist.sweep.test.ts +65 -0
- package/plugins/workflow-runner/test/captureMisbind.test.ts +99 -0
- package/plugins/workflow-runner/test/cli.e2e.test.ts +147 -0
- package/plugins/workflow-runner/test/context-files.test.ts +320 -0
- package/plugins/workflow-runner/test/e2e.test.ts +370 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +226 -0
- package/plugins/workflow-runner/test/expressions.test.ts +397 -0
- package/plugins/workflow-runner/test/foundation.test.ts +126 -0
- package/plugins/workflow-runner/test/graceful.test.ts +139 -0
- package/plugins/workflow-runner/test/greedyInputs.load.test.ts +47 -0
- package/plugins/workflow-runner/test/hostCapability.test.ts +108 -0
- package/plugins/workflow-runner/test/illustrate.load.test.ts +183 -0
- package/plugins/workflow-runner/test/jsonPayload.test.ts +83 -0
- package/plugins/workflow-runner/test/loader.test.ts +617 -0
- package/plugins/workflow-runner/test/maxTurnsRetryDelay.test.ts +33 -0
- package/plugins/workflow-runner/test/multimodal.test.ts +342 -0
- package/plugins/workflow-runner/test/output-schema.test.ts +213 -0
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +87 -0
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +269 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +1220 -0
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +220 -0
- package/plugins/workflow-runner/test/recovery.test.ts +265 -0
- package/plugins/workflow-runner/test/runner.test.ts +911 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +379 -0
- package/plugins/workflow-runner/test/skillTurnDone.test.ts +71 -0
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +150 -0
- package/plugins/workflow-runner/test/stepPipeline.test.ts +114 -0
- package/plugins/workflow-runner/test/taskArbitration.test.ts +338 -0
- package/plugins/workflow-runner/test/taskIdentity.test.ts +237 -0
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +270 -0
- package/plugins/workflow-runner/test/toolArtifactOverwrite.test.ts +221 -0
- package/plugins/workflow-runner/test/toolCapabilityBoundary.test.ts +29 -0
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/plugins/workflow-runner/test/webppt.load.test.ts +250 -0
- package/plugins/workflow-runner/test/word.load.test.ts +208 -0
- package/plugins/workflow-runner/test/workflows.smoke.test.ts +76 -0
- package/plugins/workflow-runner/test/yamlGuardConditions.test.ts +207 -0
- package/skills/browser-automation/SKILL.md +9 -19
- package/skills/web-crawler/SKILL.md +9 -11
- package/src/askBridge.ts +249 -0
- package/src/bootstrap/cwdArg.js +10 -3
- package/src/bootstrap/cwdArg.ts +31 -0
- package/src/bootstrap/workingDir.ts +48 -0
- package/src/cli/args.js +70 -7
- package/src/cli/args.ts +146 -0
- package/src/cli/computerUse.js +185 -0
- package/src/cli/computerUse.ts +221 -0
- package/src/cli/configMenu.js +274 -202
- package/src/cli/configMenu.tsx +521 -0
- package/src/cli/memoryAccess.js +45 -0
- package/src/cli/memoryAccess.ts +53 -0
- package/src/cli/print.js +119 -5
- package/src/cli/print.ts +597 -0
- package/src/cli/quickModelSetup.js +60 -0
- package/src/cli/quickModelSetup.tsx +96 -0
- package/src/cli/streamJson.js +80 -4
- package/src/cli/streamJson.ts +433 -0
- package/src/cli/wizards/advisorWizard.js +1 -0
- package/src/cli/wizards/advisorWizard.tsx +195 -0
- package/src/cli/wizards/agentRuntimeWizard.js +84 -0
- package/src/cli/wizards/agentRuntimeWizard.tsx +134 -0
- package/src/cli/wizards/browserWizard.js +55 -0
- package/src/cli/wizards/browserWizard.tsx +77 -0
- package/src/cli/wizards/contextAdvisorWizard.js +84 -0
- package/src/cli/wizards/contextAdvisorWizard.tsx +124 -0
- package/src/cli/wizards/fieldForm.js +7 -4
- package/src/cli/wizards/fieldForm.tsx +250 -0
- package/src/cli/wizards/imageWizard.js +4 -5
- package/src/cli/wizards/imageWizard.tsx +96 -0
- package/src/cli/wizards/openRouterWizard.tsx +48 -0
- package/src/cli/wizards/providerPresets.js +1 -8
- package/src/cli/wizards/providerPresets.ts +106 -0
- package/src/cli/wizards/providerWizard.js +152 -205
- package/src/cli/wizards/providerWizard.tsx +266 -0
- package/src/cli/wizards/searchWizard.tsx +47 -0
- package/src/cli/wizards/ttsWizard.js +4 -3
- package/src/cli/wizards/ttsWizard.tsx +61 -0
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configDiagnostics.js +117 -0
- package/src/config/configDiagnostics.ts +166 -0
- package/src/config/configFile.js +52 -32
- package/src/config/configFile.ts +633 -0
- package/src/config/configManagement.js +126 -0
- package/src/config/configManagement.ts +159 -0
- package/src/config/configService.js +66 -11
- package/src/config/configService.ts +554 -0
- package/src/config/numericLimits.js +61 -0
- package/src/config/numericLimits.ts +80 -0
- package/src/config.ts +109 -0
- package/src/context/agentHome.ts +11 -0
- package/src/context/archive.ts +144 -0
- package/src/context/budgetPlan.js +54 -0
- package/src/context/budgetPlan.ts +88 -0
- package/src/context/compact.js +179 -106
- package/src/context/compact.ts +584 -0
- package/src/context/contextBundle.js +119 -0
- package/src/context/contextBundle.ts +176 -0
- package/src/context/contextLock.ts +84 -0
- package/src/context/microCompactLite.ts +218 -0
- package/src/context/persistContext.ts +177 -0
- package/src/context/reactiveCompact.js +19 -4
- package/src/context/reactiveCompact.ts +240 -0
- package/src/context/recentDirs.ts +42 -0
- package/src/context/sessionPath.ts +206 -0
- package/src/context/sessionRegistry.js +8 -2
- package/src/context/sessionRegistry.ts +414 -0
- package/src/context/snipCompact.ts +163 -0
- package/src/context/tokenCounter.ts +106 -0
- package/src/context/workspaceIndex.js +2 -2
- package/src/context/workspaceIndex.ts +751 -0
- package/src/entry.compiled.tsx +20 -0
- package/src/harness/claimValidation.js +9 -0
- package/src/harness/claimValidation.ts +22 -0
- package/src/harness/evidenceLedger.js +92 -0
- package/src/harness/evidenceLedger.ts +96 -0
- package/src/harness/stallGuard.js +60 -0
- package/src/harness/stallGuard.ts +81 -0
- package/src/humanIntervention.js +129 -0
- package/src/humanIntervention.ts +216 -0
- package/src/infra/verification.ts +295 -0
- package/src/irreversible/rules.js +216 -0
- package/src/irreversible/rules.ts +261 -0
- package/src/llm/client.js +40 -3
- package/src/llm/client.ts +621 -0
- package/src/llm/promptCache.js +27 -0
- package/src/llm/promptCache.ts +41 -0
- package/src/llm/providers/anthropicFormat.js +41 -5
- package/src/llm/providers/anthropicFormat.ts +290 -0
- package/src/llm/providers/openaiFormat.js +186 -8
- package/src/llm/providers/openaiFormat.ts +428 -0
- package/src/loop.js +614 -63
- package/src/loop.ts +1454 -0
- package/src/main.js +67 -10
- package/src/main.tsx +344 -0
- package/src/memory/codec.js +204 -0
- package/src/memory/codec.ts +249 -0
- package/src/memory/constants.js +34 -0
- package/src/memory/constants.ts +50 -0
- package/src/memory/episodicMemory.js +684 -0
- package/src/memory/episodicMemory.ts +815 -0
- package/src/memory/fileStore.js +148 -0
- package/src/memory/fileStore.ts +184 -0
- package/src/memory/memoryService.js +570 -0
- package/src/memory/memoryService.ts +859 -0
- package/src/memory/migration.js +128 -0
- package/src/memory/migration.ts +160 -0
- package/src/memory/repository.js +789 -0
- package/src/memory/repository.ts +893 -0
- package/src/memory/retrieval.js +108 -0
- package/src/memory/retrieval.ts +145 -0
- package/src/memory/searchIndex.js +107 -0
- package/src/memory/searchIndex.ts +153 -0
- package/src/memory/topicView.js +82 -0
- package/src/memory/topicView.ts +95 -0
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.js +2 -0
- package/src/memory/types.ts +186 -0
- package/src/messageQueue.ts +123 -0
- package/src/multimodal.js +128 -46
- package/src/multimodal.ts +349 -0
- package/src/plugin-sdk.js +21 -1
- package/src/plugin-sdk.ts +134 -0
- package/src/plugins/builtinPluginIds.js +47 -0
- package/src/plugins/builtinPluginIds.ts +51 -0
- package/src/plugins/builtinRegistry.ts +43 -0
- package/src/plugins/commandRouter.js +40 -26
- package/src/plugins/commandRouter.ts +261 -0
- package/src/plugins/hookEngine.ts +341 -0
- package/src/plugins/pluginApi.ts +133 -0
- package/src/plugins/pluginLifecycle.ts +34 -0
- package/src/plugins/pluginLoader.ts +147 -0
- package/src/plugins/pluginManifest.js +127 -0
- package/src/plugins/pluginManifest.ts +152 -0
- package/src/plugins/pluginRunner.js +47 -2
- package/src/plugins/pluginRunner.ts +207 -0
- package/src/plugins/transcript.ts +208 -0
- package/src/prompts/lint.js +169 -0
- package/src/prompts/lint.ts +220 -0
- package/src/prompts/pluginList.js +32 -16
- package/src/prompts/pluginList.ts +86 -0
- package/src/prompts/policyRegistry.js +318 -0
- package/src/prompts/policyRegistry.ts +358 -0
- package/src/prompts/projectInstructions.js +24 -0
- package/src/prompts/projectInstructions.ts +81 -0
- package/src/prompts/promptAssembly.js +311 -202
- package/src/prompts/promptAssembly.ts +510 -0
- package/src/prompts/skillList.js +106 -45
- package/src/prompts/skillList.ts +198 -0
- package/src/prompts/system.js +31 -6
- package/src/prompts/system.ts +81 -0
- package/src/runtime/agentRuntimeContext.js +53 -0
- package/src/runtime/agentRuntimeContext.ts +112 -0
- package/src/session/runAgentTurn.js +71 -12
- package/src/session/runAgentTurn.ts +181 -0
- package/src/session/runTurn.js +29 -0
- package/src/session/runTurn.ts +160 -0
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +79 -3
- package/src/session/sessionState.ts +134 -0
- package/src/toolGate.ts +50 -0
- package/src/tools/advisor/advisor.js +76 -15
- package/src/tools/advisor/advisor.ts +512 -0
- package/src/tools/agent/agent.js +245 -29
- package/src/tools/agent/agent.ts +837 -0
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +20 -8
- package/src/tools/agent/agentTypes.ts +94 -0
- package/src/tools/agent/coordinator.ts +111 -0
- package/src/tools/agent/worktree.ts +296 -0
- package/src/tools/ask/ask.js +17 -0
- package/src/tools/ask/ask.ts +122 -0
- package/src/tools/bash/attachImage.js +223 -0
- package/src/tools/bash/attachImage.ts +281 -0
- package/src/tools/bash/bash.js +132 -14
- package/src/tools/bash/bash.ts +642 -0
- package/src/tools/bash/semantics.ts +103 -0
- package/src/tools/bash/warnings.js +17 -11
- package/src/tools/bash/warnings.ts +117 -0
- package/src/tools/capabilities.js +42 -0
- package/src/tools/capabilities.ts +69 -0
- package/src/tools/computeruse/accessibilityPermission.js +261 -0
- package/src/tools/computeruse/accessibilityPermission.ts +294 -0
- package/src/tools/computeruse/axSearch.js +96 -0
- package/src/tools/computeruse/axSearch.ts +120 -0
- package/src/tools/computeruse/axSnap.js +272 -0
- package/src/tools/computeruse/axSnap.ts +349 -0
- package/src/tools/computeruse/capabilityLabels.js +95 -0
- package/src/tools/computeruse/capabilityLabels.ts +115 -0
- package/src/tools/computeruse/computerUseService.js +2235 -0
- package/src/tools/computeruse/computerUseService.ts +2686 -0
- package/src/tools/computeruse/computeruse.js +2742 -0
- package/src/tools/computeruse/computeruse.ts +3265 -0
- package/src/tools/computeruse/diagnosticTrace.js +154 -0
- package/src/tools/computeruse/diagnosticTrace.ts +195 -0
- package/src/tools/computeruse/diagnostics.js +369 -0
- package/src/tools/computeruse/diagnostics.ts +453 -0
- package/src/tools/computeruse/grants.js +53 -0
- package/src/tools/computeruse/grants.ts +70 -0
- package/src/tools/computeruse/humanBoundary.js +159 -0
- package/src/tools/computeruse/humanBoundary.ts +199 -0
- package/src/tools/computeruse/intervention.js +77 -0
- package/src/tools/computeruse/intervention.ts +110 -0
- package/src/tools/computeruse/manualSchema.js +709 -0
- package/src/tools/computeruse/manualSchema.ts +916 -0
- package/src/tools/computeruse/observationImages.js +705 -0
- package/src/tools/computeruse/observationImages.ts +889 -0
- package/src/tools/computeruse/protocol.js +60 -0
- package/src/tools/computeruse/protocol.ts +515 -0
- package/src/tools/computeruse/telemetryLog.js +57 -0
- package/src/tools/computeruse/telemetryLog.ts +67 -0
- package/src/tools/computeruse/vision/appGuide.js +325 -0
- package/src/tools/computeruse/vision/appGuide.ts +482 -0
- package/src/tools/computeruse/vision/focus.js +291 -0
- package/src/tools/computeruse/vision/focus.ts +382 -0
- package/src/tools/computeruse/vision/frames.js +248 -0
- package/src/tools/computeruse/vision/frames.ts +329 -0
- package/src/tools/computeruse/vision/geometry.js +131 -0
- package/src/tools/computeruse/vision/geometry.ts +163 -0
- package/src/tools/computeruse/vision/imaging.js +201 -0
- package/src/tools/computeruse/vision/imaging.ts +281 -0
- package/src/tools/computeruse/vision/png.js +274 -0
- package/src/tools/computeruse/vision/png.ts +317 -0
- package/src/tools/computeruse/vision/raster.js +320 -0
- package/src/tools/computeruse/vision/raster.ts +408 -0
- package/src/tools/computeruse/vision/settle.js +131 -0
- package/src/tools/computeruse/vision/settle.ts +220 -0
- package/src/tools/computeruse/vision/telemetry.js +196 -0
- package/src/tools/computeruse/vision/telemetry.ts +241 -0
- package/src/tools/computeruse/visionOcr.js +115 -0
- package/src/tools/computeruse/visionOcr.ts +128 -0
- package/src/tools/computeruse/waitFor.js +237 -0
- package/src/tools/computeruse/waitFor.ts +291 -0
- package/src/tools/download/download.js +10 -6
- package/src/tools/download/download.ts +187 -0
- package/src/tools/edit/edit.js +30 -18
- package/src/tools/edit/edit.ts +349 -0
- package/src/tools/edit/multi-edit.js +25 -14
- package/src/tools/edit/multi-edit.ts +223 -0
- package/src/tools/effectiveToolSet.js +73 -0
- package/src/tools/effectiveToolSet.ts +91 -0
- package/src/tools/genimage/genimage.ts +257 -0
- package/src/tools/genspeech/genspeech.ts +83 -0
- package/src/tools/glob/glob.js +176 -29
- package/src/tools/glob/glob.ts +283 -0
- package/src/tools/grep/grep.js +61 -19
- package/src/tools/grep/grep.ts +239 -0
- package/src/tools/grep/rgPath.ts +206 -0
- package/src/tools/index.js +99 -5
- package/src/tools/index.ts +293 -0
- package/src/tools/lsp/config.js +113 -0
- package/src/tools/lsp/config.ts +113 -0
- package/src/tools/lsp/lsp.js +256 -0
- package/src/tools/lsp/lsp.ts +274 -0
- package/src/tools/lsp/service.js +303 -0
- package/src/tools/lsp/service.ts +296 -0
- package/src/tools/memory/memory.js +520 -0
- package/src/tools/memory/memory.ts +625 -0
- package/src/tools/memory/policy.js +53 -0
- package/src/tools/memory/policy.ts +72 -0
- package/src/tools/notebook/notebookEdit.js +114 -0
- package/src/tools/notebook/notebookEdit.ts +126 -0
- package/src/tools/powershell/powershell.js +210 -0
- package/src/tools/powershell/powershell.ts +239 -0
- package/src/tools/presentresult/presentresult.js +145 -0
- package/src/tools/presentresult/presentresult.ts +156 -0
- package/src/tools/read/read.js +371 -163
- package/src/tools/read/read.ts +558 -0
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +51 -0
- package/src/tools/shared/fileActivity.ts +66 -0
- package/src/tools/shared/fileMutationBus.js +13 -0
- package/src/tools/shared/fileMutationBus.ts +14 -0
- package/src/tools/shared/fileState.js +73 -65
- package/src/tools/shared/fileState.ts +145 -0
- package/src/tools/shared/fileUtils.js +81 -4
- package/src/tools/shared/fileUtils.ts +504 -0
- package/src/tools/shared/killTree.ts +50 -0
- package/src/tools/shared/permanentDeleteGuard.js +26 -0
- package/src/tools/shared/permanentDeleteGuard.ts +34 -0
- package/src/tools/shared/schemas.ts +20 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +275 -0
- package/src/tools/tasks/backgroundTaskService.ts +369 -0
- package/src/tools/tasks/processEvidence.js +24 -0
- package/src/tools/tasks/processEvidence.ts +34 -0
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +113 -0
- package/src/tools/tasks/taskOutput.ts +145 -0
- package/src/tools/tasks/taskStop.js +74 -0
- package/src/tools/tasks/taskStop.ts +99 -0
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/types.js +1 -1
- package/src/tools/types.ts +24 -0
- package/src/tools/webbrowser/agentBrowserPath.ts +286 -0
- package/src/tools/webbrowser/browser.js +214 -11
- package/src/tools/webbrowser/browser.ts +293 -0
- package/src/tools/webbrowser/browserControl.js +153 -48
- package/src/tools/webbrowser/browserControl.ts +262 -0
- package/src/tools/webbrowser/browserDaemon.js +147 -39
- package/src/tools/webbrowser/browserDaemon.ts +270 -0
- package/src/tools/webbrowser/browserOutcome.js +273 -0
- package/src/tools/webbrowser/browserOutcome.ts +306 -0
- package/src/tools/webbrowser/browserRunRegistry.js +150 -0
- package/src/tools/webbrowser/browserRunRegistry.ts +179 -0
- package/src/tools/webbrowser/browserService.js +20 -6
- package/src/tools/webbrowser/browserService.ts +56 -0
- package/src/tools/webbrowser/browserStream.js +30 -1
- package/src/tools/webbrowser/browserStream.ts +216 -0
- package/src/tools/webbrowser/browserTabs.js +143 -0
- package/src/tools/webbrowser/browserTabs.ts +210 -0
- package/src/tools/webbrowser/webbrowser.js +831 -150
- package/src/tools/webbrowser/webbrowser.ts +1537 -0
- package/src/tools/webfetch/preapproved.ts +274 -0
- package/src/tools/webfetch/webfetch.js +24 -23
- package/src/tools/webfetch/webfetch.ts +383 -0
- package/src/tools/websearch/websearch.js +12 -11
- package/src/tools/websearch/websearch.ts +292 -0
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +27 -44
- package/src/tools/write/write.ts +136 -0
- package/src/types.ts +960 -0
- package/src/ui/App.js +216 -12
- package/src/ui/App.tsx +879 -0
- package/src/ui/AskQuestion.tsx +300 -0
- package/src/ui/InputBox.js +77 -1
- package/src/ui/InputBox.tsx +567 -0
- package/src/ui/LiveArea.tsx +344 -0
- package/src/ui/MessageList.js +27 -6
- package/src/ui/MessageList.tsx +559 -0
- package/src/ui/ModelPicker.tsx +107 -0
- package/src/ui/QueuedMessagesBanner.tsx +51 -0
- package/src/ui/Root.js +6 -8
- package/src/ui/Root.tsx +100 -0
- package/src/ui/SessionPicker.tsx +169 -0
- package/src/ui/StatusLine.js +22 -1
- package/src/ui/StatusLine.tsx +145 -0
- package/src/ui/WorkspacePicker.tsx +84 -0
- package/src/ui/advisorCommand.js +54 -0
- package/src/ui/advisorCommand.ts +63 -0
- package/src/ui/computerCommand.js +19 -0
- package/src/ui/computerCommand.ts +35 -0
- package/src/ui/figures.ts +45 -0
- package/src/ui/hooks/askSlot.ts +112 -0
- package/src/ui/hooks/useChat.js +271 -7
- package/src/ui/hooks/useChat.ts +1578 -0
- package/src/ui/hooks/useInputHistory.ts +235 -0
- package/src/ui/hooks/usePasteHandler.ts +220 -0
- package/src/ui/hooks/useTerminalSize.ts +41 -0
- package/src/ui/hooks/useTextBuffer.ts +108 -0
- package/src/ui/hooks/useTokenUsage.ts +44 -0
- package/src/ui/liveViewport.ts +189 -0
- package/src/ui/markdown.ts +230 -0
- package/src/ui/textBuffer.ts +239 -0
- package/src/ui/theme.ts +35 -0
- package/src/utils/clipboardImage.js +358 -0
- package/src/utils/clipboardImage.ts +420 -0
- package/src/utils/debugLog.ts +50 -0
- package/src/utils/greenRoot.ts +35 -0
- package/src/utils/packageRoot.ts +40 -0
- package/src/utils/pathContains.js +28 -0
- package/src/utils/pathContains.ts +31 -0
- package/src/utils/resourcePaths.ts +71 -0
- package/src/utils/stripThink.ts +49 -0
- package/src/utils/zodToJson.ts +73 -0
- package/src/web/browserBridge.js +365 -66
- package/src/web/browserBridge.ts +672 -0
- package/src/web/protocol.ts +1804 -0
- package/vendor/computer-use/arm64-darwin/cu-helperd +0 -0
- package/workflows/ppt.yaml +13 -2
- package/workflows/wechat-reading-shots.yaml +315 -0
- package/workflows/word.yaml +11 -1
- package/skills/docx/scripts/office/helpers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/helpers/__pycache__/merge_runs.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/helpers/__pycache__/simplify_redlines.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/docx.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/pptx.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/redlining.cpython-314.pyc +0 -0
- package/skills/image-stats/scripts/__pycache__/image_stats.cpython-314.pyc +0 -0
- package/skills/image-stats/scripts/__pycache__/image_stats_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/assemble_material.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/assemble_material_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/chart_sanity_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_language_policy.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_ui_artifact.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_ui_artifact_pptx_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_pages_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_pages_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_visual_style_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_visual_style_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_budget_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_budget_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_repair_hints.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_repair_hints_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_style_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_style_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/draft_coverage_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/extract_source_assets.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/extract_source_assets_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/generated_image_review_collect.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/generated_image_review_collect_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_plan_lint.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_plan_lint_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/layout_balance.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/layout_balance_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/normalize_design_json.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/normalize_design_json_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/plan_source_layout.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/plan_source_layout_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_hint.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_hint_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_input.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_input_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/scaffold_ui_artifact.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/source_usage_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/source_usage_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/text_readability_pixel.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/visual_quality_gate.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/visual_quality_gate_test.cpython-314.pyc +0 -0
- /package/skills/ai-image/scripts/{generate_test.mjs → generate.selftest.mjs} +0 -0
- /package/skills/ai-image/scripts/{generate_from_plan_test.mjs → generate_from_plan.selftest.mjs} +0 -0
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { createConnection } from 'node:net';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { ensureMacPermissions } from './accessibilityPermission.js';
|
|
10
|
+
import { snapToNearestAxElement } from './axSnap.js';
|
|
11
|
+
import { COMPUTER_USE_PROTOCOL_VERSION, isMutatingComputerUseAction, } from './protocol.js';
|
|
12
|
+
import { FOCUS_CHANGE, axGivesControlDetail, axFocusIsConcreteControl, classifyFocusChange, deriveFocusSnapshot, } from './vision/focus.js';
|
|
13
|
+
function delay(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 判断是否是截图相关的可重试错误
|
|
18
|
+
*/
|
|
19
|
+
function isScreenshotRetryableError(error) {
|
|
20
|
+
return error?.retryable === true && error.code === 'capture_transient';
|
|
21
|
+
}
|
|
22
|
+
function executableName() {
|
|
23
|
+
return process.platform === 'win32' ? 'cu-helperd.exe' : 'cu-helperd';
|
|
24
|
+
}
|
|
25
|
+
function platformDir() {
|
|
26
|
+
return `${process.arch}-${process.platform}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 包根:本文件在 `src/tools/computeruse/`,上溯三级。
|
|
30
|
+
*
|
|
31
|
+
* 不能用 `process.cwd()` —— 那是**用户的工作目录**,不是包所在位置。dev 场景
|
|
32
|
+
* 两者恰好重合所以看不出问题,npm 安装后(cwd = 用户项目)必然找不到。
|
|
33
|
+
* 同一个坑在技能侧解对过一次,内核这边曾解错。
|
|
34
|
+
*/
|
|
35
|
+
const PACKAGE_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..', '..');
|
|
36
|
+
export function findComputerUseHelper(explicit = process.env.MINIMAL_AGENT_COMPUTER_USE_HELPER) {
|
|
37
|
+
const relative = join('vendor', 'computer-use', platformDir(), executableName());
|
|
38
|
+
const candidates = [
|
|
39
|
+
explicit,
|
|
40
|
+
// 包内 vendor/:dev 与 npm 安装
|
|
41
|
+
join(PACKAGE_ROOT, relative),
|
|
42
|
+
// exe 同级:绿色版(compile 后 import.meta.url 是虚拟路径,只能靠 execPath)
|
|
43
|
+
process.execPath ? join(dirname(process.execPath), relative) : undefined,
|
|
44
|
+
].filter((value) => !!value);
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const resolved = resolve(candidate);
|
|
47
|
+
if (existsSync(resolved))
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
|
51
|
+
const candidate = join(dir, executableName());
|
|
52
|
+
if (existsSync(candidate))
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Computer Use 的本机状态目录:配对令牌、socket、审计与遥测都落在这里。
|
|
59
|
+
*
|
|
60
|
+
* 单一出处。这段跨平台路径逻辑曾经在 `cli/computerUse.ts` 里有第二份拷贝,
|
|
61
|
+
* 两份各自演进就会让「审计写在哪」和「审计从哪读」悄悄分叉。
|
|
62
|
+
*/
|
|
63
|
+
export function computerUseStateDir() {
|
|
64
|
+
if (process.env.KAKADEAI_CU_HOME)
|
|
65
|
+
return resolve(process.env.KAKADEAI_CU_HOME);
|
|
66
|
+
if (process.platform === 'darwin')
|
|
67
|
+
return join(homedir(), 'Library', 'Application Support', 'KakadeAI', 'computer-use');
|
|
68
|
+
if (process.platform === 'win32')
|
|
69
|
+
return join(process.env.LOCALAPPDATA ?? homedir(), 'KakadeAI', 'computer-use');
|
|
70
|
+
return join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'kakadeai', 'computer-use');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 本进程的「宿主身份」—— 决定守护进程能拿到哪些系统权限的那个 App。
|
|
74
|
+
*
|
|
75
|
+
* macOS 的 TCC 不认发起请求的二进制,认的是 **responsible process**:进程被创建时从
|
|
76
|
+
* 父进程继承、并且**在 daemonize 之后依然保留**的那个 App 身份。守护进程正是被我们
|
|
77
|
+
* spawn 出来的,所以它这辈子的权限由「谁第一个拉起它」决定,之后再也不会变。
|
|
78
|
+
*
|
|
79
|
+
* 往上找到第一个 `.app` 包就是答案。找不到(比如从纯终端跑)就退回可执行文件路径 ——
|
|
80
|
+
* 那种情况下 TCC 认的确实是那个可执行文件所属的终端 App,桶分开了也依然正确。
|
|
81
|
+
*/
|
|
82
|
+
function responsibleHostIdentity() {
|
|
83
|
+
if (process.platform !== 'darwin')
|
|
84
|
+
return 'default';
|
|
85
|
+
try {
|
|
86
|
+
let pid = process.pid;
|
|
87
|
+
for (let depth = 0; depth < 12 && pid > 1; depth += 1) {
|
|
88
|
+
// macOS 的 `comm` 给的是完整可执行路径(与 Linux 的截断行为不同)。
|
|
89
|
+
const line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], {
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
92
|
+
}).trim();
|
|
93
|
+
const parsed = /^(\d+)\s+(.+)$/.exec(line);
|
|
94
|
+
if (!parsed)
|
|
95
|
+
break;
|
|
96
|
+
const bundle = /^(.*?\.app)\/Contents\/MacOS\//.exec(parsed[2]);
|
|
97
|
+
if (bundle)
|
|
98
|
+
return bundle[1];
|
|
99
|
+
pid = Number(parsed[1]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// ps 不可用时不猜:退回共享桶,行为与分桶前一致。
|
|
104
|
+
}
|
|
105
|
+
return process.execPath;
|
|
106
|
+
}
|
|
107
|
+
let cachedHostTag = null;
|
|
108
|
+
/**
|
|
109
|
+
* 守护进程的**运行时**状态目录(socket / 配对令牌 / 锁 / 审计),按宿主身份分桶。
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ 为什么必须分桶(真实事故):这些文件此前全机唯一,于是守护进程被**所有宿主共享**。
|
|
112
|
+
* 谁先拉起它,谁的 TCC 授权就决定了之后**所有**客户端的命运 —— 而它是 `ppid=1` 的常驻
|
|
113
|
+
* 进程,这个身份一旦定下就再不会变。实测后果:某次从 Claude Code 里跑了一遍端到端验证,
|
|
114
|
+
* 拉起的守护进程带着 `com.anthropic.claude-code` 的身份常驻;此后用户从自己的 Desktop
|
|
115
|
+
* App 发起的每一次截图都被 TCC 拒绝 —— App 自己的授权是好的,但请求根本没走它的身份。
|
|
116
|
+
* 重启 App 也无效(守护进程不跟着 App 死),排查耗掉数小时,期间先后错怪了 cdhash、
|
|
117
|
+
* 用户的授权设置和我们自己的改动。
|
|
118
|
+
*
|
|
119
|
+
* 分桶之后各宿主各起各的守护进程,天然隔离,**不需要任何人去杀别人的进程** —— 那个方向
|
|
120
|
+
* (启动时清理全部实例)会把并发宿主的正在进行的动作从中间掐断,是更糟的解法。
|
|
121
|
+
*
|
|
122
|
+
* 遥测与授权记录仍写在共享根目录下,不受影响;审计由守护进程写在各自桶内,
|
|
123
|
+
* 读取方 `readRecentComputerUseAudit` 负责跨桶汇总。
|
|
124
|
+
*/
|
|
125
|
+
export function computerUseHostStateDir() {
|
|
126
|
+
if (!cachedHostTag) {
|
|
127
|
+
cachedHostTag = createHash('sha256')
|
|
128
|
+
.update(responsibleHostIdentity())
|
|
129
|
+
.digest('hex')
|
|
130
|
+
.slice(0, 12);
|
|
131
|
+
}
|
|
132
|
+
return join(computerUseStateDir(), 'hosts', cachedHostTag);
|
|
133
|
+
}
|
|
134
|
+
function isRecord(value) {
|
|
135
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
function contextKey(context) {
|
|
138
|
+
return `${context.ownerId}\0${context.conversationKey}\0${context.runId}`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 动作指纹 = 「同一件事」的判据。
|
|
142
|
+
*
|
|
143
|
+
* 粒度是刻意的:**同一个动作打同一个目标**才算重试。换目标说明模型在尝试
|
|
144
|
+
* 别的路子,那正是我们希望它做的,不该继续累加;而反复戳同一个坐标正是
|
|
145
|
+
* 「原地打转」的典型形状。
|
|
146
|
+
*
|
|
147
|
+
* 坐标取整到 10(相对坐标 0–1000):模型重试时常有 1–2 的抖动,不取整会让
|
|
148
|
+
* 每次都算「新目标」,熔断永远数不到 3。
|
|
149
|
+
*/
|
|
150
|
+
export function actionFingerprint(input) {
|
|
151
|
+
const target = input.elementId ??
|
|
152
|
+
(input.x !== undefined && input.y !== undefined
|
|
153
|
+
? `${Math.round(input.x / 10)},${Math.round(input.y / 10)}`
|
|
154
|
+
: (input.key ?? input.windowOperation ?? ''));
|
|
155
|
+
return `${input.action}\0${input.appId ?? ''}\0${target}`;
|
|
156
|
+
}
|
|
157
|
+
function numberAt(value, fallback = 0) {
|
|
158
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
159
|
+
}
|
|
160
|
+
function stringAt(value) {
|
|
161
|
+
return typeof value === 'string' ? value : undefined;
|
|
162
|
+
}
|
|
163
|
+
async function validateStateImage(state) {
|
|
164
|
+
const image = state.screenshot;
|
|
165
|
+
if (!image)
|
|
166
|
+
return undefined;
|
|
167
|
+
// 只接受 PNG。
|
|
168
|
+
//
|
|
169
|
+
// 这里曾经也收 JPEG(nut-js transport 输出 q78 JPEG 省体积),但那条分支把这道
|
|
170
|
+
// 校验**架空**了:PNG 分支从 IHDR 读**文件里的真实宽高**再与声明值比对,
|
|
171
|
+
// JPEG 分支却直接 `width = image.width`,于是下面「声明元数据必须与文件相符」
|
|
172
|
+
// 变成拿声明值和自己比,恒真。nut-js 移除后 JPEG 来源消失,这道校验重新是真的。
|
|
173
|
+
if (image.mime !== 'image/png') {
|
|
174
|
+
return {
|
|
175
|
+
code: 'invalid_image',
|
|
176
|
+
message: `Computer Use 当前只接受经过验证的 PNG 截图,收到 ${image.mime}`,
|
|
177
|
+
retryable: false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const bytes = await readFile(image.path);
|
|
182
|
+
// PNG签名: 137 80 78 71 13 10 26 10
|
|
183
|
+
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
184
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(pngSignature)) {
|
|
185
|
+
throw new Error('PNG signature or IHDR is missing');
|
|
186
|
+
}
|
|
187
|
+
const width = bytes.readUInt32BE(16);
|
|
188
|
+
const height = bytes.readUInt32BE(20);
|
|
189
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
190
|
+
if (width !== image.width
|
|
191
|
+
|| height !== image.height
|
|
192
|
+
|| (image.byteLength !== undefined && image.byteLength !== bytes.length)
|
|
193
|
+
|| (image.sha256 !== undefined && image.sha256 !== digest)) {
|
|
194
|
+
throw new Error('declared image metadata does not match the file');
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return {
|
|
200
|
+
code: 'invalid_image',
|
|
201
|
+
message: `Computer Use 截图校验失败:${error.message}`,
|
|
202
|
+
retryable: false,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function normalizeRect(value) {
|
|
207
|
+
if (!isRecord(value))
|
|
208
|
+
return undefined;
|
|
209
|
+
const width = numberAt(value.width);
|
|
210
|
+
const height = numberAt(value.height);
|
|
211
|
+
if (width <= 0 || height <= 0)
|
|
212
|
+
return undefined;
|
|
213
|
+
return { x: numberAt(value.x), y: numberAt(value.y), width, height };
|
|
214
|
+
}
|
|
215
|
+
function normalizeDisplay(value) {
|
|
216
|
+
if (!isRecord(value))
|
|
217
|
+
return undefined;
|
|
218
|
+
const id = stringAt(value.id);
|
|
219
|
+
const logicalBounds = normalizeRect(value.logicalBounds);
|
|
220
|
+
if (!id || !logicalBounds)
|
|
221
|
+
return undefined;
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
...(stringAt(value.name) ? { name: stringAt(value.name) } : {}),
|
|
225
|
+
logicalBounds,
|
|
226
|
+
...(normalizeRect(value.visibleBounds)
|
|
227
|
+
? { visibleBounds: normalizeRect(value.visibleBounds) }
|
|
228
|
+
: {}),
|
|
229
|
+
physicalWidth: numberAt(value.pixelWidth ?? value.physicalWidth),
|
|
230
|
+
physicalHeight: numberAt(value.pixelHeight ?? value.physicalHeight),
|
|
231
|
+
scaleFactor: numberAt(value.scaleFactor, 1),
|
|
232
|
+
primary: value.primary === true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Ceiling on any accessibility value we forward. A single node should never be
|
|
237
|
+
* able to push an unbounded blob (a whole document body, a log pane) into the
|
|
238
|
+
* model's context.
|
|
239
|
+
*/
|
|
240
|
+
const MAX_AX_VALUE_CHARS = 256;
|
|
241
|
+
/** Decode only the response shapes that the versioned Rust protocol owns. */
|
|
242
|
+
export function normalizeNativeComputerState(data) {
|
|
243
|
+
if (!isRecord(data))
|
|
244
|
+
return undefined;
|
|
245
|
+
if (data.type === 'verified_action' && isRecord(data.observation)) {
|
|
246
|
+
return normalizeNativeComputerState({ ...data.observation, type: 'observe' });
|
|
247
|
+
}
|
|
248
|
+
// 数据已经是裸 ComputerState 形状(未包在 verified_action 里)时直接用。
|
|
249
|
+
// 形状容错,不绑定任何具体 transport。
|
|
250
|
+
if (!data.type
|
|
251
|
+
&& typeof data.revision === 'string'
|
|
252
|
+
&& Array.isArray(data.displays)
|
|
253
|
+
&& isRecord(data.screenshot)) {
|
|
254
|
+
// 已经是归一化后的ComputerState,验证必要字段后直接返回
|
|
255
|
+
const displays = Array.isArray(data.displays)
|
|
256
|
+
? data.displays
|
|
257
|
+
.map(normalizeDisplay)
|
|
258
|
+
.filter((d) => !!d)
|
|
259
|
+
: [];
|
|
260
|
+
if (displays.length === 0)
|
|
261
|
+
return undefined;
|
|
262
|
+
const state = data;
|
|
263
|
+
// 确保screenshot有expiresAt
|
|
264
|
+
if (state.screenshot && !state.screenshot.expiresAt) {
|
|
265
|
+
state.screenshot.expiresAt = Date.now() + 5 * 60_000;
|
|
266
|
+
}
|
|
267
|
+
return state;
|
|
268
|
+
}
|
|
269
|
+
if (data.type !== 'observe')
|
|
270
|
+
return undefined;
|
|
271
|
+
const revision = stringAt(data.revision) ?? 'native-observation';
|
|
272
|
+
const displays = Array.isArray(data.displays)
|
|
273
|
+
? data.displays
|
|
274
|
+
.map(normalizeDisplay)
|
|
275
|
+
.filter((display) => !!display)
|
|
276
|
+
: [];
|
|
277
|
+
const activeRaw = isRecord(data.activeApp) ? data.activeApp : undefined;
|
|
278
|
+
const activeApp = activeRaw && stringAt(activeRaw.id) && stringAt(activeRaw.name)
|
|
279
|
+
? {
|
|
280
|
+
id: stringAt(activeRaw.id),
|
|
281
|
+
name: stringAt(activeRaw.name),
|
|
282
|
+
...(typeof activeRaw.pid === 'number' ? { pid: activeRaw.pid } : {}),
|
|
283
|
+
}
|
|
284
|
+
: undefined;
|
|
285
|
+
const windows = Array.isArray(data.windows)
|
|
286
|
+
? data.windows.flatMap((value) => {
|
|
287
|
+
if (!isRecord(value))
|
|
288
|
+
return [];
|
|
289
|
+
const id = stringAt(value.id);
|
|
290
|
+
const appId = stringAt(value.appId);
|
|
291
|
+
const bounds = normalizeRect(value.bounds);
|
|
292
|
+
return id && appId && bounds
|
|
293
|
+
? [
|
|
294
|
+
{
|
|
295
|
+
id,
|
|
296
|
+
appId,
|
|
297
|
+
...(stringAt(value.title)
|
|
298
|
+
? { title: stringAt(value.title) }
|
|
299
|
+
: {}),
|
|
300
|
+
bounds,
|
|
301
|
+
...(typeof value.minimized === 'boolean'
|
|
302
|
+
? { minimized: value.minimized }
|
|
303
|
+
: {}),
|
|
304
|
+
// 守护进程一直在发 focused,类型里也一直声明着,只有这里漏抄——
|
|
305
|
+
// 于是「哪个是主窗口」退化成「列表里第一个」。微信的第一个窗口是
|
|
306
|
+
// 一块 678pt 宽的辅助面板,主窗口 2560pt 排在它后面,结果模型每次
|
|
307
|
+
// 点对了都被警告「点到窗口外面去了」,还会按错窗口重算 region 坐标。
|
|
308
|
+
...(typeof value.focused === 'boolean'
|
|
309
|
+
? { focused: value.focused }
|
|
310
|
+
: {}),
|
|
311
|
+
},
|
|
312
|
+
]
|
|
313
|
+
: [];
|
|
314
|
+
})
|
|
315
|
+
: [];
|
|
316
|
+
const sequence = normalizeSequence(data.sequence);
|
|
317
|
+
const rawAccessibility = isRecord(data.accessibility)
|
|
318
|
+
? data.accessibility
|
|
319
|
+
: undefined;
|
|
320
|
+
const nodes = rawAccessibility && Array.isArray(rawAccessibility.nodes)
|
|
321
|
+
? rawAccessibility.nodes
|
|
322
|
+
: [];
|
|
323
|
+
const screenshotRaw = isRecord(data.screenshot) ? data.screenshot : undefined;
|
|
324
|
+
const screenshot = screenshotRaw &&
|
|
325
|
+
stringAt(screenshotRaw.path) &&
|
|
326
|
+
stringAt(screenshotRaw.mime)
|
|
327
|
+
? {
|
|
328
|
+
path: stringAt(screenshotRaw.path),
|
|
329
|
+
mime: stringAt(screenshotRaw.mime),
|
|
330
|
+
width: numberAt(screenshotRaw.width),
|
|
331
|
+
height: numberAt(screenshotRaw.height),
|
|
332
|
+
byteLength: numberAt(screenshotRaw.byteLen),
|
|
333
|
+
...(stringAt(screenshotRaw.sha256)
|
|
334
|
+
? { sha256: stringAt(screenshotRaw.sha256) }
|
|
335
|
+
: {}),
|
|
336
|
+
...(stringAt(screenshotRaw.displayId)
|
|
337
|
+
? { displayId: stringAt(screenshotRaw.displayId) }
|
|
338
|
+
: {}),
|
|
339
|
+
revision,
|
|
340
|
+
// Native frames live in a protected temporary directory and are short-lived.
|
|
341
|
+
expiresAt: Date.now() + 5 * 60_000,
|
|
342
|
+
}
|
|
343
|
+
: undefined;
|
|
344
|
+
return {
|
|
345
|
+
revision,
|
|
346
|
+
epoch: numberAt(data.epoch),
|
|
347
|
+
displays,
|
|
348
|
+
...(activeApp ? { activeApp } : {}),
|
|
349
|
+
windows,
|
|
350
|
+
...(rawAccessibility
|
|
351
|
+
? {
|
|
352
|
+
accessibility: {
|
|
353
|
+
appId: activeApp?.id ?? '',
|
|
354
|
+
root: {
|
|
355
|
+
elementId: stringAt(rawAccessibility.revision) ?? 'native-root',
|
|
356
|
+
role: 'application',
|
|
357
|
+
children: nodes.filter(isRecord).map((node) => ({
|
|
358
|
+
elementId: stringAt(node.elementId) ?? 'unknown',
|
|
359
|
+
role: stringAt(node.role) ?? 'unknown',
|
|
360
|
+
...(stringAt(node.subrole)
|
|
361
|
+
? { subrole: stringAt(node.subrole) }
|
|
362
|
+
: {}),
|
|
363
|
+
...(stringAt(node.name) ? { name: stringAt(node.name) } : {}),
|
|
364
|
+
// A secure control's contents never travel, whatever the driver
|
|
365
|
+
// sent. The macOS driver declines to read AXValue at all, but this
|
|
366
|
+
// is the last layer before the model's context, and "the producer
|
|
367
|
+
// promised not to" is not an access control. Windows UIA and any
|
|
368
|
+
// future driver get the same treatment for free.
|
|
369
|
+
...(node.protected !== true && stringAt(node.value)
|
|
370
|
+
? {
|
|
371
|
+
value: stringAt(node.value).slice(0, MAX_AX_VALUE_CHARS),
|
|
372
|
+
}
|
|
373
|
+
: {}),
|
|
374
|
+
// `protected` drives the human-handoff boundary. Dropping it here
|
|
375
|
+
// would leave the detector reading an always-safe-looking tree,
|
|
376
|
+
// which is indistinguishable from having no detector at all.
|
|
377
|
+
...(node.protected === true ? { protected: true } : {}),
|
|
378
|
+
...(normalizeRect(node.bounds)
|
|
379
|
+
? { bounds: normalizeRect(node.bounds) }
|
|
380
|
+
: {}),
|
|
381
|
+
enabled: node.enabled !== false,
|
|
382
|
+
focused: node.focused === true,
|
|
383
|
+
actions: Array.isArray(node.actions)
|
|
384
|
+
? node.actions.filter((action) => typeof action === 'string')
|
|
385
|
+
: [],
|
|
386
|
+
})),
|
|
387
|
+
},
|
|
388
|
+
// A truncated or permission-denied walk must never look like a clean
|
|
389
|
+
// "no controls here" reading.
|
|
390
|
+
...(rawAccessibility.truncated === true ? { truncated: true } : {}),
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
: {}),
|
|
394
|
+
...(screenshot ? { screenshot } : {}),
|
|
395
|
+
changedRegions: Array.isArray(data.changedRegions)
|
|
396
|
+
? data.changedRegions
|
|
397
|
+
.map(normalizeRect)
|
|
398
|
+
.filter((rect) => !!rect)
|
|
399
|
+
: [],
|
|
400
|
+
...(sequence.length > 0 ? { sequence } : {}),
|
|
401
|
+
backend: 'native',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** 解出低频彩色帧序列。尺寸与载荷长度对不上的帧直接丢弃,不拿坏数据组图。 */
|
|
405
|
+
function normalizeSequence(value) {
|
|
406
|
+
if (!Array.isArray(value))
|
|
407
|
+
return [];
|
|
408
|
+
return value.flatMap((raw) => {
|
|
409
|
+
if (!isRecord(raw))
|
|
410
|
+
return [];
|
|
411
|
+
const width = numberAt(raw.width);
|
|
412
|
+
const height = numberAt(raw.height);
|
|
413
|
+
const rgbBase64 = stringAt(raw.rgbBase64);
|
|
414
|
+
if (width <= 0 || height <= 0 || !rgbBase64)
|
|
415
|
+
return [];
|
|
416
|
+
return [
|
|
417
|
+
{
|
|
418
|
+
capturedAtMs: numberAt(raw.capturedAtMs),
|
|
419
|
+
width,
|
|
420
|
+
height,
|
|
421
|
+
rgbBase64,
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function mapRelativePoint(value, origin, size) {
|
|
427
|
+
if (value === undefined)
|
|
428
|
+
return undefined;
|
|
429
|
+
return origin + (value / 1000) * size;
|
|
430
|
+
}
|
|
431
|
+
function settlePolicy(action) {
|
|
432
|
+
switch (action) {
|
|
433
|
+
case 'activate_app':
|
|
434
|
+
case 'window':
|
|
435
|
+
case 'file_drop':
|
|
436
|
+
return { quietMs: 300, timeoutMs: 3_000 };
|
|
437
|
+
case 'scroll':
|
|
438
|
+
case 'drag':
|
|
439
|
+
case 'pointer_up':
|
|
440
|
+
return { quietMs: 200, timeoutMs: 2_000 };
|
|
441
|
+
case 'type':
|
|
442
|
+
case 'key':
|
|
443
|
+
case 'hotkey':
|
|
444
|
+
return { quietMs: 150, timeoutMs: 1_500 };
|
|
445
|
+
default:
|
|
446
|
+
return { quietMs: 150, timeoutMs: 2_000 };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function actionExpectedToChangeScreen(action) {
|
|
450
|
+
return [
|
|
451
|
+
'click',
|
|
452
|
+
'double_click',
|
|
453
|
+
'right_click',
|
|
454
|
+
'middle_click',
|
|
455
|
+
'drag',
|
|
456
|
+
'scroll',
|
|
457
|
+
'pointer_up',
|
|
458
|
+
'type',
|
|
459
|
+
'key',
|
|
460
|
+
'hotkey',
|
|
461
|
+
'window',
|
|
462
|
+
'file_drop',
|
|
463
|
+
].includes(action);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* 效果**局部**的动作:预期变化只发生在目标控件那一小块。
|
|
467
|
+
*
|
|
468
|
+
* 对这些动作,「全屏变化率升高」不构成动作生效的证据 —— 屏幕上任何别的东西在动都会
|
|
469
|
+
* 让它升高。点击/滚动/窗口操作不在此列:它们的效果通常是结构性的、覆盖大片区域。
|
|
470
|
+
*/
|
|
471
|
+
function actionEffectIsLocal(action) {
|
|
472
|
+
return ['type', 'key', 'hotkey'].includes(action);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 遍历类动作:「没动」的正确解读是**到边界了**,不是动作失败。
|
|
476
|
+
*
|
|
477
|
+
* 打字不一样 —— 打了字而画面没动,那就是真没落进去,判 failed 是对的。
|
|
478
|
+
*/
|
|
479
|
+
const TRAVERSAL_ACTIONS = new Set(['scroll', 'key']);
|
|
480
|
+
/** 界面变化率低于这个值就认为「屏幕没动」。 */
|
|
481
|
+
const CHANGE_RATIO_THRESHOLD = 0.001;
|
|
482
|
+
/**
|
|
483
|
+
* 判定一次动作「是否真的落到了界面上」。
|
|
484
|
+
*
|
|
485
|
+
* 抽成纯函数是为了能被单测钉死——这段逻辑没有守卫的那段时间里,它漏掉了一次
|
|
486
|
+
* **所有键鼠输入 100% 静默失效**的事故(守护进程把每个事件的类型字段写坏了,
|
|
487
|
+
* 系统全部丢弃,屏幕纹丝不动),而它一路判 passed。
|
|
488
|
+
*
|
|
489
|
+
* 主判据是变化率,**不是** revision。revision 的 hash 里含整屏截图的 sha256,
|
|
490
|
+
* 屏幕上任何一处像素动一下都会让它变——菜单栏时钟跳秒就够了。这里曾经写成
|
|
491
|
+
* `changeRatio >= 0.001 || afterRevision !== beforeRevision`
|
|
492
|
+
* 的那条 `||`,等于让任何一次背景噪声替动作作证,于是那次事故里这道防线一次都没响。
|
|
493
|
+
*
|
|
494
|
+
* 现在按证据强度分三档,revision 降级为只在「压根没测到」时才动用的弱信号:
|
|
495
|
+
* · moved —— 测到了且超阈值:动作确实在屏幕上留下了痕迹
|
|
496
|
+
* · still —— 测到了但没超阈值:这是**确定性的**「什么都没发生」,直接判失败
|
|
497
|
+
* · unmeasured —— 探测本身降级了(守护进程测不出来时字段缺席):最多给 inconclusive,
|
|
498
|
+
* 不能拿 revision 变没变来冒充「通过」
|
|
499
|
+
*/
|
|
500
|
+
export function gradeActionVerification(input) {
|
|
501
|
+
const finiteRatio = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
502
|
+
// ⚠️ **ROI 是权威,全屏只是回落。** 两路测的不是同一件事:
|
|
503
|
+
// · ROI 回答「我这次动作落到目标控件上了吗」
|
|
504
|
+
// · 全屏 回答「屏幕上有没有任何动静」
|
|
505
|
+
//
|
|
506
|
+
// 这里曾经是 `Math.max(ROI, 全屏)`,理由写着「取证据最强的那个,任一有变化都算数」。
|
|
507
|
+
// 那条理由是错的:它让**背景噪声替动作作证**。微信的会话列表、未读角标、时间戳几乎
|
|
508
|
+
// 一直在动,全屏变化率恒超阈值 —— 于是目标输入框纹丝不动也判「通过」。
|
|
509
|
+
//
|
|
510
|
+
// 实测代价(一次「给某人发消息」的真实运行):任务只需要输入 2 次,实际 `type` 了 7 次
|
|
511
|
+
// 且**每次都报成功**。模型看截图发现输入框是空的,只好重新点、重新打,多出的 5 轮
|
|
512
|
+
// click→type 各是一次完整的 LLM 往返(含截图),约占该次任务总耗时的三分之一。
|
|
513
|
+
//
|
|
514
|
+
// 守护进程本来就是**分开**给这两个数的,注释写得很明白:「只给一个数,
|
|
515
|
+
// 『字打进去了』和『控件之外冒出了东西』分不开」。`Math.max` 正好把它花力气
|
|
516
|
+
// 提供的判别力又塌回成一个数。
|
|
517
|
+
//
|
|
518
|
+
// 不能用 `||` 合并 —— 0 是有意义的测量值(「确实没动」),会被 `||` 当成缺失。
|
|
519
|
+
const roiRatio = finiteRatio(input.regionChangeRatio);
|
|
520
|
+
const fullRatio = finiteRatio(input.changeRatio);
|
|
521
|
+
const measuredChangeRatio = roiRatio ?? fullRatio;
|
|
522
|
+
/** 这次判定的证据来自哪一路 —— 决定它有多硬。 */
|
|
523
|
+
const scope = roiRatio !== undefined ? 'roi' : fullRatio !== undefined ? 'fullscreen' : 'none';
|
|
524
|
+
const evidence = measuredChangeRatio === undefined
|
|
525
|
+
? 'unmeasured'
|
|
526
|
+
: measuredChangeRatio >= CHANGE_RATIO_THRESHOLD
|
|
527
|
+
? 'moved'
|
|
528
|
+
: 'still';
|
|
529
|
+
const expectsVisualChange = actionExpectedToChangeScreen(input.action);
|
|
530
|
+
const revisionChanged = input.afterRevision !== input.beforeRevision;
|
|
531
|
+
const changed = evidence === 'moved' || (evidence === 'unmeasured' && revisionChanged);
|
|
532
|
+
const activatedExpectedApp = input.action !== 'activate_app'
|
|
533
|
+
|| !input.expectedAppId
|
|
534
|
+
|| input.actualAppId === input.expectedAppId;
|
|
535
|
+
const withRatio = (verdict) => measuredChangeRatio === undefined ? verdict : { ...verdict, measuredChangeRatio };
|
|
536
|
+
if (!activatedExpectedApp) {
|
|
537
|
+
return withRatio({
|
|
538
|
+
status: 'failed',
|
|
539
|
+
reason: `动作后前台应用不是目标 ${input.expectedAppId ?? ''}。`,
|
|
540
|
+
changed,
|
|
541
|
+
evidence,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
// ⚠️ 遍历类动作「没动」= **到边界了**,那是信息,不是失败。
|
|
545
|
+
//
|
|
546
|
+
// 判成 failed 会连锁出两个恶果:`ok` 变 false(一次正常的「已经翻到头」被当成动作出错),
|
|
547
|
+
// 而 `repeat` 半路遇到 `!next.ok` 会直接 break —— 到底的那一下反而被当成故障中止。
|
|
548
|
+
// 边界该由 `isAtScrollEdge` 读同一个比率去回答,措辞也该是「翻到头了」。
|
|
549
|
+
if (expectsVisualChange && evidence === 'still' && TRAVERSAL_ACTIONS.has(input.action)) {
|
|
550
|
+
// 遍历类动作「没动」不是失败 —— 到边界时本来就不动。
|
|
551
|
+
// 有 movedRatio(动作前后差分)才敢说到底了;只有 settle 那两个比率时,
|
|
552
|
+
// 0 可能只是「翻页是瞬时的、没有动画」,说明不了任何事。
|
|
553
|
+
const moved = finiteRatio(input.movedRatio);
|
|
554
|
+
const atEdge = moved !== undefined && moved < CHANGE_RATIO_THRESHOLD;
|
|
555
|
+
return withRatio({
|
|
556
|
+
status: 'inconclusive',
|
|
557
|
+
reason: atEdge
|
|
558
|
+
? '动作已发出,但**动作前后画面完全相同** —— 已经翻到边界(到顶/到底),'
|
|
559
|
+
+ '同方向再翻不会有新内容。'
|
|
560
|
+
: `动作已发出,但这次测量**回答不了它有没有生效**(变化率 `
|
|
561
|
+
+ `${(measuredChangeRatio ?? 0).toFixed(4)})。这个指标测的是「画面还在不在动」,`
|
|
562
|
+
+ `而翻页是瞬时的、没有动画,测到 0 属于正常 —— **不代表已经翻到头**。`
|
|
563
|
+
+ `请对照最新画面自行确认内容有没有前进。`,
|
|
564
|
+
changed,
|
|
565
|
+
evidence,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if (expectsVisualChange && evidence === 'still') {
|
|
569
|
+
return withRatio({
|
|
570
|
+
status: 'failed',
|
|
571
|
+
reason: `动作后界面变化率仅 ${(measuredChangeRatio ?? 0).toFixed(4)},低于阈值 `
|
|
572
|
+
+ `${CHANGE_RATIO_THRESHOLD}——这次操作很可能根本没有落到界面上。`,
|
|
573
|
+
changed,
|
|
574
|
+
evidence,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// 文字录入类动作的效果是**局部**的(输入框里多几个字符)。只有全屏这一路证据时,
|
|
578
|
+
// 「屏幕动了」完全可能是别处在动 —— 那不构成「字打进去了」的证明。据实报不确定,
|
|
579
|
+
// 而不是把它算作通过:模型据此会去核对,而不是带着一个假的成功往下走。
|
|
580
|
+
//
|
|
581
|
+
// 只收窄到文字录入,不一刀切到所有动作:点击/滚动的效果通常是结构性的,全屏证据
|
|
582
|
+
// 对它们仍然成立,无差别改成不确定只会制造噪声。
|
|
583
|
+
if (expectsVisualChange && evidence === 'moved' && scope === 'fullscreen'
|
|
584
|
+
&& actionEffectIsLocal(input.action)) {
|
|
585
|
+
return withRatio({
|
|
586
|
+
status: 'inconclusive',
|
|
587
|
+
reason: '只测到全屏有变化,没有目标区域的证据 —— 无法确认这次输入真的落进了目标控件'
|
|
588
|
+
+ '(屏幕别处的动静也会让全屏变化率升高)。请对照最新截图确认内容是否已经出现,'
|
|
589
|
+
+ '没出现就重新定位输入框后再输入。',
|
|
590
|
+
changed,
|
|
591
|
+
evidence,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (expectsVisualChange && evidence === 'unmeasured') {
|
|
595
|
+
return withRatio(revisionChanged
|
|
596
|
+
? {
|
|
597
|
+
status: 'inconclusive',
|
|
598
|
+
reason: '界面变化率未能测得,仅凭观测差异无法确认动作已落地;请重新观察确认。',
|
|
599
|
+
changed,
|
|
600
|
+
evidence,
|
|
601
|
+
}
|
|
602
|
+
: {
|
|
603
|
+
status: 'failed',
|
|
604
|
+
reason: '动作后未检测到预期界面变化。',
|
|
605
|
+
changed,
|
|
606
|
+
evidence,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (!input.settled) {
|
|
610
|
+
return withRatio({
|
|
611
|
+
status: 'inconclusive',
|
|
612
|
+
reason: '已取得动作后状态,但稳态或变化证据不足。',
|
|
613
|
+
changed,
|
|
614
|
+
evidence,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return withRatio({
|
|
618
|
+
status: 'passed',
|
|
619
|
+
reason: '动作后状态已重新观测并通过稳态检查。',
|
|
620
|
+
changed,
|
|
621
|
+
evidence,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
class ComputerUseClientError extends Error {
|
|
625
|
+
code;
|
|
626
|
+
constructor(code, message) {
|
|
627
|
+
super(message);
|
|
628
|
+
this.code = code;
|
|
629
|
+
this.name = 'ComputerUseClientError';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function nativeContext(context) {
|
|
633
|
+
return {
|
|
634
|
+
ownerId: context.ownerId,
|
|
635
|
+
conversationKey: context.conversationKey,
|
|
636
|
+
runId: context.runId,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function target(request) {
|
|
640
|
+
if (request.elementId) {
|
|
641
|
+
if (!request.expectedRevision) {
|
|
642
|
+
throw new ComputerUseClientError('invalid_request', '使用 elementId 时必须提供 expectedRevision');
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
kind: 'element',
|
|
646
|
+
elementId: request.elementId,
|
|
647
|
+
revision: request.expectedRevision,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (!request.displayId ||
|
|
651
|
+
request.x === undefined ||
|
|
652
|
+
request.y === undefined) {
|
|
653
|
+
throw new ComputerUseClientError('invalid_request', '坐标动作必须提供 displayId、x 和 y');
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
kind: 'point',
|
|
657
|
+
displayId: request.displayId,
|
|
658
|
+
point: { x: request.x, y: request.y },
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function nativeAction(request) {
|
|
662
|
+
switch (request.action) {
|
|
663
|
+
case 'click':
|
|
664
|
+
case 'double_click':
|
|
665
|
+
case 'right_click':
|
|
666
|
+
case 'middle_click':
|
|
667
|
+
return {
|
|
668
|
+
kind: 'click',
|
|
669
|
+
target: target(request),
|
|
670
|
+
button: request.action === 'right_click'
|
|
671
|
+
? 'right'
|
|
672
|
+
: request.action === 'middle_click'
|
|
673
|
+
? 'middle'
|
|
674
|
+
: 'left',
|
|
675
|
+
count: request.action === 'double_click' ? 2 : 1,
|
|
676
|
+
};
|
|
677
|
+
case 'hover':
|
|
678
|
+
return { kind: 'hover', target: target(request) };
|
|
679
|
+
case 'pointer_down':
|
|
680
|
+
case 'pointer_move':
|
|
681
|
+
case 'pointer_up':
|
|
682
|
+
if (request.x === undefined || request.y === undefined) {
|
|
683
|
+
throw new ComputerUseClientError('invalid_request', `${request.action} 必须提供 x 和 y`);
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
kind: 'pointer',
|
|
687
|
+
phase: request.action.slice('pointer_'.length),
|
|
688
|
+
point: { x: request.x, y: request.y },
|
|
689
|
+
buttons: request.action === 'pointer_up' ? 0 : 1,
|
|
690
|
+
};
|
|
691
|
+
case 'drag':
|
|
692
|
+
if (request.toX === undefined ||
|
|
693
|
+
request.toY === undefined ||
|
|
694
|
+
!request.displayId) {
|
|
695
|
+
throw new ComputerUseClientError('invalid_request', 'drag 必须提供 displayId、toX 和 toY');
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
kind: 'drag',
|
|
699
|
+
from: target(request),
|
|
700
|
+
to: request.elementId
|
|
701
|
+
? {
|
|
702
|
+
kind: 'point',
|
|
703
|
+
displayId: request.displayId,
|
|
704
|
+
point: { x: request.toX, y: request.toY },
|
|
705
|
+
}
|
|
706
|
+
: target({ ...request, x: request.toX, y: request.toY }),
|
|
707
|
+
durationMs: request.timeoutMs ?? 500,
|
|
708
|
+
};
|
|
709
|
+
case 'scroll': {
|
|
710
|
+
let deltaX = request.dx ?? 0;
|
|
711
|
+
let deltaY = request.dy ?? 0;
|
|
712
|
+
if (request.direction) {
|
|
713
|
+
const scrollAmount = 500;
|
|
714
|
+
switch (request.direction) {
|
|
715
|
+
case 'up':
|
|
716
|
+
deltaY = -scrollAmount;
|
|
717
|
+
break;
|
|
718
|
+
case 'down':
|
|
719
|
+
deltaY = scrollAmount;
|
|
720
|
+
break;
|
|
721
|
+
case 'left':
|
|
722
|
+
deltaX = -scrollAmount;
|
|
723
|
+
break;
|
|
724
|
+
case 'right':
|
|
725
|
+
deltaX = scrollAmount;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// ⚠️ **坐标必须传下去。**
|
|
730
|
+
//
|
|
731
|
+
// 这里以前只发 deltaX/deltaY,x/y 被静默丢弃 —— 而工具描述一直写着
|
|
732
|
+
// 「x/y 指向要滚动的区域中心」。macOS 的滚轮事件不携带位置,投递给**当前光标
|
|
733
|
+
// 所在**的视图,于是滚轮实际落在上一次点击留下的地方。
|
|
734
|
+
//
|
|
735
|
+
// 实测抓到:任务要滚聊天记录,模型规规矩矩给了聊天区中心的坐标,连滚三次 ——
|
|
736
|
+
// 右侧聊天记录一行没动,左侧(上一次点过的)会话列表滚了一大截。三次都报成功,
|
|
737
|
+
// 因为全屏确实变了。工具承诺了实现没做的事,模型无从察觉。
|
|
738
|
+
//
|
|
739
|
+
// 坐标此刻已被 mapRelativeCoordinates 换算过(scroll 在 needsPoint 名单里),
|
|
740
|
+
// 直接复用 target() 构造,与其它指针动作走同一条路。
|
|
741
|
+
const at = request.elementId || (request.displayId && request.x !== undefined && request.y !== undefined)
|
|
742
|
+
? target(request)
|
|
743
|
+
: undefined;
|
|
744
|
+
return {
|
|
745
|
+
kind: 'scroll',
|
|
746
|
+
deltaX,
|
|
747
|
+
deltaY,
|
|
748
|
+
...(at ? { at } : {}),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
case 'type':
|
|
752
|
+
if (request.text === undefined)
|
|
753
|
+
throw new ComputerUseClientError('invalid_request', 'type 必须提供 text');
|
|
754
|
+
return { kind: 'type_text', text: request.text, replaceSelection: false };
|
|
755
|
+
case 'key':
|
|
756
|
+
if (!request.key)
|
|
757
|
+
throw new ComputerUseClientError('invalid_request', 'key 必须提供 key');
|
|
758
|
+
return { kind: 'key', key: request.key, modifiers: request.modifiers ?? [] };
|
|
759
|
+
case 'hotkey': {
|
|
760
|
+
if (!request.key)
|
|
761
|
+
throw new ComputerUseClientError('invalid_request', 'hotkey 必须提供 key(格式:command+c, command+shift+g)');
|
|
762
|
+
const parts = request.key.split('+');
|
|
763
|
+
const mainKey = parts.pop();
|
|
764
|
+
const modifiers = parts;
|
|
765
|
+
return { kind: 'key', key: mainKey, modifiers };
|
|
766
|
+
}
|
|
767
|
+
case 'clipboard_read':
|
|
768
|
+
return { kind: 'clipboard_read' };
|
|
769
|
+
case 'clipboard_write':
|
|
770
|
+
if (request.text === undefined) {
|
|
771
|
+
throw new ComputerUseClientError('invalid_request', 'clipboard_write 必须提供 text');
|
|
772
|
+
}
|
|
773
|
+
return { kind: 'clipboard_write', text: request.text };
|
|
774
|
+
case 'grab_region_to_clipboard':
|
|
775
|
+
if (!request.rect) {
|
|
776
|
+
throw new ComputerUseClientError('invalid_request', 'grab_region_to_clipboard 必须提供 rect');
|
|
777
|
+
}
|
|
778
|
+
return { kind: 'grab_region_to_clipboard', rect: request.rect };
|
|
779
|
+
case 'activate_app':
|
|
780
|
+
if (!request.appId)
|
|
781
|
+
throw new ComputerUseClientError('invalid_request', 'activate_app 必须提供 appId');
|
|
782
|
+
return { kind: 'activate_app', appId: request.appId };
|
|
783
|
+
case 'window': {
|
|
784
|
+
if (!request.windowId)
|
|
785
|
+
throw new ComputerUseClientError('invalid_request', 'window 必须提供 windowId');
|
|
786
|
+
const op = request.windowOperation ?? 'focus';
|
|
787
|
+
if (op === 'move_resize' && !request.rect) {
|
|
788
|
+
throw new ComputerUseClientError('invalid_request', 'window move_resize 必须提供 rect');
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
kind: 'window',
|
|
792
|
+
windowId: request.windowId,
|
|
793
|
+
// Rust's WindowOperation is externally tagged: unit variants are bare
|
|
794
|
+
// strings, the struct variant carries its payload.
|
|
795
|
+
// `center` 是 struct variant(带 force 字段),与 move_resize 一样不能
|
|
796
|
+
// 序列化成裸字符串 —— 那样 Rust 侧反序列化会直接失败。
|
|
797
|
+
operation: op === 'move_resize'
|
|
798
|
+
? { move_resize: { bounds: request.rect } }
|
|
799
|
+
: op === 'center'
|
|
800
|
+
? { center: { force: request.force === true } }
|
|
801
|
+
: op,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
case 'file_drop':
|
|
805
|
+
if (!request.filePaths?.length) {
|
|
806
|
+
throw new ComputerUseClientError('invalid_request', 'file_drop 必须提供至少一个文件路径');
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
kind: 'file_drop',
|
|
810
|
+
paths: request.filePaths,
|
|
811
|
+
// Optional on purpose: with no drop target the paste lands in whatever
|
|
812
|
+
// already has focus, which is often what the caller wants. Synthesising
|
|
813
|
+
// a (0,0) target instead would click the corner of the screen.
|
|
814
|
+
...(request.elementId || (request.displayId && request.x !== undefined)
|
|
815
|
+
? { target: target(request) }
|
|
816
|
+
: {}),
|
|
817
|
+
method: request.dropMethod ?? 'paste',
|
|
818
|
+
};
|
|
819
|
+
default:
|
|
820
|
+
throw new ComputerUseClientError('unsupported', `${request.action} 尚未映射到原生动作协议`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* 这次输入动作要不要顺手测一下界面变化率。
|
|
825
|
+
*
|
|
826
|
+
* @internal 判据依赖上一次观测,见下。
|
|
827
|
+
*
|
|
828
|
+
* 判断留在 TS 而不是守护进程,因为它依赖一件只有宿主知道的事:**上一次观测
|
|
829
|
+
* 的无障碍树到底可不可用**。
|
|
830
|
+
*
|
|
831
|
+
* 两个条件同时成立才探测:
|
|
832
|
+
* ① 动作是 `type` —— 这条判据要回答的是「**打字**过程中冒出了联想框/补全/
|
|
833
|
+
* 确认框吗」。曾经把 `key` 也算进来,那是错的:回车导航、Cmd+Tab 切应用、
|
|
834
|
+
* 翻页快捷键都会让整屏大面积变化,于是每一次正常的键盘操作都被判成
|
|
835
|
+
* 「弹出了可疑东西」并停下来找人。按键的意图千差万别,用同一条像素判据
|
|
836
|
+
* 去套必然误报。
|
|
837
|
+
* ② 上次观测的 AX 树是 `truncated` —— 树好用的时候有更准的 AX 判据
|
|
838
|
+
* (`detectComputerHumanBoundary`),不必用像素这条更钝的信号添乱
|
|
839
|
+
*
|
|
840
|
+
* 于是探测那 ~400ms 只花在真正瞎的应用上(微信、Electron 系),而这恰好是
|
|
841
|
+
* 唯一需要它的地方。AX 树正常的应用零额外成本。
|
|
842
|
+
*/
|
|
843
|
+
/**
|
|
844
|
+
* 效果可以由「目标区域动没动」回答的动作。
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ 这里原先只有 `type`,理由(见下方历史注释)是:点击/滚动引起的变化本来就是预期
|
|
847
|
+
* 结果,拿变化率去判「是不是出意外了」必然满屏误报。**那个理由本身没错,但它混同了
|
|
848
|
+
* 两个不同的问题**:
|
|
849
|
+
* · 「有没有出意外」—— 确实只对打字有意义,这一路的消费方是 `classifyPixelChange`,
|
|
850
|
+
* 它自己用 `typing` 门禁挡住了非输入动作(humanBoundary.ts:148),不受本表影响;
|
|
851
|
+
* · 「**动作到底生效了没有**」—— 对滚动/点击同样成立,而且语义明确:
|
|
852
|
+
* 目标区域没动 = 没滚动 = 到边界了 / 没点中。
|
|
853
|
+
*
|
|
854
|
+
* 后者恰恰是整条反馈链路的地基,而它此前从未被测量过。实测代价:`repeat` 的边界自动停
|
|
855
|
+
* 是死代码、type 恒判 inconclusive(诚实文案退化成恒定误报)、「滚动没生效」的提示恒不
|
|
856
|
+
* 触发、点击零命中反馈 —— 模型只能盲操作 + 肉眼看截图。
|
|
857
|
+
*/
|
|
858
|
+
const PROBE_CHANGE_ACTIONS = new Set(['type', 'scroll', 'key']);
|
|
859
|
+
/**
|
|
860
|
+
* ⛔ **点击类动作不在上表里,这是实测定下来的边界,别再加回去。**
|
|
861
|
+
*
|
|
862
|
+
* 关键在于 `probe_change_ratio` 测的**不是**「动作前 vs 动作后的差异」,而是
|
|
863
|
+
* `settle_timeline(region, 120, 400)` —— 在动作之后的一个窗口里**逐帧对比相邻采样**,
|
|
864
|
+
* 回答的是「画面还在不在动」。
|
|
865
|
+
*
|
|
866
|
+
* 于是三类动作的契合度完全不同:
|
|
867
|
+
* · `type` —— 逐字上屏的重绘落在窗口内 ✅
|
|
868
|
+
* · `scroll` / `key`(翻页) —— 滚动持续到窗口内;到边界时纹丝不动,0 正是「到底了」✅
|
|
869
|
+
* · `click` —— 重绘往往在采样开始前就结束了,**动作成功也测出 0** ❌
|
|
870
|
+
*
|
|
871
|
+
* 一次把 click 加进来的实测代价:10 步里 7 步被判 `verification_failed:
|
|
872
|
+
* 动作后界面变化率仅 0.0000`,而那些点击在截图上明明生效了。原注释担心的
|
|
873
|
+
* 「拿变化率判点击必然误报」是对的,只是理由要说准 —— 不是「变化本来就是预期结果」,
|
|
874
|
+
* 是**这个测量根本回答不了点击那个问题**。
|
|
875
|
+
*/
|
|
876
|
+
/**
|
|
877
|
+
* 要不要让守护进程顺带做一次像素变化探测。
|
|
878
|
+
*
|
|
879
|
+
* 探测每次要几百毫秒,所以判据要窄 —— 但要窄在**对的维度**上:
|
|
880
|
+
*
|
|
881
|
+
* ① **动作**:见 `PROBE_CHANGE_ACTIONS`。
|
|
882
|
+
*
|
|
883
|
+
* ② **只在 AX 给不出控件级信息时**。有真实 AX 树的应用,焦点变化、控件角色这些信号
|
|
884
|
+
* 比像素准得多且几乎免费(见 `humanBoundary.ts::detectComputerHumanBoundary`),
|
|
885
|
+
* 再花几百毫秒去数像素纯属把成本外溢给正常应用。像素是自绘 / Electron
|
|
886
|
+
* 这类"AX 树只是个窗口壳"的应用**唯一剩下**的手段,探测该留给它们。
|
|
887
|
+
*
|
|
888
|
+
* 这个参数此前是 `_observation`(收下却从不读),于是判据退化成
|
|
889
|
+
* 「凡输入类动作一律探测」——死参数照例是 bug 的强信号。
|
|
890
|
+
*/
|
|
891
|
+
function shouldProbeChange(action, observation) {
|
|
892
|
+
if (!PROBE_CHANGE_ACTIONS.has(action))
|
|
893
|
+
return false;
|
|
894
|
+
return !axGivesControlDetail(observation);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* 探测用的 ROI:上一次观测里焦点控件的矩形(逻辑点)。
|
|
898
|
+
*
|
|
899
|
+
* 拿不到就返回 undefined,守护进程退回整屏测量 —— 那仍然可用,只是判据更钝
|
|
900
|
+
* (见 `humanBoundary.ts::detectPixelHumanBoundary` 里的面积归一化)。
|
|
901
|
+
*
|
|
902
|
+
* **不经相对坐标换算**:AX 给的 bounds 本来就是逻辑点,再换算一次会得到一块
|
|
903
|
+
* 完全不相干的区域,而且不会报错——只会安静地测错地方。
|
|
904
|
+
*/
|
|
905
|
+
/** 回落 ROI 的边长,占显示器短边的比例。够罩住一个输入框,又不至于把半屏噪声收进来。 */
|
|
906
|
+
const FALLBACK_PROBE_SPAN = 0.12;
|
|
907
|
+
/**
|
|
908
|
+
* 这次动作的「目标区域」——用来回答「动作有没有落到目标上」。
|
|
909
|
+
*
|
|
910
|
+
* 首选 AX 焦点控件的矩形。**但自绘应用给不出它**:微信的 AX 树整棵只有 5 个节点、
|
|
911
|
+
* `focusLevel` 恒为 1(只有窗口壳),于是 ROI 永远缺席,判定只能退到全屏 —— 而全屏
|
|
912
|
+
* 在聊天软件上几乎恒有动静,等于没有判定。这不是边缘情况,是这类应用的常态。
|
|
913
|
+
*
|
|
914
|
+
* 所以拿不到 AX 时用**刚刚点击的那个位置**兜底:对「点输入框 → 打字」这个最常见的
|
|
915
|
+
* 序列,点击点就是文字将要出现的地方,围绕它取一小块就是正确的观察窗口。
|
|
916
|
+
*
|
|
917
|
+
* `point` 是 0–1000 相对坐标(模型的坐标系),这里换算成逻辑点 —— 与 AX 矩形同一
|
|
918
|
+
* 空间,因为守护进程按逻辑点解释 probeRegion(见调用处注释:ROI 不能再过一次
|
|
919
|
+
* mapRelativeCoordinates)。
|
|
920
|
+
*/
|
|
921
|
+
export function probeRegionFor(observation, point, action) {
|
|
922
|
+
if (!observation)
|
|
923
|
+
return undefined;
|
|
924
|
+
// ⚠️ **只在焦点节点确实是个控件时才信它的矩形。**
|
|
925
|
+
//
|
|
926
|
+
// 与 `shouldProbeChange` 共用同一个判据不是为了省代码 —— 「要不要测」和「测哪里」
|
|
927
|
+
// 必须对「这个 AX 焦点算不算数」给出同一个答案,否则会出现最坏的组合:
|
|
928
|
+
// 判定去测了,却测在一块永远不变的地方,于是**言之凿凿地报「没有变化」**。
|
|
929
|
+
//
|
|
930
|
+
// 实测:微信读书报 `AXGroup · caret(51,97)` —— 窗口左上角的退化包装节点。
|
|
931
|
+
// ROI 落在那个角上,右箭头把书从 32/106 翻到 34/106(整页正文全换),
|
|
932
|
+
// movedRatio 依旧是 0。模型收到的是一条**证据确凿的错误结论**。
|
|
933
|
+
if (axFocusIsConcreteControl(observation)) {
|
|
934
|
+
const caret = deriveFocusSnapshot(observation)?.caret;
|
|
935
|
+
if (caret)
|
|
936
|
+
return caret;
|
|
937
|
+
}
|
|
938
|
+
// ⚠️ **遍历类动作不吃「上次点击位置」这个兜底。**
|
|
939
|
+
// 指针落点之所以能当 ROI,靠的是「点输入框 → 在那里打字」这条因果链。
|
|
940
|
+
// `key: right` / `scroll` 没有这条链:上一次点击可能是十几步之前为了打开这本书,
|
|
941
|
+
// 围着那个陈旧的点取一小块,测的还是一块不相干的区域 —— 又一次「测了个寂寞」。
|
|
942
|
+
// 这类动作的效果本来就是整页/整个视口的,退回整屏才是正确的观察窗口。
|
|
943
|
+
if (action && TRAVERSAL_ACTIONS.has(action))
|
|
944
|
+
return undefined;
|
|
945
|
+
// 容器焦点 + 没有指针落点 → 返回 undefined,守护进程退回**整屏**测量。
|
|
946
|
+
if (!point)
|
|
947
|
+
return undefined;
|
|
948
|
+
const display = observation.displays?.find((candidate) => candidate.primary)
|
|
949
|
+
?? observation.displays?.[0];
|
|
950
|
+
const bounds = display?.logicalBounds;
|
|
951
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0)
|
|
952
|
+
return undefined;
|
|
953
|
+
const span = Math.min(bounds.width, bounds.height) * FALLBACK_PROBE_SPAN;
|
|
954
|
+
const centerX = bounds.x + (point.x / 1000) * bounds.width;
|
|
955
|
+
const centerY = bounds.y + (point.y / 1000) * bounds.height;
|
|
956
|
+
// 夹回显示器内:越界的矩形在采样侧要么被拒、要么退化成全屏,两种都会让 ROI 白设。
|
|
957
|
+
const x = Math.max(bounds.x, Math.min(centerX - span / 2, bounds.x + bounds.width - span));
|
|
958
|
+
const y = Math.max(bounds.y, Math.min(centerY - span / 2, bounds.y + bounds.height - span));
|
|
959
|
+
return { x, y, width: span, height: span };
|
|
960
|
+
}
|
|
961
|
+
export function toNativeComputerUseRequest(request) {
|
|
962
|
+
const context = nativeContext(request.context);
|
|
963
|
+
if (request.action === 'status')
|
|
964
|
+
return { type: 'hello', supported: { min: 1, max: 1 } };
|
|
965
|
+
if (request.action === 'permissions')
|
|
966
|
+
return { type: 'permissions' };
|
|
967
|
+
if (request.action === 'observe') {
|
|
968
|
+
return {
|
|
969
|
+
type: 'observe',
|
|
970
|
+
context,
|
|
971
|
+
includeScreenshot: true,
|
|
972
|
+
...(request._internalObserve ? { internal: true } : {}),
|
|
973
|
+
...(request.frames && request.frames > 1
|
|
974
|
+
? { frames: request.frames }
|
|
975
|
+
: {}),
|
|
976
|
+
...(request.rect ? { rect: request.rect } : {}),
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
// Host diagnostics use a metadata-only observation. It intentionally stays
|
|
980
|
+
// outside the model-visible action list until the native app inventory has
|
|
981
|
+
// a stable, cross-platform contract.
|
|
982
|
+
if (request.action === 'list_apps') {
|
|
983
|
+
return {
|
|
984
|
+
type: 'observe',
|
|
985
|
+
context,
|
|
986
|
+
includeScreenshot: false,
|
|
987
|
+
internal: true,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
// 30 帧是**守护进程侧**的上限,不是给模型的图数 —— TS 侧会用事件锚定挑出
|
|
991
|
+
// 4 帧再编码(见 observationImages::buildReplayImages)。要多要少都在这里定,
|
|
992
|
+
// 别在模型那边定。
|
|
993
|
+
if (request.action === 'replay')
|
|
994
|
+
return { type: 'replay', context, maxFrames: 30 };
|
|
995
|
+
if (request.action === 'settle') {
|
|
996
|
+
return {
|
|
997
|
+
type: 'settle',
|
|
998
|
+
context,
|
|
999
|
+
expectedEpoch: request.expectedEpoch ?? 0,
|
|
1000
|
+
quietMs: request.quietMs ?? 300,
|
|
1001
|
+
timeoutMs: request.timeoutMs ?? 3_000,
|
|
1002
|
+
region: request.rect ?? null,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (request._verifiedAction) {
|
|
1006
|
+
if (request.expectedEpoch === undefined) {
|
|
1007
|
+
throw new ComputerUseClientError('invalid_request', `${request.action} 前必须 observe 并提供 expectedEpoch`);
|
|
1008
|
+
}
|
|
1009
|
+
if (!request.context.grantId?.trim()) {
|
|
1010
|
+
throw new ComputerUseClientError('grant_required', `${request.action} 必须携带有效 grantId`);
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
type: 'execute_verified_action',
|
|
1014
|
+
context,
|
|
1015
|
+
grantId: request.context.grantId,
|
|
1016
|
+
expectedEpoch: request.expectedEpoch,
|
|
1017
|
+
action: nativeAction(request),
|
|
1018
|
+
...(request.probeChange ? { probeChange: true } : {}),
|
|
1019
|
+
...(request.probeRegion ? { probeRegion: request.probeRegion } : {}),
|
|
1020
|
+
quietMs: request._verifiedAction.quietMs,
|
|
1021
|
+
timeoutMs: request._verifiedAction.timeoutMs,
|
|
1022
|
+
...(request._verifiedAction.region
|
|
1023
|
+
? { settleRegion: request._verifiedAction.region }
|
|
1024
|
+
: {}),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
if (request.action === 'stop' || request.action === 'emergency_stop') {
|
|
1028
|
+
return { type: 'emergency_stop', context, reason: request.action };
|
|
1029
|
+
}
|
|
1030
|
+
// activate_app 通过 bundleId 工作,不依赖之前的屏幕观测状态,
|
|
1031
|
+
// 不需要 expectedEpoch(默认传 0 即可)。用户刚打开 app 时也不可能先 observe。
|
|
1032
|
+
if (request.action === 'activate_app') {
|
|
1033
|
+
return {
|
|
1034
|
+
type: 'action',
|
|
1035
|
+
context,
|
|
1036
|
+
grantId: request.context.grantId ?? '',
|
|
1037
|
+
expectedEpoch: request.expectedEpoch ?? 0,
|
|
1038
|
+
action: nativeAction(request),
|
|
1039
|
+
...(request.probeChange ? { probeChange: true } : {}),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if ([
|
|
1043
|
+
'select_text',
|
|
1044
|
+
'secondary_action',
|
|
1045
|
+
'wait_stable',
|
|
1046
|
+
'wait_for_element',
|
|
1047
|
+
'wait_for_text',
|
|
1048
|
+
'wait_for_window',
|
|
1049
|
+
'takeover',
|
|
1050
|
+
'resume',
|
|
1051
|
+
].includes(request.action)) {
|
|
1052
|
+
throw new ComputerUseClientError('unsupported', `${request.action} 尚未实现原生 Wire 请求`);
|
|
1053
|
+
}
|
|
1054
|
+
if (request.expectedEpoch === undefined) {
|
|
1055
|
+
throw new ComputerUseClientError('invalid_request', `${request.action} 前必须 observe 并提供 expectedEpoch`);
|
|
1056
|
+
}
|
|
1057
|
+
if (!request.context.grantId?.trim()) {
|
|
1058
|
+
throw new ComputerUseClientError('grant_required', `${request.action} 必须携带有效 grantId`);
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
type: 'action',
|
|
1062
|
+
context,
|
|
1063
|
+
grantId: request.context.grantId ?? '',
|
|
1064
|
+
expectedEpoch: request.expectedEpoch,
|
|
1065
|
+
action: nativeAction(request),
|
|
1066
|
+
...(request.probeChange ? { probeChange: true } : {}),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
export function createSignedComputerUseWire(input) {
|
|
1070
|
+
return createSignedNativeWire({
|
|
1071
|
+
nativeRequest: toNativeComputerUseRequest(input.request),
|
|
1072
|
+
requestId: input.request.requestId,
|
|
1073
|
+
token: input.token,
|
|
1074
|
+
nonce: input.nonce,
|
|
1075
|
+
timestampMs: input.timestampMs,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
function createSignedNativeWire(input) {
|
|
1079
|
+
const payloadHash = createHash('sha256')
|
|
1080
|
+
.update(JSON.stringify(input.nativeRequest))
|
|
1081
|
+
.digest('hex');
|
|
1082
|
+
const signing = `${COMPUTER_USE_PROTOCOL_VERSION}\n${input.requestId}\n${input.timestampMs}\n${input.nonce}\n${payloadHash}`;
|
|
1083
|
+
return {
|
|
1084
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1085
|
+
requestId: input.requestId,
|
|
1086
|
+
auth: {
|
|
1087
|
+
nonce: input.nonce,
|
|
1088
|
+
timestampMs: input.timestampMs,
|
|
1089
|
+
macHex: createHmac('sha256', input.token).update(signing).digest('hex'),
|
|
1090
|
+
},
|
|
1091
|
+
request: input.nativeRequest,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* 是否是「连不上守护进程」而非业务失败。
|
|
1096
|
+
*
|
|
1097
|
+
* 只有这一类值得清掉陈旧 socket 重试;业务错误重试没有意义,而且对写操作
|
|
1098
|
+
* 来说重试可能造成重复动作。
|
|
1099
|
+
*/
|
|
1100
|
+
function isConnectionFailure(error) {
|
|
1101
|
+
const code = error?.code;
|
|
1102
|
+
return (code === 'ECONNREFUSED' ||
|
|
1103
|
+
code === 'ENOENT' ||
|
|
1104
|
+
code === 'EPIPE' ||
|
|
1105
|
+
code === 'ECONNRESET');
|
|
1106
|
+
}
|
|
1107
|
+
function mapErrorCode(code) {
|
|
1108
|
+
const map = {
|
|
1109
|
+
unsupported: 'unsupported',
|
|
1110
|
+
permission_denied: 'permission_denied',
|
|
1111
|
+
capture_transient: 'capture_transient',
|
|
1112
|
+
invalid_request: 'invalid_request',
|
|
1113
|
+
protocol_mismatch: 'protocol_mismatch',
|
|
1114
|
+
stale_epoch: 'stale_state',
|
|
1115
|
+
run_not_found: 'stale_state',
|
|
1116
|
+
// 与 stale_state 分开:「应用没启动」重新观测也不会让它出现,
|
|
1117
|
+
// 建议模型「再试一次」是错的指引。
|
|
1118
|
+
target_not_found: 'target_not_found',
|
|
1119
|
+
input_lease_unavailable: 'lease_conflict',
|
|
1120
|
+
human_input_detected: 'human_input_detected',
|
|
1121
|
+
emergency_stopped: 'cancelled',
|
|
1122
|
+
ipc_failure: 'unavailable',
|
|
1123
|
+
authentication_failed: 'permission_denied',
|
|
1124
|
+
nonce_replay: 'permission_denied',
|
|
1125
|
+
request_expired: 'invalid_request',
|
|
1126
|
+
};
|
|
1127
|
+
return map[code] ?? 'internal';
|
|
1128
|
+
}
|
|
1129
|
+
class NativeDaemonTransport {
|
|
1130
|
+
helperPath;
|
|
1131
|
+
stateDir;
|
|
1132
|
+
startPromise = null;
|
|
1133
|
+
daemonProcess = null;
|
|
1134
|
+
permissionPrompted = false;
|
|
1135
|
+
constructor(helperPath, stateDir) {
|
|
1136
|
+
this.helperPath = helperPath;
|
|
1137
|
+
this.stateDir = stateDir;
|
|
1138
|
+
}
|
|
1139
|
+
/** Kill the daemon if running. Called on dispose. */
|
|
1140
|
+
killDaemon() {
|
|
1141
|
+
if (this.daemonProcess) {
|
|
1142
|
+
try {
|
|
1143
|
+
this.daemonProcess.kill();
|
|
1144
|
+
}
|
|
1145
|
+
catch {
|
|
1146
|
+
// already dead
|
|
1147
|
+
}
|
|
1148
|
+
this.daemonProcess = null;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/** 一次完整往返:确保守护进程在跑 → 读配对 token → 签名 → 发送。 */
|
|
1152
|
+
async exchange(request, signal, restart) {
|
|
1153
|
+
await this.ensureDaemon(signal, restart);
|
|
1154
|
+
const token = await readFile(join(this.stateDir, 'pairing.token'));
|
|
1155
|
+
const wire = createSignedComputerUseWire({
|
|
1156
|
+
request,
|
|
1157
|
+
token,
|
|
1158
|
+
nonce: randomUUID(),
|
|
1159
|
+
timestampMs: Date.now(),
|
|
1160
|
+
});
|
|
1161
|
+
return this.sendWire(wire, signal);
|
|
1162
|
+
}
|
|
1163
|
+
async request(request, signal) {
|
|
1164
|
+
try {
|
|
1165
|
+
const response = await this.exchange(request, signal, false)
|
|
1166
|
+
// 连不上就当作「守护进程死了但 socket 文件还在」,清掉重启再试一次。
|
|
1167
|
+
// 只重试连接失败,不重试业务错误 —— 后者重试没有意义且可能重复动作。
|
|
1168
|
+
.catch(async (error) => {
|
|
1169
|
+
if (signal?.aborted || !isConnectionFailure(error))
|
|
1170
|
+
throw error;
|
|
1171
|
+
return this.exchange(request, signal, true);
|
|
1172
|
+
});
|
|
1173
|
+
if ('Err' in response.response) {
|
|
1174
|
+
const errorCode = mapErrorCode(response.response.Err.code);
|
|
1175
|
+
return {
|
|
1176
|
+
protocolVersion: response.protocolVersion,
|
|
1177
|
+
requestId: response.requestId,
|
|
1178
|
+
ok: false,
|
|
1179
|
+
error: {
|
|
1180
|
+
code: errorCode,
|
|
1181
|
+
message: response.response.Err.message,
|
|
1182
|
+
retryable: response.response.Err.retryable === true,
|
|
1183
|
+
details: isRecord(response.response.Err.details)
|
|
1184
|
+
? response.response.Err.details
|
|
1185
|
+
: undefined,
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const data = response.response.Ok;
|
|
1190
|
+
return {
|
|
1191
|
+
protocolVersion: response.protocolVersion,
|
|
1192
|
+
requestId: response.requestId,
|
|
1193
|
+
ok: true,
|
|
1194
|
+
// `data` is kept for the protocol normalizer and diagnostics. Do not
|
|
1195
|
+
// stringify a whole native observation into `content`: the tool layer
|
|
1196
|
+
// already emits a compact state summary plus one image. Repeating the
|
|
1197
|
+
// AX tree and screenshot metadata here costs thousands of tokens per
|
|
1198
|
+
// action and makes a CLI run appear to stall between mouse events.
|
|
1199
|
+
data,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
catch (error) {
|
|
1203
|
+
return {
|
|
1204
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1205
|
+
requestId: request.requestId,
|
|
1206
|
+
ok: false,
|
|
1207
|
+
error: {
|
|
1208
|
+
code: signal?.aborted
|
|
1209
|
+
? 'cancelled'
|
|
1210
|
+
: error instanceof ComputerUseClientError
|
|
1211
|
+
? error.code
|
|
1212
|
+
: 'unavailable',
|
|
1213
|
+
message: error.message,
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async emergencyStopAll(signal) {
|
|
1219
|
+
const requestId = randomUUID();
|
|
1220
|
+
try {
|
|
1221
|
+
await this.ensureDaemon(signal);
|
|
1222
|
+
const token = await readFile(join(this.stateDir, 'pairing.token'));
|
|
1223
|
+
const wire = createSignedNativeWire({
|
|
1224
|
+
nativeRequest: {
|
|
1225
|
+
type: 'emergency_stop',
|
|
1226
|
+
context: null,
|
|
1227
|
+
reason: 'desktop_global_hotkey',
|
|
1228
|
+
},
|
|
1229
|
+
requestId,
|
|
1230
|
+
token,
|
|
1231
|
+
nonce: randomUUID(),
|
|
1232
|
+
timestampMs: Date.now(),
|
|
1233
|
+
});
|
|
1234
|
+
const response = await this.sendWire(wire, signal);
|
|
1235
|
+
if ('Err' in response.response) {
|
|
1236
|
+
return {
|
|
1237
|
+
protocolVersion: response.protocolVersion,
|
|
1238
|
+
requestId: response.requestId,
|
|
1239
|
+
ok: false,
|
|
1240
|
+
error: {
|
|
1241
|
+
code: mapErrorCode(response.response.Err.code),
|
|
1242
|
+
message: response.response.Err.message,
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
protocolVersion: response.protocolVersion,
|
|
1248
|
+
requestId: response.requestId,
|
|
1249
|
+
ok: true,
|
|
1250
|
+
content: JSON.stringify(response.response.Ok),
|
|
1251
|
+
data: response.response.Ok,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
return {
|
|
1256
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1257
|
+
requestId,
|
|
1258
|
+
ok: false,
|
|
1259
|
+
error: {
|
|
1260
|
+
code: signal?.aborted ? 'cancelled' : 'unavailable',
|
|
1261
|
+
message: error.message,
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
async inputStatus(context, acknowledge = false, signal) {
|
|
1267
|
+
const requestId = randomUUID();
|
|
1268
|
+
const send = async (restart) => {
|
|
1269
|
+
await this.ensureDaemon(signal, restart);
|
|
1270
|
+
const token = await readFile(join(this.stateDir, 'pairing.token'));
|
|
1271
|
+
const wire = createSignedNativeWire({
|
|
1272
|
+
nativeRequest: { type: 'input_status', context, acknowledge },
|
|
1273
|
+
requestId,
|
|
1274
|
+
token,
|
|
1275
|
+
nonce: randomUUID(),
|
|
1276
|
+
timestampMs: Date.now(),
|
|
1277
|
+
});
|
|
1278
|
+
return this.sendWire(wire, signal);
|
|
1279
|
+
};
|
|
1280
|
+
const response = await send(false).catch(async (error) => {
|
|
1281
|
+
if (signal?.aborted || !isConnectionFailure(error))
|
|
1282
|
+
throw error;
|
|
1283
|
+
return send(true);
|
|
1284
|
+
});
|
|
1285
|
+
if ('Err' in response.response) {
|
|
1286
|
+
throw new ComputerUseClientError(mapErrorCode(response.response.Err.code), response.response.Err.message);
|
|
1287
|
+
}
|
|
1288
|
+
const value = response.response.Ok;
|
|
1289
|
+
const envelope = isRecord(value) && isRecord(value.InputStatus)
|
|
1290
|
+
? value.InputStatus
|
|
1291
|
+
: value;
|
|
1292
|
+
if (!isRecord(envelope)) {
|
|
1293
|
+
throw new ComputerUseClientError('protocol_mismatch', 'Helper 未返回有效的输入接管状态');
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
context: {
|
|
1297
|
+
ownerId: context.ownerId,
|
|
1298
|
+
conversationKey: context.conversationKey,
|
|
1299
|
+
runId: context.runId,
|
|
1300
|
+
},
|
|
1301
|
+
detected: envelope.detected === true,
|
|
1302
|
+
...(stringAt(envelope.inputType)
|
|
1303
|
+
? { inputType: stringAt(envelope.inputType) }
|
|
1304
|
+
: {}),
|
|
1305
|
+
...(numberAt(envelope.detectedAtMs) > 0
|
|
1306
|
+
? { detectedAtMs: numberAt(envelope.detectedAtMs) }
|
|
1307
|
+
: {}),
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
endpoint() {
|
|
1311
|
+
if (process.platform === 'win32') {
|
|
1312
|
+
return `\\\\.\\pipe\\kakadeai-cu-helperd-${process.env.USERNAME ?? 'user'}`;
|
|
1313
|
+
}
|
|
1314
|
+
return join(this.stateDir, 'cu-helperd.sock');
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* 主动调用 permissions 接口:
|
|
1318
|
+
* 1. 触发 cu-helperd 内部的 macOS 系统权限弹窗(首次 not determined 时)
|
|
1319
|
+
* 2. 获取当前权限状态
|
|
1320
|
+
* 3. 如果权限缺失,自动打开系统设置页面并打印醒目引导
|
|
1321
|
+
*
|
|
1322
|
+
* 设计参考:UI-TARS ensurePermissions() + openSystemPreferences()
|
|
1323
|
+
*/
|
|
1324
|
+
async probePermissions() {
|
|
1325
|
+
try {
|
|
1326
|
+
// 等待一小会确保 daemon 完成初始化并写入 pairing.token
|
|
1327
|
+
await delay(300);
|
|
1328
|
+
// ⚠️ **必须按 Buffer 读,不能加 'utf8'。** 配对令牌是 32 字节随机数,不是文本:
|
|
1329
|
+
// 按 utf8 解码会把非法字节替换成 U+FFFD,算出来的 HMAC 与 `exchange()`(一直是
|
|
1330
|
+
// 按 Buffer 读的)完全不同。后果不是「偶尔失败」,是这个自检**从来没有成功过一次** ——
|
|
1331
|
+
// 它每次都收到 authentication_failed,而下面的 parsePermissionResponse 把错误信封
|
|
1332
|
+
// 当成「字段缺失」,于是无条件判定两个权限都没授权,打印一整屏权限引导。
|
|
1333
|
+
// 用户因此被反复引去检查根本没问题的系统设置,而真正的故障在别处。
|
|
1334
|
+
const token = await readFile(join(this.stateDir, 'pairing.token'));
|
|
1335
|
+
const nonce = randomUUID();
|
|
1336
|
+
const timestampMs = Date.now();
|
|
1337
|
+
const nativeRequest = { type: 'permissions' };
|
|
1338
|
+
const payloadHash = createHash('sha256')
|
|
1339
|
+
.update(JSON.stringify(nativeRequest))
|
|
1340
|
+
.digest('hex');
|
|
1341
|
+
const requestId = randomUUID();
|
|
1342
|
+
const signing = `${COMPUTER_USE_PROTOCOL_VERSION}\n${requestId}\n${timestampMs}\n${nonce}\n${payloadHash}`;
|
|
1343
|
+
const wire = {
|
|
1344
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1345
|
+
requestId,
|
|
1346
|
+
auth: {
|
|
1347
|
+
nonce,
|
|
1348
|
+
timestampMs,
|
|
1349
|
+
macHex: createHmac('sha256', token).update(signing).digest('hex'),
|
|
1350
|
+
},
|
|
1351
|
+
request: nativeRequest,
|
|
1352
|
+
};
|
|
1353
|
+
// 等待权限响应
|
|
1354
|
+
const resp = await this.sendWire(wire);
|
|
1355
|
+
// 检查权限状态,缺失则自动打开系统设置并显示引导
|
|
1356
|
+
ensureMacPermissions(resp);
|
|
1357
|
+
}
|
|
1358
|
+
catch {
|
|
1359
|
+
// 权限探测失败不影响主流程(可能是daemon还没完全就绪),静默忽略
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* 确保守护进程在跑。
|
|
1364
|
+
*
|
|
1365
|
+
* `force` 用于**陈旧 socket 恢复**:unix domain socket 是文件,守护进程被
|
|
1366
|
+
* kill 或崩溃时不会被清理,于是 `existsSync` 恒为 true、永远不重启,而
|
|
1367
|
+
* 连接必然失败 —— 每一次后续请求都失败,直到有人手工删掉那个文件。
|
|
1368
|
+
* 一个需要人工介入才能恢复的失败模式不能算产品级。
|
|
1369
|
+
*/
|
|
1370
|
+
async ensureDaemon(signal, force = false) {
|
|
1371
|
+
if (force) {
|
|
1372
|
+
this.startPromise = null;
|
|
1373
|
+
// 先移除陈旧 socket,否则守护进程 bind 时会撞上它
|
|
1374
|
+
if (process.platform !== 'win32') {
|
|
1375
|
+
try {
|
|
1376
|
+
unlinkSync(this.endpoint());
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
// 已经不在了,或者没权限 —— 两种情况下继续尝试启动都是对的
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
else if (existsSync(this.endpoint())) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (!this.startPromise) {
|
|
1387
|
+
this.startPromise = new Promise((resolveStart, reject) => {
|
|
1388
|
+
const child = spawn(this.helperPath, [], {
|
|
1389
|
+
// 不要设置 detached: true —— detached 会调用 setsid() 创建新 session,
|
|
1390
|
+
// 导致 macOS TCC 无法通过进程继承链找到 responsible application(.app),
|
|
1391
|
+
// 权限弹窗就只能显示 "cu-helperd" 而不是 "KakadeAI"。
|
|
1392
|
+
// 保持子进程在父进程的 session 内,TCC 能正确关联到 KakadeAI.app。
|
|
1393
|
+
stdio: 'ignore',
|
|
1394
|
+
windowsHide: true,
|
|
1395
|
+
env: { ...process.env, KAKADEAI_CU_HOME: this.stateDir },
|
|
1396
|
+
});
|
|
1397
|
+
this.daemonProcess = child;
|
|
1398
|
+
child.once('error', reject);
|
|
1399
|
+
child.once('exit', () => {
|
|
1400
|
+
// 守护进程退出时清理引用,下次请求会自动重启
|
|
1401
|
+
this.daemonProcess = null;
|
|
1402
|
+
this.startPromise = null;
|
|
1403
|
+
});
|
|
1404
|
+
// unref() 让子进程不阻止父进程事件循环退出,但不改变进程组归属。
|
|
1405
|
+
// 父进程退出时子进程会收到 SIGHUP 自然消亡,避免孤儿进程。
|
|
1406
|
+
child.unref();
|
|
1407
|
+
const startedAt = Date.now();
|
|
1408
|
+
const poll = () => {
|
|
1409
|
+
if (signal?.aborted)
|
|
1410
|
+
return reject(new DOMException('aborted', 'AbortError'));
|
|
1411
|
+
if (existsSync(this.endpoint()) || process.platform === 'win32') {
|
|
1412
|
+
return resolveStart();
|
|
1413
|
+
}
|
|
1414
|
+
if (Date.now() - startedAt > 5_000)
|
|
1415
|
+
return reject(new Error('cu-helperd 启动超时'));
|
|
1416
|
+
setTimeout(poll, 50);
|
|
1417
|
+
};
|
|
1418
|
+
poll();
|
|
1419
|
+
}).finally(() => {
|
|
1420
|
+
this.startPromise = null;
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
await this.startPromise;
|
|
1424
|
+
// Daemon 启动成功后,主动检查 macOS 权限并引导用户授权。
|
|
1425
|
+
// 设计参考 UI-TARS ensurePermissions():
|
|
1426
|
+
// 1. 触发 cu-helperd 内部的系统原生弹窗(首次 not determined 时)
|
|
1427
|
+
// 2. 获取当前权限状态
|
|
1428
|
+
// 3. 权限缺失时自动打开系统设置页面 + 打印醒目引导
|
|
1429
|
+
if (!this.permissionPrompted && process.platform === 'darwin') {
|
|
1430
|
+
this.permissionPrompted = true;
|
|
1431
|
+
// 后台异步执行,不阻塞主流程
|
|
1432
|
+
this.probePermissions().catch(() => {
|
|
1433
|
+
// 权限检查失败不影响主流程
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
sendWire(wire, signal) {
|
|
1438
|
+
return new Promise((resolveResponse, reject) => {
|
|
1439
|
+
const socket = createConnection(this.endpoint());
|
|
1440
|
+
let buffer = '';
|
|
1441
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
1442
|
+
const abort = () => {
|
|
1443
|
+
socket.destroy();
|
|
1444
|
+
cleanup();
|
|
1445
|
+
reject(new DOMException('aborted', 'AbortError'));
|
|
1446
|
+
};
|
|
1447
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1448
|
+
socket.setEncoding('utf8');
|
|
1449
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(wire)}\n`));
|
|
1450
|
+
socket.on('data', (chunk) => {
|
|
1451
|
+
buffer += chunk;
|
|
1452
|
+
const newline = buffer.indexOf('\n');
|
|
1453
|
+
if (newline < 0)
|
|
1454
|
+
return;
|
|
1455
|
+
cleanup();
|
|
1456
|
+
socket.end();
|
|
1457
|
+
try {
|
|
1458
|
+
resolveResponse(JSON.parse(buffer.slice(0, newline)));
|
|
1459
|
+
}
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
reject(error);
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
socket.once('error', (error) => {
|
|
1465
|
+
cleanup();
|
|
1466
|
+
reject(error);
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
export class ComputerUseService {
|
|
1472
|
+
transport;
|
|
1473
|
+
observations = new Map();
|
|
1474
|
+
/**
|
|
1475
|
+
* 动作发生时那次观测。动作会让 `observations` 里的条目作废(坐标不能再用),
|
|
1476
|
+
* 但动作**之后**的像素兜底判定需要知道当时的 AX 可用性与前台应用。
|
|
1477
|
+
* 分开存放是为了让「不能用于坐标」这件事在类型/命名上就说清楚。
|
|
1478
|
+
*/
|
|
1479
|
+
actedObservations = new Map();
|
|
1480
|
+
/** 上一次观测派生的焦点快照。跨动作存活,用于跨进程打断检测。 */
|
|
1481
|
+
focusSnapshots = new Map();
|
|
1482
|
+
/** 本运行内已经注入过完整说明书摘要的应用。 */
|
|
1483
|
+
injectedManuals = new Map();
|
|
1484
|
+
/**
|
|
1485
|
+
* 上一个动作对「焦点应该落到哪」的预期。**一次性**:下一次观测取用后即清除。
|
|
1486
|
+
*
|
|
1487
|
+
* 不清除的话,一次 `activate_app` 会永久豁免这个运行里后续所有的前台抢占
|
|
1488
|
+
* 检测 —— 那就把 C2 这条信号变成了摆设。
|
|
1489
|
+
*/
|
|
1490
|
+
pendingExpectations = new Map();
|
|
1491
|
+
/**
|
|
1492
|
+
* 最近一次观测返回的AX元素短id→真实id映射。
|
|
1493
|
+
* 用于支持LLM用@1/@2这种简短引用点击元素。
|
|
1494
|
+
*/
|
|
1495
|
+
axIdMaps = new Map();
|
|
1496
|
+
/**
|
|
1497
|
+
* 最近一次AX吸附结果,用于executeVerifiedAction返回给上层反馈。
|
|
1498
|
+
*/
|
|
1499
|
+
lastSnapResults = new Map();
|
|
1500
|
+
/**
|
|
1501
|
+
* 每个 run 最近一次**指针动作**落在哪(0–1000 相对坐标)。
|
|
1502
|
+
*
|
|
1503
|
+
* `type` / `key` 自身不带坐标,但它们的效果几乎总是出现在上一次点击的位置 ——
|
|
1504
|
+
* 「点输入框 → 打字」。AX 给不出焦点控件时(自绘应用的常态),这就是唯一能把
|
|
1505
|
+
* 判定收敛到局部的信息,没有它就只能退到全屏,而全屏在聊天软件上等于没有判定。
|
|
1506
|
+
*/
|
|
1507
|
+
lastPointerPoints = new Map();
|
|
1508
|
+
/**
|
|
1509
|
+
* 连续失败熔断状态,**按运行 + 动作指纹**隔离。
|
|
1510
|
+
*
|
|
1511
|
+
* 曾经它住在全机唯一的 `telemetry.jsonl` 里,有三个问题,每一个都足够致命:
|
|
1512
|
+
* ① 没有隔离 —— 会话 A 连续失败会把会话 B 的写操作一起熔断
|
|
1513
|
+
* ② 读改写整文件 + 时间戳临时文件名 —— 并发调用会互相覆盖、丢记录
|
|
1514
|
+
* ③ 计数不分动作 —— 一次成功的 observe 就清零,于是
|
|
1515
|
+
* 「失败 → observe → 失败 → observe …」这个最常见的打转模式
|
|
1516
|
+
* 可以无限绕过熔断
|
|
1517
|
+
*
|
|
1518
|
+
* 现在状态在内存里、按 `contextKey + 指纹` 分桶:同一个失败动作反复重试才
|
|
1519
|
+
* 累加,换目标重置,**该指纹的一次成功**才清零。磁盘遥测退回纯观测职责。
|
|
1520
|
+
*/
|
|
1521
|
+
failureStreaks = new Map();
|
|
1522
|
+
constructor(options = {}) {
|
|
1523
|
+
// 平台门放在这里,而不是各宿主各判一次。
|
|
1524
|
+
//
|
|
1525
|
+
// Windows 只有协议桩、没有 UI Automation 驱动,而 `ensureDaemon` 的
|
|
1526
|
+
// win32 分支还不等命名管道出现就直接 resolve —— 走进去必然连接失败,
|
|
1527
|
+
// 报出来的是含糊的 `unavailable`。宁可在入口就明说「这个平台还不支持」。
|
|
1528
|
+
// Unit tests use injected transports for behavior and an explicit helper
|
|
1529
|
+
// path for the one native contract test. Auto-discovering the user's real
|
|
1530
|
+
// daemon would turn unrelated tests into permission/UI integration tests
|
|
1531
|
+
// and write into the user's machine state.
|
|
1532
|
+
const nativeAutoDiscoveryEnabled = process.env.NODE_ENV !== 'test' || options.helperPath !== undefined;
|
|
1533
|
+
const supported = options.transport !== undefined
|
|
1534
|
+
|| (process.platform === 'darwin' && nativeAutoDiscoveryEnabled);
|
|
1535
|
+
// Transport 只有一条:Rust 的 cu-helperd(`NativeDaemonTransport`),CLI 与 Desktop 共用。
|
|
1536
|
+
//
|
|
1537
|
+
// 这里曾经并存第二条 `NutJsNodeTransport`(CLI 默认走它,在主进程内用
|
|
1538
|
+
// `@computer-use/nut-js` 直接操作键鼠)。它被整条移除,理由按影响从大到小:
|
|
1539
|
+
// ① 它带进 3 个原生 `.node` 二进制,`bun --compile` 在**打包期**就要解析,
|
|
1540
|
+
// 于是绿色版(装 U 盘卖的免安装单文件夹)在 win-x64 直接编译失败。
|
|
1541
|
+
// 平台判断和 try/catch 都救不了——那是运行时的事,打包器不看。
|
|
1542
|
+
// ② 同一能力两套实现。已核实 Rust 侧 `Action`(13 变体) + `Request`(10 变体)
|
|
1543
|
+
// 覆盖了 nut-js 那条通路的全部动作,并且**独有** ExecuteVerifiedAction /
|
|
1544
|
+
// InputStatus / probe_change —— 移除不丢能力。
|
|
1545
|
+
// ③ 它顺带架空了两道校验:截图完整性校验的 JPEG 分支拿声明值和自己比(见
|
|
1546
|
+
// `verifyScreenshot`),动作验证的 nut-js 分支无条件合成 `settled: true`。
|
|
1547
|
+
// 代价是真实的、已单独补偿:nut-js 的唯一优势是「权限授给终端、免给 daemon 单独授权」,
|
|
1548
|
+
// 改走 daemon 后由 `accessibilityPermission.ts` 的授权引导承接。
|
|
1549
|
+
let transport = options.transport ?? null;
|
|
1550
|
+
if (!transport && supported) {
|
|
1551
|
+
const helperPath = options.helperPath ?? findComputerUseHelper();
|
|
1552
|
+
if (helperPath) {
|
|
1553
|
+
transport = new NativeDaemonTransport(helperPath,
|
|
1554
|
+
// 守护进程的运行时状态按**宿主身份**分桶,不再全机共用一个 socket。
|
|
1555
|
+
// 理由见 computerUseHostStateDir 的注释(跨宿主共享曾导致 TCC 身份污染)。
|
|
1556
|
+
options.stateDir ?? computerUseHostStateDir());
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
this.transport = transport;
|
|
1560
|
+
}
|
|
1561
|
+
available() {
|
|
1562
|
+
return this.transport !== null;
|
|
1563
|
+
}
|
|
1564
|
+
/** 当前平台是否有可用的原生驱动(而不是「只有协议定义」)。 */
|
|
1565
|
+
static platformSupported() {
|
|
1566
|
+
return process.platform === 'darwin';
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* 最近一次成功观测。
|
|
1570
|
+
*
|
|
1571
|
+
* 给「这个动作在当前应用里是不是不可逆」这类查询用 —— 判定需要知道前台是
|
|
1572
|
+
* 哪个应用,而那是观测的产物。放在这里而不是塞进 `ToolCallContext`:服务
|
|
1573
|
+
* 本来就是「我们上次看到了什么」的所有者,不必让内核也记一份。
|
|
1574
|
+
*/
|
|
1575
|
+
lastObservation(context) {
|
|
1576
|
+
return this.observations.get(contextKey(context));
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* 动作发生时那次观测(动作已让它对坐标无效,但对「当时是什么状况」仍有效)。
|
|
1580
|
+
*
|
|
1581
|
+
* 仅供动作后的安全判定使用 —— 像素人工接管兜底要知道当时 AX 树可不可用、
|
|
1582
|
+
* 前台是哪个应用。**不要**拿它做坐标换算:那是它被作废的原因。
|
|
1583
|
+
*/
|
|
1584
|
+
observationAtAction(context) {
|
|
1585
|
+
return this.actedObservations.get(contextKey(context));
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* 本运行内是否**首次**要注入某份说明书的完整摘要;调用即登记。
|
|
1589
|
+
*
|
|
1590
|
+
* 完整摘要约 1–2k token,每次观测都发一遍是纯浪费;但只发一次又不够 ——
|
|
1591
|
+
* 所以策略是「首次全量、之后一行提醒 + 路径」。状态住在服务里而不是工具里,
|
|
1592
|
+
* 因为服务本来就是「这个运行看到过什么」的所有者(观测、焦点快照都在这)。
|
|
1593
|
+
*/
|
|
1594
|
+
shouldInjectFullManual(context, manualName) {
|
|
1595
|
+
const key = contextKey(context);
|
|
1596
|
+
let injected = this.injectedManuals.get(key);
|
|
1597
|
+
if (!injected) {
|
|
1598
|
+
injected = new Set();
|
|
1599
|
+
this.injectedManuals.set(key, injected);
|
|
1600
|
+
}
|
|
1601
|
+
if (injected.has(manualName))
|
|
1602
|
+
return false;
|
|
1603
|
+
injected.add(manualName);
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* 这次动作**这个具体目标**已经连续失败几次了。
|
|
1608
|
+
*
|
|
1609
|
+
* 同步、无 IO:熔断判定在每个写类动作前都要跑一次,不能是一次文件读。
|
|
1610
|
+
*/
|
|
1611
|
+
consecutiveFailuresFor(context, input) {
|
|
1612
|
+
const streak = this.failureStreaks.get(contextKey(context));
|
|
1613
|
+
if (!streak)
|
|
1614
|
+
return 0;
|
|
1615
|
+
return streak.fingerprint === actionFingerprint(input) ? streak.count : 0;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* 登记一次动作结局。
|
|
1619
|
+
*
|
|
1620
|
+
* 只认写类动作:观测失败不该熔断写操作(模型正需要用观测看清自己卡在哪),
|
|
1621
|
+
* 观测成功也不该清零写操作的计数(那正是绕过熔断的老路子)。
|
|
1622
|
+
*/
|
|
1623
|
+
recordActionOutcome(context, input, ok) {
|
|
1624
|
+
if (!isMutatingComputerUseAction(input.action))
|
|
1625
|
+
return;
|
|
1626
|
+
const key = contextKey(context);
|
|
1627
|
+
const fingerprint = actionFingerprint(input);
|
|
1628
|
+
if (ok) {
|
|
1629
|
+
// 只清掉这个指纹的计数。别的目标还卡着就该继续卡着。
|
|
1630
|
+
const streak = this.failureStreaks.get(key);
|
|
1631
|
+
if (streak?.fingerprint === fingerprint)
|
|
1632
|
+
this.failureStreaks.delete(key);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const streak = this.failureStreaks.get(key);
|
|
1636
|
+
this.failureStreaks.set(key, streak?.fingerprint === fingerprint
|
|
1637
|
+
? { fingerprint, count: streak.count + 1 }
|
|
1638
|
+
: { fingerprint, count: 1 });
|
|
1639
|
+
}
|
|
1640
|
+
async emergencyStopAll(signal) {
|
|
1641
|
+
const requestId = randomUUID();
|
|
1642
|
+
if (!this.transport?.emergencyStopAll) {
|
|
1643
|
+
return {
|
|
1644
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1645
|
+
requestId,
|
|
1646
|
+
ok: false,
|
|
1647
|
+
error: {
|
|
1648
|
+
code: 'unavailable',
|
|
1649
|
+
message: '当前 Computer Use 服务不支持全局急停',
|
|
1650
|
+
},
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
const response = await this.transport.emergencyStopAll(signal);
|
|
1654
|
+
this.observations.clear();
|
|
1655
|
+
return response;
|
|
1656
|
+
}
|
|
1657
|
+
async inputStatus(context, acknowledge = false, signal) {
|
|
1658
|
+
if (!this.transport?.inputStatus) {
|
|
1659
|
+
return { context, detected: false };
|
|
1660
|
+
}
|
|
1661
|
+
return this.transport.inputStatus(context, acknowledge, signal);
|
|
1662
|
+
}
|
|
1663
|
+
async request(context, input, signal, options) {
|
|
1664
|
+
const requestId = randomUUID();
|
|
1665
|
+
if (!this.transport) {
|
|
1666
|
+
return {
|
|
1667
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1668
|
+
requestId,
|
|
1669
|
+
ok: false,
|
|
1670
|
+
error: ComputerUseService.platformSupported()
|
|
1671
|
+
? {
|
|
1672
|
+
code: 'unavailable',
|
|
1673
|
+
message: 'Computer Use 原生服务未安装或不可用',
|
|
1674
|
+
hint: '请安装当前平台的 cu-helperd,或在 Desktop 设置中检查 Computer Use 诊断。',
|
|
1675
|
+
}
|
|
1676
|
+
: {
|
|
1677
|
+
code: 'unsupported',
|
|
1678
|
+
message: `Computer Use 目前只支持 macOS,当前平台是 ${process.platform}`,
|
|
1679
|
+
hint: 'Windows 的 UI Automation 驱动尚未实现;请改用其它方式完成这一步,不要重试。',
|
|
1680
|
+
},
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
// Internal observations (UI refresh, intervention inspect, diagnostics) go straight
|
|
1684
|
+
// through without coordinate mapping (no observation to map against) and without
|
|
1685
|
+
// updating the model-visible observation cache or focus state. They do not advance
|
|
1686
|
+
// the daemon-side epoch either (the native `internal: true` flag handles that).
|
|
1687
|
+
if (options?.internalObserve && input.action === 'observe') {
|
|
1688
|
+
const response = await this.transport.request({
|
|
1689
|
+
...input,
|
|
1690
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1691
|
+
requestId,
|
|
1692
|
+
context,
|
|
1693
|
+
_internalObserve: true,
|
|
1694
|
+
}, signal);
|
|
1695
|
+
if (response.protocolVersion !== COMPUTER_USE_PROTOCOL_VERSION) {
|
|
1696
|
+
return {
|
|
1697
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1698
|
+
requestId,
|
|
1699
|
+
ok: false,
|
|
1700
|
+
error: {
|
|
1701
|
+
code: 'protocol_mismatch',
|
|
1702
|
+
message: `Computer Use 协议不兼容:客户端 ${COMPUTER_USE_PROTOCOL_VERSION},Helper ${response.protocolVersion}`,
|
|
1703
|
+
},
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
const state = response.ok
|
|
1707
|
+
? normalizeNativeComputerState(response.data)
|
|
1708
|
+
: undefined;
|
|
1709
|
+
if (state) {
|
|
1710
|
+
const imageError = await validateStateImage(state);
|
|
1711
|
+
if (imageError)
|
|
1712
|
+
return { ...response, ok: false, error: imageError };
|
|
1713
|
+
return {
|
|
1714
|
+
...response,
|
|
1715
|
+
state,
|
|
1716
|
+
...(state.screenshot ? { images: [state.screenshot] } : {}),
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
return response;
|
|
1720
|
+
}
|
|
1721
|
+
const observation = this.observations.get(contextKey(context));
|
|
1722
|
+
const ctxKey = contextKey(context);
|
|
1723
|
+
const mappedInput = this.mapRelativeCoordinates(input, observation, ctxKey);
|
|
1724
|
+
if ('error' in mappedInput) {
|
|
1725
|
+
return {
|
|
1726
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1727
|
+
requestId,
|
|
1728
|
+
ok: false,
|
|
1729
|
+
error: mappedInput.error,
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
// 存储吸附结果供executeVerifiedAction取用
|
|
1733
|
+
if (mappedInput.snapInfo) {
|
|
1734
|
+
this.lastSnapResults.set(ctxKey, mappedInput.snapInfo);
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
this.lastSnapResults.delete(ctxKey);
|
|
1738
|
+
}
|
|
1739
|
+
// 记住指针落点,供后续 type/key 的 ROI 回落使用(见 probeRegionFor)。
|
|
1740
|
+
// 用**吸附后**的位置:那才是真正被点到的地方。
|
|
1741
|
+
const pointerPoint = mappedInput.snapInfo?.snapped
|
|
1742
|
+
? { x: mappedInput.snapInfo.x, y: mappedInput.snapInfo.y }
|
|
1743
|
+
: input.x !== undefined && input.y !== undefined
|
|
1744
|
+
? {
|
|
1745
|
+
x: input.x2 !== undefined ? (input.x + input.x2) / 2 : input.x,
|
|
1746
|
+
y: input.y2 !== undefined ? (input.y + input.y2) / 2 : input.y,
|
|
1747
|
+
}
|
|
1748
|
+
: undefined;
|
|
1749
|
+
if (pointerPoint)
|
|
1750
|
+
this.lastPointerPoints.set(ctxKey, pointerPoint);
|
|
1751
|
+
// Only a request that will actually reach the helper may establish a
|
|
1752
|
+
// focus expectation. Invalid coordinates must not leave a stale
|
|
1753
|
+
// expectation that suppresses the next real focus-change signal.
|
|
1754
|
+
// ★ 对齐 UI-TARS:永远全屏截图,不裁剪。observe 带 rect 参数时,
|
|
1755
|
+
// rect 仅用于 grab_region_to_clipboard/window 操作,不用于裁剪返回给模型的图片。
|
|
1756
|
+
// 裁剪图会造成"坐标错觉",模型在裁剪图内看到目标却需要输出整屏坐标,容易算错。
|
|
1757
|
+
const helperInput = mappedInput.input.action === 'observe' && mappedInput.input.rect
|
|
1758
|
+
? { ...mappedInput.input, rect: undefined }
|
|
1759
|
+
: mappedInput.input;
|
|
1760
|
+
this.rememberExpectation(context, helperInput, observation);
|
|
1761
|
+
const probeRegion = probeRegionFor(observation, this.lastPointerPoints.get(ctxKey), helperInput.action);
|
|
1762
|
+
// ⚠️ `activate_app` 的 epoch 守卫**必须用当前 epoch**,不能用 0。
|
|
1763
|
+
//
|
|
1764
|
+
// 这里的注释(见 toNativeComputerUseRequest 里 activate_app 那支)一直写着
|
|
1765
|
+
// 「不需要 expectedEpoch,默认传 0 即可」—— 但守护进程做的是**严格相等比较**
|
|
1766
|
+
// (service.rs::`actual != expected`),0 不是通配符。于是那句话只在
|
|
1767
|
+
// 「本 run 还没 observe 过」时碰巧成立。
|
|
1768
|
+
//
|
|
1769
|
+
// 实测代价:模型走「observe → activate_app」这个最自然的流程时,
|
|
1770
|
+
// observe 把 epoch 推到 1,activate_app 带着 0 过去 → `stale_state`。
|
|
1771
|
+
// 而 activate_app 靠 bundleId 工作、根本不依赖屏幕状态,这道守卫对它没有意义 ——
|
|
1772
|
+
// 所以取当前 epoch,语义上等于「不做新鲜度检查」。
|
|
1773
|
+
const currentEpoch = this.lastObservation(context)?.epoch;
|
|
1774
|
+
const epochFixed = helperInput.action === 'activate_app' && currentEpoch !== undefined
|
|
1775
|
+
? { ...helperInput, expectedEpoch: currentEpoch }
|
|
1776
|
+
: helperInput;
|
|
1777
|
+
const requestPayload = {
|
|
1778
|
+
...epochFixed,
|
|
1779
|
+
// 探测参数在坐标换算**之后**注入:ROI 来自 AX,本来就是逻辑点,
|
|
1780
|
+
// 混进 mapRelativeCoordinates 会被当成 0–1000 相对值再换算一次。
|
|
1781
|
+
...(shouldProbeChange(helperInput.action, observation)
|
|
1782
|
+
? { probeChange: true, ...(probeRegion ? { probeRegion } : {}) }
|
|
1783
|
+
: {}),
|
|
1784
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1785
|
+
requestId,
|
|
1786
|
+
context,
|
|
1787
|
+
};
|
|
1788
|
+
// 截图请求(observe)失败时自动重试最多2次
|
|
1789
|
+
const maxScreenshotRetries = helperInput.action === 'observe' ? 2 : 0;
|
|
1790
|
+
let response;
|
|
1791
|
+
let retries = 0;
|
|
1792
|
+
while (true) {
|
|
1793
|
+
response = await this.transport.request(requestPayload, signal);
|
|
1794
|
+
if (response.ok || retries >= maxScreenshotRetries || !isScreenshotRetryableError(response.error)) {
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1797
|
+
retries++;
|
|
1798
|
+
await delay(100);
|
|
1799
|
+
}
|
|
1800
|
+
if (response.protocolVersion !== COMPUTER_USE_PROTOCOL_VERSION) {
|
|
1801
|
+
return {
|
|
1802
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1803
|
+
requestId,
|
|
1804
|
+
ok: false,
|
|
1805
|
+
error: {
|
|
1806
|
+
code: 'protocol_mismatch',
|
|
1807
|
+
message: `Computer Use 协议不兼容:客户端 ${COMPUTER_USE_PROTOCOL_VERSION},Helper ${response.protocolVersion}`,
|
|
1808
|
+
},
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
const state = response.ok
|
|
1812
|
+
? normalizeNativeComputerState(response.data)
|
|
1813
|
+
: undefined;
|
|
1814
|
+
if (state) {
|
|
1815
|
+
const imageError = await validateStateImage(state);
|
|
1816
|
+
if (imageError)
|
|
1817
|
+
return { ...response, ok: false, error: imageError };
|
|
1818
|
+
const key = contextKey(context);
|
|
1819
|
+
this.observations.set(key, state);
|
|
1820
|
+
// 焦点变化要跨「观测被作废」而存活,所以单独存一份。
|
|
1821
|
+
const focus = deriveFocusSnapshot(state);
|
|
1822
|
+
const previous = this.focusSnapshots.get(key);
|
|
1823
|
+
if (focus)
|
|
1824
|
+
this.focusSnapshots.set(key, focus);
|
|
1825
|
+
// 取用并清除上一个动作留下的预期(一次性,见 pendingExpectations)。
|
|
1826
|
+
const expectation = this.pendingExpectations.get(key);
|
|
1827
|
+
this.pendingExpectations.delete(key);
|
|
1828
|
+
const focusChange = classifyFocusChange(previous, focus, expectation);
|
|
1829
|
+
return {
|
|
1830
|
+
...response,
|
|
1831
|
+
state,
|
|
1832
|
+
...(focus ? { focus } : {}),
|
|
1833
|
+
...(focusChange !== FOCUS_CHANGE.NONE ? { focusChange } : {}),
|
|
1834
|
+
...(state.screenshot ? { images: [state.screenshot] } : {}),
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
if (response.ok &&
|
|
1838
|
+
input.action !== 'observe' &&
|
|
1839
|
+
input.action !== 'replay') {
|
|
1840
|
+
// Every successful input invalidates the observation on the daemon. Do
|
|
1841
|
+
// the same locally so a stale normalized display cannot be reused.
|
|
1842
|
+
//
|
|
1843
|
+
// 但**移到 lastActedObservation**而不是直接丢掉:动作之后的像素兜底判定
|
|
1844
|
+
// (detectPixelHumanBoundary)需要「动作发生时那次观测」的 AX 可用性和
|
|
1845
|
+
// 前台应用。丢掉它,那条判定就永远拿不到 state 而静默失效 —— 这正是本轮
|
|
1846
|
+
// 差点复刻的老毛病:接线看着通了,实际那一端是空的。
|
|
1847
|
+
// 语义上它**不能**再被当成有效观测用于坐标换算,所以换了个名字存放。
|
|
1848
|
+
const key = contextKey(context);
|
|
1849
|
+
const invalidated = this.observations.get(key);
|
|
1850
|
+
if (invalidated)
|
|
1851
|
+
this.actedObservations.set(key, invalidated);
|
|
1852
|
+
this.observations.delete(key);
|
|
1853
|
+
}
|
|
1854
|
+
return response;
|
|
1855
|
+
}
|
|
1856
|
+
async executeVerifiedAction(context, input, signal) {
|
|
1857
|
+
let before = this.observations.get(contextKey(context));
|
|
1858
|
+
// Pointer down/move/up and press are intermediate gesture steps.
|
|
1859
|
+
// Holding a lease through an intermediate post-observe would break capture,
|
|
1860
|
+
// so only the final up/release is eligible for the verified transaction.
|
|
1861
|
+
if (['pointer_down', 'pointer_move', 'press'].includes(input.action)) {
|
|
1862
|
+
return this.request(context, input, signal);
|
|
1863
|
+
}
|
|
1864
|
+
// An explicit epoch is a reference to a particular observation. If that
|
|
1865
|
+
// observation is no longer in this process, silently taking a new one does
|
|
1866
|
+
// not make the old coordinates valid again. Reject before touching the OS;
|
|
1867
|
+
// the caller must observe and re-locate. `activate_app` is the exception:
|
|
1868
|
+
// it identifies its target by app id and is specifically allowed to
|
|
1869
|
+
// bootstrap a fresh run.
|
|
1870
|
+
if (!before && input.expectedEpoch !== undefined && input.action !== 'activate_app') {
|
|
1871
|
+
return {
|
|
1872
|
+
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
1873
|
+
requestId: randomUUID(),
|
|
1874
|
+
ok: false,
|
|
1875
|
+
error: {
|
|
1876
|
+
code: 'stale_state',
|
|
1877
|
+
message: 'expectedEpoch 对应的本地观测已失效;请重新 observe 并定位后再操作。',
|
|
1878
|
+
retryable: false,
|
|
1879
|
+
},
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
// The verified transaction always starts from a canonical observation.
|
|
1883
|
+
// This also makes activate_app usable from a fresh run without inventing
|
|
1884
|
+
// epoch 0 as if it represented a real screen state.
|
|
1885
|
+
if (!before) {
|
|
1886
|
+
const observed = await this.request(context, { action: 'observe' }, signal);
|
|
1887
|
+
if (!observed.ok || !observed.state)
|
|
1888
|
+
return observed;
|
|
1889
|
+
before = observed.state;
|
|
1890
|
+
}
|
|
1891
|
+
const policy = settlePolicy(input.action);
|
|
1892
|
+
const actionResponse = await this.request(context, {
|
|
1893
|
+
...input,
|
|
1894
|
+
expectedEpoch: input.expectedEpoch ?? before.epoch,
|
|
1895
|
+
_verifiedAction: {
|
|
1896
|
+
quietMs: policy.quietMs,
|
|
1897
|
+
timeoutMs: policy.timeoutMs,
|
|
1898
|
+
...(input.rect ? { region: input.rect } : {}),
|
|
1899
|
+
},
|
|
1900
|
+
}, signal);
|
|
1901
|
+
if (!actionResponse.ok || !isMutatingComputerUseAction(input.action)) {
|
|
1902
|
+
return actionResponse;
|
|
1903
|
+
}
|
|
1904
|
+
const verifiedData = isRecord(actionResponse.data)
|
|
1905
|
+
&& actionResponse.data.type === 'verified_action'
|
|
1906
|
+
? actionResponse.data
|
|
1907
|
+
: undefined;
|
|
1908
|
+
const actionData = verifiedData && isRecord(verifiedData.action)
|
|
1909
|
+
? verifiedData.action
|
|
1910
|
+
: undefined;
|
|
1911
|
+
const settleData = verifiedData && isRecord(verifiedData.settle)
|
|
1912
|
+
? verifiedData.settle
|
|
1913
|
+
: undefined;
|
|
1914
|
+
// 动作后观察必须完整,否则不认「操作成功」。
|
|
1915
|
+
//
|
|
1916
|
+
// 这里曾有一条 nut-js 兼容旁路:`isNutJsTransport = !verifiedData && state`,
|
|
1917
|
+
// 它既绕过下面这道守卫,又**无条件合成 `settled: true`** —— 稳态检查在那条
|
|
1918
|
+
// 通路上是假的。nut-js 移除后旁路一并删掉,契约恢复为「没有完整的
|
|
1919
|
+
// verified_action 就是验证失败」。
|
|
1920
|
+
if (!verifiedData || !actionResponse.state || !actionData || !settleData) {
|
|
1921
|
+
return {
|
|
1922
|
+
...actionResponse,
|
|
1923
|
+
ok: false,
|
|
1924
|
+
error: {
|
|
1925
|
+
code: 'verification_failed',
|
|
1926
|
+
message: '原生动作未返回完整的动作后观察结果,不能确认操作成功。',
|
|
1927
|
+
retryable: false,
|
|
1928
|
+
},
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
const postState = actionResponse.state;
|
|
1932
|
+
const settle = {
|
|
1933
|
+
settled: settleData.settled === true,
|
|
1934
|
+
stableMs: numberAt(settleData.stableMs),
|
|
1935
|
+
observedEpoch: numberAt(settleData.observedEpoch),
|
|
1936
|
+
...(stringAt(settleData.reason)
|
|
1937
|
+
? { reason: stringAt(settleData.reason) }
|
|
1938
|
+
: {}),
|
|
1939
|
+
};
|
|
1940
|
+
const graded = gradeActionVerification({
|
|
1941
|
+
action: input.action,
|
|
1942
|
+
expectedAppId: input.appId,
|
|
1943
|
+
actualAppId: postState.activeApp?.id,
|
|
1944
|
+
regionChangeRatio: actionData.regionChangeRatio,
|
|
1945
|
+
changeRatio: actionData.changeRatio,
|
|
1946
|
+
movedRatio: actionData.movedRatio,
|
|
1947
|
+
beforeRevision: before?.revision,
|
|
1948
|
+
afterRevision: postState.revision,
|
|
1949
|
+
settled: settle.settled,
|
|
1950
|
+
});
|
|
1951
|
+
const status = graded.status;
|
|
1952
|
+
const verification = {
|
|
1953
|
+
status,
|
|
1954
|
+
reason: graded.reason,
|
|
1955
|
+
...(before?.revision ? { beforeRevision: before.revision } : {}),
|
|
1956
|
+
afterRevision: postState.revision,
|
|
1957
|
+
changed: graded.changed,
|
|
1958
|
+
};
|
|
1959
|
+
// ★ 对齐 UI-TARS:actionMarker 标记实际点击位置(bbox中心点),不是左上角
|
|
1960
|
+
// 如果发生了AX吸附,标记吸附后的真实点击位置
|
|
1961
|
+
const ctxKey = contextKey(context);
|
|
1962
|
+
const snapInfo = this.lastSnapResults.get(ctxKey);
|
|
1963
|
+
let markerX = input.x;
|
|
1964
|
+
let markerY = input.y;
|
|
1965
|
+
if (snapInfo?.snapped) {
|
|
1966
|
+
markerX = snapInfo.x;
|
|
1967
|
+
markerY = snapInfo.y;
|
|
1968
|
+
}
|
|
1969
|
+
else if (input.x !== undefined && input.y !== undefined && input.x2 !== undefined && input.y2 !== undefined) {
|
|
1970
|
+
markerX = (input.x + input.x2) / 2;
|
|
1971
|
+
markerY = (input.y + input.y2) / 2;
|
|
1972
|
+
}
|
|
1973
|
+
return {
|
|
1974
|
+
...actionResponse,
|
|
1975
|
+
ok: status !== 'failed',
|
|
1976
|
+
postState,
|
|
1977
|
+
beforeState: before,
|
|
1978
|
+
verification,
|
|
1979
|
+
settle,
|
|
1980
|
+
actionMarker: {
|
|
1981
|
+
action: input.action,
|
|
1982
|
+
...(input.displayId ? { displayId: input.displayId } : {}),
|
|
1983
|
+
...(markerX !== undefined ? { x: markerX } : {}),
|
|
1984
|
+
...(markerY !== undefined ? { y: markerY } : {}),
|
|
1985
|
+
...(before?.revision ? { beforeRevision: before.revision } : {}),
|
|
1986
|
+
afterRevision: postState.revision,
|
|
1987
|
+
},
|
|
1988
|
+
...(status === 'failed'
|
|
1989
|
+
? {
|
|
1990
|
+
error: {
|
|
1991
|
+
code: 'verification_failed',
|
|
1992
|
+
message: verification.reason,
|
|
1993
|
+
retryable: false,
|
|
1994
|
+
},
|
|
1995
|
+
}
|
|
1996
|
+
: {}),
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* ★ AX吸附:更新最近一次观测的短id->真实id映射。
|
|
2001
|
+
* 由 computeruse.ts 在 buildObservationImages 后调用。
|
|
2002
|
+
*/
|
|
2003
|
+
updateAxIdMap(context, idMap) {
|
|
2004
|
+
const key = contextKey(context);
|
|
2005
|
+
if (idMap && idMap.size > 0) {
|
|
2006
|
+
this.axIdMaps.set(key, idMap);
|
|
2007
|
+
}
|
|
2008
|
+
else {
|
|
2009
|
+
this.axIdMaps.delete(key);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* ★ AX吸附:取出并清除最近一次动作的吸附结果。
|
|
2014
|
+
* 由 computeruse.ts 在 executeVerifiedAction 后调用,用于向模型反馈吸附信息。
|
|
2015
|
+
* 一次性消费,避免跨动作污染。
|
|
2016
|
+
*/
|
|
2017
|
+
consumeLastSnapResult(context) {
|
|
2018
|
+
const key = contextKey(context);
|
|
2019
|
+
const result = this.lastSnapResults.get(key);
|
|
2020
|
+
this.lastSnapResults.delete(key);
|
|
2021
|
+
return result;
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Internal observation for UI refresh, intervention inspect, diagnostics.
|
|
2025
|
+
* Returns a fresh screenshot/AX snapshot but does NOT advance the model-visible
|
|
2026
|
+
* epoch or update the observation cache. Safe to call from polling/UI code without
|
|
2027
|
+
* causing stale_epoch errors for the model's action sequence.
|
|
2028
|
+
*/
|
|
2029
|
+
async observeInternal(context, signal) {
|
|
2030
|
+
return this.request(context, { action: 'observe' }, signal, {
|
|
2031
|
+
internalObserve: true,
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* 记下这个动作对「焦点应该落到哪」的预期,供下一次观测判定用。
|
|
2036
|
+
*
|
|
2037
|
+
* 这条修的是一个必然发生的误判:agent 自己执行 `activate_app` 之后,
|
|
2038
|
+
* 下一次观测看到前台进程变了,于是判成 `other-process`「别的程序抢了前台」
|
|
2039
|
+
* 并触发人工介入 —— 一次由 agent 主动发起的正常切换被当成事故。
|
|
2040
|
+
* 只要工作流里有一步切应用,接下来就必然卡住。
|
|
2041
|
+
*
|
|
2042
|
+
* 观测类动作不记预期:它们不改变焦点,记了反而会覆盖掉上一个动作的预期。
|
|
2043
|
+
*/
|
|
2044
|
+
rememberExpectation(context, input, observation) {
|
|
2045
|
+
if (!isMutatingComputerUseAction(input.action))
|
|
2046
|
+
return;
|
|
2047
|
+
const key = contextKey(context);
|
|
2048
|
+
if (input.action === 'activate_app' && input.appId) {
|
|
2049
|
+
this.pendingExpectations.set(key, { expectAppId: input.appId });
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (input.action === 'window' &&
|
|
2053
|
+
input.windowId &&
|
|
2054
|
+
(input.windowOperation === 'focus' || input.windowOperation === undefined)) {
|
|
2055
|
+
this.pendingExpectations.set(key, { expectWindowId: input.windowId });
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
// 其余动作预期**留在原地**:点一下、打个字不该让前台换应用。
|
|
2059
|
+
// 记下当前前台是谁,这样「点击后前台变了」仍然是可检出的异常。
|
|
2060
|
+
const current = input.appId ?? observation?.activeApp?.id ?? observation?.activeApp?.name;
|
|
2061
|
+
if (current)
|
|
2062
|
+
this.pendingExpectations.set(key, { expectAppId: current });
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* 把模型给的 0–1000 相对坐标换算成守护进程要的逻辑点。
|
|
2066
|
+
*
|
|
2067
|
+
* 点和会改变系统状态的矩形都要换算。`observe { rect }` 是例外:它是
|
|
2068
|
+
* 本地模型图像裁剪,不会进入原生输入线路,因此无需也不能依赖旧显示器快照。
|
|
2069
|
+
*/
|
|
2070
|
+
mapRelativeCoordinates(input, observation, contextKey) {
|
|
2071
|
+
// ★ AX短id→真实id转换:如果elementId是@1/@2这种短引用,从map里取真实id
|
|
2072
|
+
if (input.elementId && input.elementId.startsWith('@')) {
|
|
2073
|
+
const idMap = this.axIdMaps.get(contextKey);
|
|
2074
|
+
const realId = idMap?.get(input.elementId);
|
|
2075
|
+
if (realId) {
|
|
2076
|
+
input = { ...input, elementId: realId };
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const needsPoint = [
|
|
2080
|
+
'click',
|
|
2081
|
+
'double_click',
|
|
2082
|
+
'right_click',
|
|
2083
|
+
'middle_click',
|
|
2084
|
+
'hover',
|
|
2085
|
+
'pointer_down',
|
|
2086
|
+
'pointer_move',
|
|
2087
|
+
'pointer_up',
|
|
2088
|
+
'drag',
|
|
2089
|
+
'scroll',
|
|
2090
|
+
].includes(input.action);
|
|
2091
|
+
const needsRect = input.rect !== undefined &&
|
|
2092
|
+
['grab_region_to_clipboard', 'window'].includes(input.action);
|
|
2093
|
+
const hasObserveRect = input.action === 'observe' && input.rect !== undefined;
|
|
2094
|
+
if ((!needsPoint || input.elementId) && !needsRect && !hasObserveRect)
|
|
2095
|
+
return { input };
|
|
2096
|
+
const coordinates = [input.x, input.y, input.x2, input.y2, input.toX, input.toY].filter((value) => value !== undefined);
|
|
2097
|
+
if (coordinates.some((value) => !Number.isFinite(value) || value < 0 || value > 1000)) {
|
|
2098
|
+
return {
|
|
2099
|
+
error: {
|
|
2100
|
+
code: 'invalid_coordinates',
|
|
2101
|
+
message: '坐标必须是0–1000范围内的有限相对值;请重新观察并定位目标。',
|
|
2102
|
+
retryable: false,
|
|
2103
|
+
},
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
// ★ 对齐 UI-TARS bbox 支持:如果提供了 x2/y2(框选右下角),自动取中心点
|
|
2107
|
+
// 这样模型只需要"框住"目标而不用精确点中一个像素,容错率大大提高
|
|
2108
|
+
// ★ 容错:如果模型只输出单点 x/y 没提供 x2/y2,自动补上默认大小的 bbox
|
|
2109
|
+
// 中心点保持为输入的 x/y 不变,避免点击位置偏移
|
|
2110
|
+
const DEFAULT_BBOX_SIZE = 40; // 0-1000 坐标中,默认 bbox 大小为 40×40(约 40-50 像素)
|
|
2111
|
+
let centerX = input.x;
|
|
2112
|
+
let centerY = input.y;
|
|
2113
|
+
if (input.x !== undefined && input.y !== undefined) {
|
|
2114
|
+
if (input.x2 !== undefined && input.y2 !== undefined) {
|
|
2115
|
+
centerX = (input.x + input.x2) / 2;
|
|
2116
|
+
centerY = (input.y + input.y2) / 2;
|
|
2117
|
+
}
|
|
2118
|
+
else {
|
|
2119
|
+
// 单点兜底:保持中心点为 (x,y),自动补上 x2/y2
|
|
2120
|
+
const halfSize = DEFAULT_BBOX_SIZE / 2;
|
|
2121
|
+
const cx = input.x;
|
|
2122
|
+
const cy = input.y;
|
|
2123
|
+
input.x = Math.max(0, cx - halfSize);
|
|
2124
|
+
input.y = Math.max(0, cy - halfSize);
|
|
2125
|
+
input.x2 = Math.min(1000, cx + halfSize);
|
|
2126
|
+
input.y2 = Math.min(1000, cy + halfSize);
|
|
2127
|
+
centerX = cx;
|
|
2128
|
+
centerY = cy;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if ((needsRect || hasObserveRect) &&
|
|
2132
|
+
input.rect &&
|
|
2133
|
+
(!Number.isFinite(input.rect.x) ||
|
|
2134
|
+
!Number.isFinite(input.rect.y) ||
|
|
2135
|
+
!Number.isFinite(input.rect.width) ||
|
|
2136
|
+
!Number.isFinite(input.rect.height) ||
|
|
2137
|
+
input.rect.x < 0 ||
|
|
2138
|
+
input.rect.y < 0 ||
|
|
2139
|
+
input.rect.width <= 0 ||
|
|
2140
|
+
input.rect.height <= 0 ||
|
|
2141
|
+
input.rect.x + input.rect.width > 1000 ||
|
|
2142
|
+
input.rect.y + input.rect.height > 1000)) {
|
|
2143
|
+
return {
|
|
2144
|
+
error: {
|
|
2145
|
+
code: 'invalid_coordinates',
|
|
2146
|
+
message: '矩形必须完整位于0–1000相对坐标范围内;请缩小或重新定位区域。',
|
|
2147
|
+
retryable: false,
|
|
2148
|
+
},
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
// ★ 对齐 UI-TARS:observe 永远返回全屏截图(不裁剪),不需要映射坐标。
|
|
2152
|
+
// rect 参数仅用于 grab_region_to_clipboard 和 window 操作。
|
|
2153
|
+
if (hasObserveRect)
|
|
2154
|
+
return { input };
|
|
2155
|
+
// 矩形动作不必指定 displayId —— 它们作用在被观测的那块屏上。
|
|
2156
|
+
// 点动作未指定时自动填充主显示器 id(单屏场景模型无需知道 display id)。
|
|
2157
|
+
const display = input.displayId
|
|
2158
|
+
? observation?.displays.find((candidate) => candidate.id === input.displayId)
|
|
2159
|
+
: observation?.displays[0];
|
|
2160
|
+
if (!display) {
|
|
2161
|
+
return {
|
|
2162
|
+
error: {
|
|
2163
|
+
code: 'stale_state',
|
|
2164
|
+
message: '坐标操作前必须先 observe;当前观察已失效或显示器已变化。',
|
|
2165
|
+
},
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
const { logicalBounds } = display;
|
|
2169
|
+
// ★ AX最近邻吸附:在比例坐标上执行吸附(找到最近元素,吸附到中心)
|
|
2170
|
+
let snapInfo;
|
|
2171
|
+
if (needsPoint && !input.elementId && centerX !== undefined && centerY !== undefined && observation) {
|
|
2172
|
+
snapInfo = snapToNearestAxElement(centerX, centerY, observation, display);
|
|
2173
|
+
if (snapInfo.snapped) {
|
|
2174
|
+
// 吸附后更新中心点
|
|
2175
|
+
centerX = snapInfo.x;
|
|
2176
|
+
centerY = snapInfo.y;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (needsPoint &&
|
|
2180
|
+
(centerX === undefined || centerY === undefined) &&
|
|
2181
|
+
!needsRect) {
|
|
2182
|
+
return { input };
|
|
2183
|
+
}
|
|
2184
|
+
const mapX = (value) => {
|
|
2185
|
+
const mapped = mapRelativePoint(value, logicalBounds.x, logicalBounds.width);
|
|
2186
|
+
return mapped === undefined
|
|
2187
|
+
? undefined
|
|
2188
|
+
: Math.min(mapped, logicalBounds.x + logicalBounds.width - 0.001);
|
|
2189
|
+
};
|
|
2190
|
+
const mapY = (value) => {
|
|
2191
|
+
const mapped = mapRelativePoint(value, logicalBounds.y, logicalBounds.height);
|
|
2192
|
+
return mapped === undefined
|
|
2193
|
+
? undefined
|
|
2194
|
+
: Math.min(mapped, logicalBounds.y + logicalBounds.height - 0.001);
|
|
2195
|
+
};
|
|
2196
|
+
return {
|
|
2197
|
+
input: {
|
|
2198
|
+
...input,
|
|
2199
|
+
// Auto-fill displayId for point actions when model didn't specify one
|
|
2200
|
+
// (single-display setups don't require the model to know the display id).
|
|
2201
|
+
...(needsPoint && !input.elementId && !input.displayId
|
|
2202
|
+
? { displayId: display.id }
|
|
2203
|
+
: {}),
|
|
2204
|
+
...(needsPoint && !input.elementId
|
|
2205
|
+
? {
|
|
2206
|
+
x: mapX(centerX),
|
|
2207
|
+
y: mapY(centerY),
|
|
2208
|
+
toX: mapX(input.toX),
|
|
2209
|
+
toY: mapY(input.toY),
|
|
2210
|
+
}
|
|
2211
|
+
: {}),
|
|
2212
|
+
...(needsRect
|
|
2213
|
+
? { rect: mapRelativeRect(input.rect, logicalBounds) }
|
|
2214
|
+
: {}),
|
|
2215
|
+
},
|
|
2216
|
+
...(snapInfo ? { snapInfo } : {}),
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/** 0–1000 相对矩形 → 逻辑点矩形。宽高至少 1 点,避免退化成不可见区域。 */
|
|
2221
|
+
function mapRelativeRect(rect, bounds) {
|
|
2222
|
+
return {
|
|
2223
|
+
x: bounds.x + (rect.x / 1000) * bounds.width,
|
|
2224
|
+
y: bounds.y + (rect.y / 1000) * bounds.height,
|
|
2225
|
+
width: Math.max(1, (rect.width / 1000) * bounds.width),
|
|
2226
|
+
height: Math.max(1, (rect.height / 1000) * bounds.height),
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
export function anonymousOwnerId() {
|
|
2230
|
+
return createHash('sha256')
|
|
2231
|
+
.update(`${homedir()}\0${process.getuid?.() ?? 'windows'}`)
|
|
2232
|
+
.digest('hex')
|
|
2233
|
+
.slice(0, 24);
|
|
2234
|
+
}
|
|
2235
|
+
export const computerUseService = new ComputerUseService();
|