Files
moeka-project/services/computer-use-mcp/src/browser-action-router.ts
T

174 lines
5.2 KiB
TypeScript

/**
* Browser action router — decides whether a desktop action should go through
* the browser-dom bridge (DOM-level precision) or OS-level input.
*
* Routing rules are fixed, not heuristic:
* - chrome_dom candidate with selector + bridge available → browser_dom
* - Everything else → os_input
* - Bridge unavailable → os_input (graceful fallback)
*
* Covers: click, type/setInputValue, checkCheckbox, selectOption.
*/
import type { DesktopTargetCandidate } from './desktop-grounding-types'
export interface BrowserActionDecision {
/** Which execution path to use */
route: 'browser_dom' | 'os_input'
/** Human-readable explanation of the routing decision */
reason: string
/** CSS selector for browser-dom action (only when route is browser_dom) */
selector?: string
/** Frame ID for browser-dom action (only when route is browser_dom) */
frameId?: number
/** Which bridge method to use (only when route is browser_dom) */
bridgeMethod?: 'clickSelector' | 'setInputValue' | 'checkCheckbox' | 'selectOption'
}
/**
* Shared precondition check for browser-dom routing.
* Returns a rejection decision if the candidate is ineligible,
* or undefined if all preconditions pass.
*/
function checkBrowserDomPreconditions(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
): BrowserActionDecision | undefined {
if (candidate.source !== 'chrome_dom') {
return {
route: 'os_input',
reason: `source is '${candidate.source}', not chrome_dom`,
}
}
if (!candidate.selector) {
return {
route: 'os_input',
reason: 'chrome_dom candidate has no CSS selector for re-query',
}
}
if (!bridgeAvailable) {
return {
route: 'os_input',
reason: 'browser-dom bridge is not connected, falling back to OS input',
}
}
return undefined
}
/**
* Decide whether a click on a candidate should go through browser-dom
* bridge or OS-level input. Also handles checkbox toggling via checkCheckbox.
*/
export function decideBrowserAction(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
actionButton: 'left' | 'right' | 'middle' = 'left',
clickCount = 1,
): BrowserActionDecision {
const rejection = checkBrowserDomPreconditions(candidate, bridgeAvailable)
if (rejection)
return rejection
if (actionButton !== 'left' || clickCount !== 1) {
return {
route: 'os_input',
reason: `browser-dom click routing only supports left single-click, got ${actionButton} with count ${clickCount}`,
}
}
// Checkbox: route to checkCheckbox instead of generic click
if (isCheckboxCandidate(candidate)) {
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'checkCheckbox',
reason: `chrome_dom checkbox with selector '${candidate.selector}' routed to checkCheckbox`,
}
}
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'clickSelector',
reason: `chrome_dom candidate with selector '${candidate.selector}' routed to browser-dom bridge`,
}
}
/**
* Decide whether a type action should go through browser-dom setInputValue
* or OS-level typeText.
*
* Only routes to browser_dom if the candidate is a text-input-like element
* (input[text|password|email|...], textarea, or role="textbox").
*/
export function decideBrowserTypeAction(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
): BrowserActionDecision {
const rejection = checkBrowserDomPreconditions(candidate, bridgeAvailable)
if (rejection)
return rejection
if (!isTextInputCandidate(candidate)) {
return {
route: 'os_input',
reason: `chrome_dom candidate tag '${candidate.tag}' is not a text input element`,
}
}
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'setInputValue',
reason: `chrome_dom text input with selector '${candidate.selector}' routed to setInputValue`,
}
}
// ---------------------------------------------------------------------------
// Candidate classification helpers
// ---------------------------------------------------------------------------
const TEXT_INPUT_TYPES = new Set([
'text',
'password',
'email',
'search',
'url',
'tel',
'number',
])
/** Whether a candidate represents a text-input-like element. */
function isTextInputCandidate(candidate: DesktopTargetCandidate): boolean {
const tag = candidate.tag?.toLowerCase()
if (tag === 'textarea')
return true
if (tag === 'input') {
// Exclude non-text input types (checkbox, radio, file, etc.)
const inputType = candidate.inputType?.toLowerCase() || 'text'
return TEXT_INPUT_TYPES.has(inputType)
}
// contenteditable elements surfaced with role="textbox"
if (candidate.role === 'textbox')
return true
return false
}
/** Whether a candidate represents a checkbox or toggle. */
function isCheckboxCandidate(candidate: DesktopTargetCandidate): boolean {
const tag = candidate.tag?.toLowerCase()
if (tag === 'input') {
const inputType = candidate.inputType?.toLowerCase()
return inputType === 'checkbox'
}
if (candidate.role === 'checkbox')
return true
return false
}