evOo-local/packages/adapters/src/browser-tools.ts
cynarAI 49431def83
feat: show the bot's live cursor and make the in-app browser work (#113)
Bots now announce every click and typing step before it runs, and the
web app draws a cursor in the bot's colour that glides to that point
over the live computer screen, ripples on clicks and shows typing.

- contracts: ComputerPointerPayload on the existing thread.computer event
- executor: computer_act and browser_act announce pointer steps
  (best effort); the page-browser helper reports element screen boxes,
  which are stripped before results reach the model
- web: agentPointerFeed + AgentCursor over the side-panel preview and
  the full-window screen, mapped through the noVNC letterbox
- in-app browser: native WebContentsView on desktop (own session, http(s)
  only, clamped bounds, back/forward, reload, DevTools, load errors) and
  a sandboxed iframe on the web; replaces the dead panel, the fake
  50%/40% cursors and the unimplemented devServer bridge


Claude-Session: https://claude.ai/code/session_01WiGooXtsKrZQmdszepRrNi

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 19:08:48 +02:00

131 lines
4.3 KiB
TypeScript

import type {
AdapterContext,
BrowserActKind,
BrowserActStep,
BrowserProvider,
ComputerRef,
} from "@rakazo/adapter-kit";
import type { ComputerPointerPayload } from "@rakazo/contracts";
import { type BrowserPointerTargets, withoutBrowserGeometry } from "./computer-pointer.js";
/** Optional hooks that let the visible bot cursor follow page-browser actions. */
export interface BrowserPointerHooks {
targets: BrowserPointerTargets;
announce: (payload: ComputerPointerPayload) => Promise<void>;
}
const MAX_BROWSER_ACTIONS = 24;
export async function browserNavigateFromTool(
browser: BrowserProvider,
computer: ComputerRef,
context: AdapterContext,
args: Record<string, unknown>,
pointer?: BrowserPointerHooks,
) {
const url = String(args.url ?? "").trim();
try {
const parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
return {
error: "An HTTP(S) URL without embedded credentials is required",
fallback: "computer_act" as const,
};
}
pointer?.targets.forget();
const result = await browser.navigate(computer, { url, signal: context.signal }, context);
return formatBrowserResult(result);
} catch (error) {
context.signal.throwIfAborted();
return {
error: error instanceof Error ? error.message : String(error),
fallback: "computer_act" as const,
};
}
}
export async function browserSnapshotFromTool(
browser: BrowserProvider,
computer: ComputerRef,
context: AdapterContext,
args: Record<string, unknown>,
pointer?: BrowserPointerHooks,
) {
void args;
try {
const result = await browser.snapshot(computer, { signal: context.signal }, context);
pointer?.targets.remember(result);
return formatBrowserResult(withoutBrowserGeometry(result));
} catch (error) {
context.signal.throwIfAborted();
return {
error: error instanceof Error ? error.message : String(error),
fallback: "computer_act" as const,
};
}
}
export async function browserActFromTool(
browser: BrowserProvider,
computer: ComputerRef,
context: AdapterContext,
args: Record<string, unknown>,
pointer?: BrowserPointerHooks,
) {
try {
const actions = parseBrowserActions(args.actions);
const announcement = pointer?.targets.payloadFor(actions);
if (announcement) await pointer?.announce(announcement);
const result = await browser.act(computer, { actions, signal: context.signal }, context);
if (result.elements) pointer?.targets.remember(result);
else pointer?.targets.forget();
return formatBrowserResult(withoutBrowserGeometry(result));
} catch (error) {
context.signal.throwIfAborted();
return {
ok: false,
uncertain: true,
error: error instanceof Error ? error.message : String(error),
fallback: "computer_act" as const,
};
}
}
export function parseBrowserActions(value: unknown): BrowserActStep[] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("browser_act requires at least one action");
}
if (value.length > MAX_BROWSER_ACTIONS) {
throw new Error(`browser_act accepts at most ${MAX_BROWSER_ACTIONS} actions`);
}
return value.map((raw, index) => {
if (!raw || typeof raw !== "object") {
throw new Error(`browser_act action ${index} must be an object`);
}
const action = raw as Record<string, unknown>;
const kind = String(action.kind ?? "") as BrowserActKind;
if (kind !== "click" && kind !== "fill" && kind !== "type") {
throw new Error(`browser_act action ${index} has unsupported kind`);
}
const ref = String(action.ref ?? "").trim();
if (!ref) throw new Error(`browser_act action ${index} requires ref`);
if (kind === "fill" || kind === "type") {
if (typeof action.text !== "string") {
throw new Error(`browser_act ${kind} requires text`);
}
return { kind, ref, text: String(action.text) };
}
return { kind, ref };
});
}
function formatBrowserResult<T extends { fallback?: "computer_act"; error?: string }>(result: T) {
if (result.fallback === "computer_act") {
return {
...result,
note: "Page browser could not complete this step. Inspect the current state before continuing with computer_act if available, otherwise request_takeover. Do not replay completed or uncertain actions.",
};
}
return result;
}