@loadmill/droid-cua 2.6.0 → 2.8.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/LICENSE +1 -1
- package/README.md +11 -2
- package/build/index.js +55 -20
- package/build/src/browser/actions.js +9 -0
- package/build/src/browser/connection.js +37 -0
- package/build/src/browser/factory.js +22 -0
- package/build/src/browser/interface.js +14 -0
- package/build/src/browser/navigation.js +11 -0
- package/build/src/browser/normalize.js +36 -0
- package/build/src/browser/playwright/actions.js +199 -0
- package/build/src/browser/playwright/connection.js +196 -0
- package/build/src/browser/playwright/installed-browser.js +131 -0
- package/build/src/browser/playwright/session-state.js +19 -0
- package/build/src/browser/playwright/url.js +79 -0
- package/build/src/cli/headless-execution-config.js +59 -3
- package/build/src/cli/headless-instructions.js +12 -0
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/commands/help.js +10 -2
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/context-optimization.js +36 -0
- package/build/src/core/execution-engine.js +45 -12
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/session.js +18 -4
- package/build/src/device/action-event-meta.js +18 -0
- package/build/src/device/android/actions.js +6 -13
- package/build/src/device/cloud/actions.js +9 -14
- package/build/src/device/cloud/connection.js +8 -0
- package/build/src/device/ios/actions.js +6 -13
- package/build/src/device/ios/appium-server.js +4 -3
- package/build/src/device/ios/connection.js +8 -0
- package/build/src/device/ios/node-cli-env.js +28 -0
- package/build/src/device/openai.js +47 -2
- package/build/src/modes/execution-mode.js +113 -21
- package/build/src/prompts/base.js +95 -21
- package/build/src/prompts/design.js +6 -11
- package/build/src/prompts/execution.js +26 -4
- package/build/src/utils/run-screenshot-recorder.js +1 -1
- package/package.json +7 -1
package/LICENSE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
©
|
|
1
|
+
© 2026 Loadmill. All rights reserved.
|
package/README.md
CHANGED
|
@@ -155,7 +155,9 @@ Example headless config:
|
|
|
155
155
|
},
|
|
156
156
|
"appContextEnabled": true,
|
|
157
157
|
"appContextBudget": 300,
|
|
158
|
-
"appContextPath": "../tests/context.md"
|
|
158
|
+
"appContextPath": "../tests/context.md",
|
|
159
|
+
"contextOptimizationEnabled": true,
|
|
160
|
+
"contextOptimizationThreshold": 30000
|
|
159
161
|
}
|
|
160
162
|
```
|
|
161
163
|
|
|
@@ -177,10 +179,14 @@ Supported CLI options include:
|
|
|
177
179
|
- `--context`
|
|
178
180
|
- `--app-context-budget`
|
|
179
181
|
- `--no-context`
|
|
182
|
+
- `--context-optimization-threshold`
|
|
183
|
+
- `--no-context-optimization`
|
|
180
184
|
- `--base-prompt`
|
|
181
185
|
- `--execution-prompt`
|
|
182
186
|
- `--base-prompt-file`
|
|
183
187
|
- `--execution-prompt-file`
|
|
188
|
+
- `--strict`
|
|
189
|
+
- `--no-strict`
|
|
184
190
|
- `--record`
|
|
185
191
|
- `--debug`
|
|
186
192
|
|
|
@@ -189,6 +195,9 @@ Config and precedence rules:
|
|
|
189
195
|
- CLI flags override config file values.
|
|
190
196
|
- `--context` overrides the config app-context path.
|
|
191
197
|
- `--no-context` disables app context entirely.
|
|
198
|
+
- Context Optimization is enabled by default with a `30000` input-token threshold.
|
|
199
|
+
- `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
|
|
200
|
+
- `--no-context-optimization` disables Context Optimization.
|
|
192
201
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
|
193
202
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
194
203
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
@@ -216,4 +225,4 @@ Current headless behavior is documented in [docs/headless-cli-spec.md](docs/head
|
|
|
216
225
|
|
|
217
226
|
<h2 id="license">📄 License</h2>
|
|
218
227
|
|
|
219
|
-
©
|
|
228
|
+
© 2026 Loadmill. All rights reserved.
|
package/build/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import minimist from "minimist";
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { mkdir
|
|
4
|
+
import { mkdir } from "fs/promises";
|
|
5
5
|
import { connectToDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
6
|
+
import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "./src/browser/connection.js";
|
|
7
|
+
import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
|
|
8
|
+
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
6
9
|
import { Session } from "./src/core/session.js";
|
|
7
10
|
import { ExecutionEngine } from "./src/core/execution-engine.js";
|
|
8
11
|
import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
|
|
@@ -11,14 +14,18 @@ import { ExecutionMode } from "./src/modes/execution-mode.js";
|
|
|
11
14
|
import { logger } from "./src/utils/logger.js";
|
|
12
15
|
import { selectDevice } from "./src/cli/device-selector.js";
|
|
13
16
|
import { buildAppContextBriefing, DEFAULT_APP_CONTEXT_BUDGET } from "./src/core/app-context.js";
|
|
17
|
+
import { CONTEXT_OPTIMIZATION_ENABLED_ENV, CONTEXT_OPTIMIZATION_THRESHOLD_ENV, } from "./src/core/context-optimization.js";
|
|
14
18
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
19
|
+
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
15
20
|
import { printCliOutput } from "./src/utils/console-output.js";
|
|
16
21
|
import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
|
|
17
22
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
23
|
+
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
18
24
|
dotenv.config();
|
|
19
25
|
const args = minimist(process.argv.slice(2));
|
|
20
|
-
|
|
21
|
-
let
|
|
26
|
+
const targetConfig = resolveHeadlessTarget(args);
|
|
27
|
+
let avdName = targetConfig.avdName;
|
|
28
|
+
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
22
29
|
const recordScreenshots = args["record"] || false;
|
|
23
30
|
const instructionsFile = args.instructions || args.i || null;
|
|
24
31
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
@@ -33,6 +40,7 @@ if (recordScreenshots)
|
|
|
33
40
|
await mkdir(screenshotDir, { recursive: true });
|
|
34
41
|
async function main() {
|
|
35
42
|
const isHeadlessInstructionsRun = Boolean(instructionsFile);
|
|
43
|
+
const isBrowserTarget = targetConfig.target === "web";
|
|
36
44
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
37
45
|
cwd: process.cwd(),
|
|
38
46
|
enabled: isHeadlessInstructionsRun && debugMode
|
|
@@ -42,19 +50,38 @@ async function main() {
|
|
|
42
50
|
}
|
|
43
51
|
// Initialize legacy plain-text debug logging only for non-headless flows.
|
|
44
52
|
await logger.init(debugMode && !isHeadlessInstructionsRun);
|
|
53
|
+
if (isBrowserTarget && !isHeadlessInstructionsRun) {
|
|
54
|
+
throw new Error("--target web is only supported with --instructions headless execution.");
|
|
55
|
+
}
|
|
45
56
|
// If no device specified, show interactive selection menu
|
|
46
|
-
if (!avdName && !platform) {
|
|
57
|
+
if (!isBrowserTarget && !avdName && !platform) {
|
|
47
58
|
const selection = await selectDevice();
|
|
48
59
|
platform = selection.platform;
|
|
49
60
|
avdName = selection.deviceName;
|
|
50
61
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
let targetId;
|
|
63
|
+
let deviceInfo;
|
|
64
|
+
if (isBrowserTarget) {
|
|
65
|
+
targetId = await connectToBrowser({
|
|
66
|
+
url: targetConfig.url,
|
|
67
|
+
headless: true,
|
|
68
|
+
sessionMode: targetConfig.sessionMode
|
|
69
|
+
});
|
|
70
|
+
deviceInfo = normalizeBrowserInfoForSession(await getBrowserInfo(targetId));
|
|
71
|
+
console.log(`Using browser viewport: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
72
|
+
console.log(`Model sees viewport: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
73
|
+
if (deviceInfo.currentUrl) {
|
|
74
|
+
console.log(`Starting URL: ${deviceInfo.currentUrl}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
targetId = await connectToDevice(avdName, platform);
|
|
79
|
+
deviceInfo = await getDeviceInfo(targetId);
|
|
80
|
+
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
81
|
+
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
82
|
+
}
|
|
56
83
|
// Create session to manage state
|
|
57
|
-
const session = new Session(
|
|
84
|
+
const session = new Session(targetId, deviceInfo);
|
|
58
85
|
const initialSystemText = buildBaseSystemPrompt(deviceInfo);
|
|
59
86
|
session.setSystemPrompt(initialSystemText);
|
|
60
87
|
// If --instructions provided, run in headless mode
|
|
@@ -74,14 +101,13 @@ async function main() {
|
|
|
74
101
|
console.log(`Debug logging enabled: ${currentLogFilePath}`);
|
|
75
102
|
}
|
|
76
103
|
console.log(`\nRunning test from: ${instructionsFile}\n`);
|
|
77
|
-
|
|
78
|
-
instructions = content
|
|
79
|
-
.split("\n")
|
|
80
|
-
.map(line => line.trim())
|
|
81
|
-
.filter(line => line.length > 0);
|
|
104
|
+
instructions = await loadHeadlessInstructions(instructionsFile);
|
|
82
105
|
const taskText = instructions.join("\n");
|
|
83
106
|
const headlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
84
107
|
process.env.OPENAI_CUA_MODEL = headlessConfig.cuaModel;
|
|
108
|
+
process.env[CONTEXT_OPTIMIZATION_ENABLED_ENV] = String(headlessConfig.contextOptimizationEnabled);
|
|
109
|
+
process.env[CONTEXT_OPTIMIZATION_THRESHOLD_ENV] = String(headlessConfig.contextOptimizationThreshold);
|
|
110
|
+
console.log(`Using CUA model: ${headlessConfig.cuaModel}`);
|
|
85
111
|
let appContextBriefing = "";
|
|
86
112
|
if (!headlessConfig.appContextEnabled) {
|
|
87
113
|
emitDesktopDebug("app_context.status", "execution", { runId }, {
|
|
@@ -136,6 +162,9 @@ async function main() {
|
|
|
136
162
|
budget: headlessConfig.appContextBudget,
|
|
137
163
|
status: "missing"
|
|
138
164
|
});
|
|
165
|
+
if (isBrowserTarget && deviceInfo.currentUrl) {
|
|
166
|
+
appContextBriefing = `Starting URL: ${deviceInfo.currentUrl}`;
|
|
167
|
+
}
|
|
139
168
|
}
|
|
140
169
|
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextBriefing, { strictMode: headlessConfig.strictMode });
|
|
141
170
|
session.setSystemPrompt(executionPrompt);
|
|
@@ -144,17 +173,23 @@ async function main() {
|
|
|
144
173
|
recordScreenshots,
|
|
145
174
|
screenshotDir
|
|
146
175
|
});
|
|
147
|
-
const
|
|
148
|
-
?
|
|
176
|
+
const engineOptions = screenshotRecorder
|
|
177
|
+
? {
|
|
149
178
|
recordScreenshots: true,
|
|
150
179
|
screenshotRecorder,
|
|
151
180
|
strictMode: headlessConfig.strictMode
|
|
152
|
-
}
|
|
153
|
-
:
|
|
181
|
+
}
|
|
182
|
+
: {
|
|
154
183
|
recordScreenshots,
|
|
155
184
|
screenshotDir,
|
|
156
185
|
strictMode: headlessConfig.strictMode
|
|
157
|
-
}
|
|
186
|
+
};
|
|
187
|
+
if (isBrowserTarget) {
|
|
188
|
+
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
189
|
+
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
190
|
+
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
191
|
+
}
|
|
192
|
+
const engine = new ExecutionEngine(session, engineOptions);
|
|
158
193
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
159
194
|
const result = await executionMode.execute({
|
|
160
195
|
runId,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getBrowserBackend, getCurrentPlatform } from "./factory.js";
|
|
2
|
+
export async function handleModelAction(browserSessionId, action, scale = 1.0, context = null) {
|
|
3
|
+
const platform = getCurrentPlatform();
|
|
4
|
+
if (!platform) {
|
|
5
|
+
throw new Error("No platform set. Call connectToBrowser first.");
|
|
6
|
+
}
|
|
7
|
+
const backend = getBrowserBackend(platform);
|
|
8
|
+
return backend.handleModelAction(browserSessionId, action, scale, context);
|
|
9
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getBrowserBackend, detectPlatform, setCurrentPlatform, getCurrentPlatform } from "./factory.js";
|
|
2
|
+
let currentBackend = null;
|
|
3
|
+
export async function connectToBrowser(targetOrOptions, platform = null) {
|
|
4
|
+
if (platform && platform !== "browser") {
|
|
5
|
+
throw new Error(`Unsupported platform: ${platform}. Only browser is supported.`);
|
|
6
|
+
}
|
|
7
|
+
const detectedPlatform = platform || detectPlatform(targetOrOptions);
|
|
8
|
+
setCurrentPlatform(detectedPlatform);
|
|
9
|
+
currentBackend = getBrowserBackend(detectedPlatform);
|
|
10
|
+
console.log(`Platform: ${detectedPlatform}`);
|
|
11
|
+
return currentBackend.connectToBrowser(targetOrOptions);
|
|
12
|
+
}
|
|
13
|
+
export async function getBrowserInfo(browserSessionId) {
|
|
14
|
+
if (!currentBackend) {
|
|
15
|
+
throw new Error("Not connected to a browser session. Call connectToBrowser first.");
|
|
16
|
+
}
|
|
17
|
+
return currentBackend.getBrowserInfo(browserSessionId);
|
|
18
|
+
}
|
|
19
|
+
export async function getScreenshotAsBase64(browserSessionId, browserInfo) {
|
|
20
|
+
if (!currentBackend) {
|
|
21
|
+
throw new Error("Not connected to a browser session. Call connectToBrowser first.");
|
|
22
|
+
}
|
|
23
|
+
return currentBackend.getScreenshotAsBase64(browserSessionId, browserInfo);
|
|
24
|
+
}
|
|
25
|
+
export async function navigateToUrl(browserSessionId, url) {
|
|
26
|
+
if (!currentBackend?.navigateToUrl) {
|
|
27
|
+
throw new Error("Active browser backend does not support direct navigation.");
|
|
28
|
+
}
|
|
29
|
+
return currentBackend.navigateToUrl(browserSessionId, url);
|
|
30
|
+
}
|
|
31
|
+
export { getCurrentPlatform };
|
|
32
|
+
export async function disconnect() {
|
|
33
|
+
if (currentBackend?.disconnect) {
|
|
34
|
+
await currentBackend.disconnect();
|
|
35
|
+
}
|
|
36
|
+
currentBackend = null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as browserConnection from "./playwright/connection.js";
|
|
2
|
+
import * as browserActions from "./playwright/actions.js";
|
|
3
|
+
let currentPlatform = null;
|
|
4
|
+
export function detectPlatform() {
|
|
5
|
+
return "browser";
|
|
6
|
+
}
|
|
7
|
+
export function getBrowserBackend(_platform = "browser") {
|
|
8
|
+
return {
|
|
9
|
+
connectToBrowser: browserConnection.connectToBrowser,
|
|
10
|
+
getBrowserInfo: browserConnection.getBrowserInfo,
|
|
11
|
+
getScreenshotAsBase64: browserConnection.getScreenshotAsBase64,
|
|
12
|
+
navigateToUrl: browserConnection.navigateToUrl,
|
|
13
|
+
handleModelAction: browserActions.handleModelAction,
|
|
14
|
+
disconnect: browserConnection.disconnect,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function setCurrentPlatform(platform) {
|
|
18
|
+
currentPlatform = platform;
|
|
19
|
+
}
|
|
20
|
+
export function getCurrentPlatform() {
|
|
21
|
+
return currentPlatform;
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function isNavigateInstruction(userInput) {
|
|
2
|
+
const trimmed = typeof userInput === "string" ? userInput.trim() : "";
|
|
3
|
+
return trimmed.toLowerCase().startsWith("navigate:");
|
|
4
|
+
}
|
|
5
|
+
export function extractNavigateTarget(userInput) {
|
|
6
|
+
const trimmed = typeof userInput === "string" ? userInput.trim() : "";
|
|
7
|
+
if (!trimmed.toLowerCase().startsWith("navigate:")) {
|
|
8
|
+
return trimmed;
|
|
9
|
+
}
|
|
10
|
+
return trimmed.slice("navigate:".length).trim();
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const DEFAULT_BROWSER_VIEWPORT = {
|
|
2
|
+
width: 1440,
|
|
3
|
+
height: 900,
|
|
4
|
+
};
|
|
5
|
+
function finiteNumber(value, fallback) {
|
|
6
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
7
|
+
}
|
|
8
|
+
export function normalizeBrowserInfoForSession(browserInfo = {}) {
|
|
9
|
+
const viewportWidth = finiteNumber(browserInfo.viewportWidth, DEFAULT_BROWSER_VIEWPORT.width);
|
|
10
|
+
const viewportHeight = finiteNumber(browserInfo.viewportHeight, DEFAULT_BROWSER_VIEWPORT.height);
|
|
11
|
+
const scaledWidth = finiteNumber(browserInfo.scaledWidth, viewportWidth);
|
|
12
|
+
const scaledHeight = finiteNumber(browserInfo.scaledHeight, viewportHeight);
|
|
13
|
+
const scale = finiteNumber(browserInfo.scale, 1);
|
|
14
|
+
const browserName = typeof browserInfo.browserName === "string" && browserInfo.browserName.trim()
|
|
15
|
+
? browserInfo.browserName.trim()
|
|
16
|
+
: "Browser";
|
|
17
|
+
const browserFamily = browserInfo.browserFamily === "chrome" || browserInfo.browserFamily === "edge"
|
|
18
|
+
? browserInfo.browserFamily
|
|
19
|
+
: null;
|
|
20
|
+
const currentUrl = typeof browserInfo.currentUrl === "string" && browserInfo.currentUrl.trim()
|
|
21
|
+
? browserInfo.currentUrl.trim()
|
|
22
|
+
: null;
|
|
23
|
+
return {
|
|
24
|
+
...browserInfo,
|
|
25
|
+
platform: "browser",
|
|
26
|
+
device_width: viewportWidth,
|
|
27
|
+
device_height: viewportHeight,
|
|
28
|
+
scaled_width: scaledWidth,
|
|
29
|
+
scaled_height: scaledHeight,
|
|
30
|
+
scale,
|
|
31
|
+
device_name: browserName,
|
|
32
|
+
browserName,
|
|
33
|
+
browserFamily,
|
|
34
|
+
currentUrl,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
|
+
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../../device/action-event-meta.js";
|
|
5
|
+
import { getActiveBrowserPage } from "./session-state.js";
|
|
6
|
+
const KEY_ALIASES = {
|
|
7
|
+
CTRL: "Control",
|
|
8
|
+
CONTROL: "Control",
|
|
9
|
+
CMD: "Meta",
|
|
10
|
+
COMMAND: "Meta",
|
|
11
|
+
META: "Meta",
|
|
12
|
+
ALT: "Alt",
|
|
13
|
+
OPTION: "Alt",
|
|
14
|
+
SHIFT: "Shift",
|
|
15
|
+
ENTER: "Enter",
|
|
16
|
+
RETURN: "Enter",
|
|
17
|
+
ESC: "Escape",
|
|
18
|
+
ESCAPE: "Escape",
|
|
19
|
+
BACKSPACE: "Backspace",
|
|
20
|
+
DELETE: "Delete",
|
|
21
|
+
TAB: "Tab",
|
|
22
|
+
SPACE: "Space",
|
|
23
|
+
HOME: "Home",
|
|
24
|
+
END: "End",
|
|
25
|
+
PAGEUP: "PageUp",
|
|
26
|
+
PAGEDOWN: "PageDown",
|
|
27
|
+
ARROWUP: "ArrowUp",
|
|
28
|
+
ARROWDOWN: "ArrowDown",
|
|
29
|
+
ARROWLEFT: "ArrowLeft",
|
|
30
|
+
ARROWRIGHT: "ArrowRight",
|
|
31
|
+
};
|
|
32
|
+
function normalizeKeyEntry(key) {
|
|
33
|
+
const raw = String(key ?? "").trim();
|
|
34
|
+
if (!raw) {
|
|
35
|
+
throw new Error("Keypress action is missing keys.");
|
|
36
|
+
}
|
|
37
|
+
const normalized = KEY_ALIASES[raw.toUpperCase()];
|
|
38
|
+
if (normalized) {
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
if (raw.length === 1) {
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
return raw[0].toUpperCase() + raw.slice(1);
|
|
45
|
+
}
|
|
46
|
+
function normalizeShortcut(keys = []) {
|
|
47
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
48
|
+
throw new Error("Keypress action is missing keys.");
|
|
49
|
+
}
|
|
50
|
+
return keys.map(normalizeKeyEntry).join("+");
|
|
51
|
+
}
|
|
52
|
+
function buildMeta(context, action, payload = {}) {
|
|
53
|
+
return buildActionEventMeta({
|
|
54
|
+
context,
|
|
55
|
+
action,
|
|
56
|
+
platform: "browser",
|
|
57
|
+
payload,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
|
|
61
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
62
|
+
const page = getActiveBrowserPage(browserSessionId);
|
|
63
|
+
try {
|
|
64
|
+
emitDesktopDebug("browser.action.execute", "device", {
|
|
65
|
+
runId: context?.runId,
|
|
66
|
+
sessionId: context?.sessionId,
|
|
67
|
+
stepId: context?.stepId,
|
|
68
|
+
instructionIndex: context?.instructionIndex,
|
|
69
|
+
}, {
|
|
70
|
+
platform: "browser",
|
|
71
|
+
browserSessionId,
|
|
72
|
+
actionType: action?.type,
|
|
73
|
+
scale,
|
|
74
|
+
x: action?.x,
|
|
75
|
+
y: action?.y,
|
|
76
|
+
x1: action?.x1,
|
|
77
|
+
y1: action?.y1,
|
|
78
|
+
x2: action?.x2,
|
|
79
|
+
y2: action?.y2,
|
|
80
|
+
text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
|
|
81
|
+
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
82
|
+
pathPoints: Array.isArray(action?.path) ? action.path.length : 0,
|
|
83
|
+
});
|
|
84
|
+
switch (action.type) {
|
|
85
|
+
case "click": {
|
|
86
|
+
const x = Math.round(action.x / scale);
|
|
87
|
+
const y = Math.round(action.y / scale);
|
|
88
|
+
addOutput({ type: "action", text: "Click", ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
89
|
+
await page.mouse.click(x, y);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "double_click": {
|
|
93
|
+
const x = Math.round(action.x / scale);
|
|
94
|
+
const y = Math.round(action.y / scale);
|
|
95
|
+
addOutput({ type: "action", text: `Double-clicking at (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
96
|
+
await page.mouse.dblclick(x, y);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "move": {
|
|
100
|
+
const x = Math.round(action.x / scale);
|
|
101
|
+
const y = Math.round(action.y / scale);
|
|
102
|
+
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
103
|
+
await page.mouse.move(x, y);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "scroll": {
|
|
107
|
+
const scrollX = Math.round((action.scroll_x ?? 0) / scale);
|
|
108
|
+
const scrollY = Math.round((action.scroll_y ?? 0) / scale);
|
|
109
|
+
addOutput({
|
|
110
|
+
type: "action",
|
|
111
|
+
text: `Scrolling by (${scrollX}, ${scrollY})`,
|
|
112
|
+
...buildMeta(context, action, { scrollX, scrollY, unit: "px" }),
|
|
113
|
+
});
|
|
114
|
+
await page.mouse.wheel(scrollX, scrollY);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "drag": {
|
|
118
|
+
const dragPath = Array.isArray(action.path) ? action.path : [];
|
|
119
|
+
if (dragPath.length < 2) {
|
|
120
|
+
throw new Error("Drag action requires a path with at least two points.");
|
|
121
|
+
}
|
|
122
|
+
const [start] = dragPath;
|
|
123
|
+
const end = dragPath[dragPath.length - 1];
|
|
124
|
+
const scaledStart = { x: Math.round(start.x / scale), y: Math.round(start.y / scale) };
|
|
125
|
+
const scaledEnd = { x: Math.round(end.x / scale), y: Math.round(end.y / scale) };
|
|
126
|
+
addOutput({
|
|
127
|
+
type: "action",
|
|
128
|
+
text: `Dragging from (${scaledStart.x}, ${scaledStart.y}) to (${scaledEnd.x}, ${scaledEnd.y})`,
|
|
129
|
+
...buildMeta(context, action, { pathStart: scaledStart, pathEnd: scaledEnd, unit: "px" }),
|
|
130
|
+
});
|
|
131
|
+
await page.mouse.move(scaledStart.x, scaledStart.y);
|
|
132
|
+
await page.mouse.down();
|
|
133
|
+
for (const point of dragPath.slice(1)) {
|
|
134
|
+
await page.mouse.move(Math.round(point.x / scale), Math.round(point.y / scale));
|
|
135
|
+
}
|
|
136
|
+
await page.mouse.up();
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "type": {
|
|
140
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
141
|
+
addOutput({ type: "action", text: `Typing text: ${text}`, ...buildMeta(context, action, { text }) });
|
|
142
|
+
await page.keyboard.type(text);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "keypress": {
|
|
146
|
+
const shortcut = normalizeShortcut(action.keys);
|
|
147
|
+
addOutput({
|
|
148
|
+
type: "action",
|
|
149
|
+
text: `Pressing key: ${shortcut}`,
|
|
150
|
+
...buildMeta(context, action, { keys: action.keys, shortcut }),
|
|
151
|
+
});
|
|
152
|
+
await page.keyboard.press(shortcut);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "wait": {
|
|
156
|
+
addOutput({ type: "action", text: "Waiting...", ...buildMeta(context, action) });
|
|
157
|
+
const timeoutMs = typeof action.ms === "number" && Number.isFinite(action.ms) && action.ms >= 0
|
|
158
|
+
? Math.round(action.ms)
|
|
159
|
+
: getConfiguredStepDelayMs();
|
|
160
|
+
await page.waitForTimeout(timeoutMs);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
logger.error("Action execution error", {
|
|
169
|
+
action,
|
|
170
|
+
message: error.message,
|
|
171
|
+
stack: error.stack,
|
|
172
|
+
});
|
|
173
|
+
emitDesktopDebug("browser.error", "device", {
|
|
174
|
+
runId: context?.runId,
|
|
175
|
+
sessionId: context?.sessionId,
|
|
176
|
+
stepId: context?.stepId,
|
|
177
|
+
instructionIndex: context?.instructionIndex,
|
|
178
|
+
}, {
|
|
179
|
+
platform: "browser",
|
|
180
|
+
operation: "action.execute",
|
|
181
|
+
actionType: action?.type,
|
|
182
|
+
message: error.message,
|
|
183
|
+
});
|
|
184
|
+
addOutput({
|
|
185
|
+
type: "error",
|
|
186
|
+
text: `Error executing action: ${error.message}`,
|
|
187
|
+
eventType: "error",
|
|
188
|
+
actionType: action?.type,
|
|
189
|
+
runId: context?.runId,
|
|
190
|
+
stepId: context?.stepId,
|
|
191
|
+
instructionIndex: context?.instructionIndex,
|
|
192
|
+
payload: {
|
|
193
|
+
message: error.message,
|
|
194
|
+
platform: "browser",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
198
|
+
}
|
|
199
|
+
}
|