* feat(web): Oo computer browser panel with cowork and local preview Wire the in-app browser panel to the active Oo's live Chromium via the computer screen stream, page-browser navigation, and interactive cowork input with AgentCursor. User browser actions become system meta lines in thread context; localhost preview remains as Lokale Vorschau. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(i18n): sync Lingui catalogs for Oo browser panel Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Fix the Oo browser's lint and E2E failures The shared-browser overlay is a focusable input surface, so it gets the application role and a translated label. The local preview focuses the address field again when no page is open, which the split out of InAppBrowser had dropped. The Oo browser spec opens the panel by its exact name, because the signed-up user's name also contains "Browser", and reads the user action from the RPC body's json envelope. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
275 lines
8.8 KiB
TypeScript
275 lines
8.8 KiB
TypeScript
import type {
|
|
AdapterContext,
|
|
BrowserActRequest,
|
|
BrowserActResult,
|
|
BrowserNavigateRequest,
|
|
BrowserNavigateResult,
|
|
BrowserProvider,
|
|
BrowserSnapshotNode,
|
|
BrowserSnapshotRequest,
|
|
BrowserSnapshotResult,
|
|
ComputerRef,
|
|
PageBrowserCommand,
|
|
PageBrowserResult,
|
|
SandboxProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import { FakeBrowserProvider, type FakeBrowserProviderOptions } from "./fake-browser.js";
|
|
import { pageBrowserSessionKey } from "./page-browser-session.js";
|
|
|
|
const DETACHED_MESSAGE =
|
|
"Page browser is not attached to this computer's Chrome. Use computer_act on the desktop browser instead.";
|
|
|
|
type LivePayload = Partial<PageBrowserResult>;
|
|
|
|
export type LivePageBrowserDriver = (
|
|
computer: ComputerRef,
|
|
request: PageBrowserCommand,
|
|
context: AdapterContext,
|
|
) => Promise<LivePayload>;
|
|
|
|
/**
|
|
* Production page-browser adapter for the bot computer.
|
|
*
|
|
* Fake computers (and the fake sandbox) use the in-process DOM session.
|
|
* Real computers drive the live Chrome already on the graphical display via
|
|
* CDP (`rakazo-page-browser`). If that path cannot operate, return
|
|
* `fallback: "computer_act"` — never report success against a detached DOM.
|
|
*/
|
|
export class ComputerBrowserProvider implements BrowserProvider {
|
|
private readonly fake: FakeBrowserProvider;
|
|
private readonly sandbox?: SandboxProvider;
|
|
private readonly liveDriver?: LivePageBrowserDriver;
|
|
|
|
constructor(
|
|
options: FakeBrowserProviderOptions & {
|
|
sandbox?: SandboxProvider;
|
|
/** Test seam for the live CDP helper. */
|
|
liveDriver?: LivePageBrowserDriver;
|
|
} = {},
|
|
) {
|
|
const { sandbox, liveDriver, ...fakeOptions } = options;
|
|
this.sandbox = sandbox;
|
|
this.liveDriver = liveDriver;
|
|
this.fake = new FakeBrowserProvider(fakeOptions);
|
|
}
|
|
|
|
describe() {
|
|
return {
|
|
id: "computer",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: {
|
|
page: Boolean(
|
|
this.liveDriver || this.sandbox?.pageBrowser || this.sandbox?.describe().id === "fake",
|
|
),
|
|
refs: true,
|
|
keyless: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async navigate(
|
|
computer: ComputerRef,
|
|
request: BrowserNavigateRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserNavigateResult> {
|
|
if (this.canUseInProcess(computer)) {
|
|
return this.fake.navigate(computer, request, context);
|
|
}
|
|
const live = await this.runLive(computer, { command: "navigate", url: request.url }, context);
|
|
if (live?.ok !== true || live.fallback === "computer_act") {
|
|
return {
|
|
url: request.url,
|
|
title: "",
|
|
fallback: "computer_act",
|
|
error: live?.error || DETACHED_MESSAGE,
|
|
};
|
|
}
|
|
return {
|
|
url: typeof live.url === "string" ? live.url : request.url,
|
|
title: typeof live.title === "string" ? live.title : "",
|
|
};
|
|
}
|
|
|
|
async snapshot(
|
|
computer: ComputerRef,
|
|
request: BrowserSnapshotRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserSnapshotResult> {
|
|
if (this.canUseInProcess(computer)) {
|
|
return this.fake.snapshot(computer, request, context);
|
|
}
|
|
const live = await this.runLive(computer, { command: "snapshot" }, context);
|
|
if (live?.ok !== true || live.fallback === "computer_act") {
|
|
return {
|
|
url: "",
|
|
title: "",
|
|
tree: "",
|
|
elements: [],
|
|
fallback: "computer_act",
|
|
error: live?.error || DETACHED_MESSAGE,
|
|
};
|
|
}
|
|
const elements = Array.isArray(live.elements) ? live.elements : [];
|
|
return {
|
|
url: typeof live.url === "string" ? live.url : "",
|
|
title: typeof live.title === "string" ? live.title : "",
|
|
tree: typeof live.tree === "string" ? live.tree : formatTree(elements),
|
|
elements,
|
|
...(live.screen ? { screen: live.screen } : {}),
|
|
};
|
|
}
|
|
|
|
async chrome(
|
|
computer: ComputerRef,
|
|
command: "state" | "back" | "forward" | "reload",
|
|
context: AdapterContext,
|
|
) {
|
|
if (this.canUseInProcess(computer)) {
|
|
const session = this.fake.sessions.get(pageBrowserSessionKey(computer, context));
|
|
if (command === "state") return session.chromeState();
|
|
if (command === "back") {
|
|
const moved = session.back();
|
|
if (!moved)
|
|
return { ok: false as const, error: "cannot go back", fallback: "computer_act" as const };
|
|
const state = session.chromeState();
|
|
return { ...state };
|
|
}
|
|
if (command === "forward") {
|
|
const moved = session.forward();
|
|
if (!moved)
|
|
return {
|
|
ok: false as const,
|
|
error: "cannot go forward",
|
|
fallback: "computer_act" as const,
|
|
};
|
|
const state = session.chromeState();
|
|
return { ...state };
|
|
}
|
|
session.reload();
|
|
return session.chromeState();
|
|
}
|
|
const live = await this.runLive(computer, { command }, context);
|
|
if (live?.ok !== true) {
|
|
return {
|
|
ok: false as const,
|
|
error: live?.error || DETACHED_MESSAGE,
|
|
fallback: "computer_act" as const,
|
|
};
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
url: typeof live.url === "string" ? live.url : "",
|
|
title: typeof live.title === "string" ? live.title : "",
|
|
loading: Boolean(live.loading),
|
|
canGoBack: Boolean(live.canGoBack),
|
|
canGoForward: Boolean(live.canGoForward),
|
|
};
|
|
}
|
|
|
|
async act(
|
|
computer: ComputerRef,
|
|
request: BrowserActRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserActResult> {
|
|
if (this.canUseInProcess(computer)) {
|
|
return this.fake.act(computer, request, context);
|
|
}
|
|
const live = await this.runLive(
|
|
computer,
|
|
{ command: "act", actions: request.actions },
|
|
context,
|
|
);
|
|
if (live?.ok !== true || live.fallback === "computer_act") {
|
|
return {
|
|
ok: false,
|
|
completed: typeof live?.completed === "number" ? live.completed : 0,
|
|
uncertain: live?.uncertain ?? true,
|
|
url: typeof live?.url === "string" ? live.url : "",
|
|
title: typeof live?.title === "string" ? live.title : "",
|
|
fallback: "computer_act",
|
|
error: live?.error || DETACHED_MESSAGE,
|
|
};
|
|
}
|
|
const elements = Array.isArray(live.elements) ? live.elements : undefined;
|
|
return {
|
|
ok: true,
|
|
completed: typeof live.completed === "number" ? live.completed : request.actions.length,
|
|
url: typeof live.url === "string" ? live.url : "",
|
|
title: typeof live.title === "string" ? live.title : "",
|
|
tree: typeof live.tree === "string" ? live.tree : elements ? formatTree(elements) : undefined,
|
|
elements,
|
|
...(live.screen ? { screen: live.screen } : {}),
|
|
};
|
|
}
|
|
|
|
private canUseInProcess(computer: ComputerRef): boolean {
|
|
return computer.kind === "fake";
|
|
}
|
|
|
|
private async runLive(
|
|
computer: ComputerRef,
|
|
request: PageBrowserCommand,
|
|
context: AdapterContext,
|
|
): Promise<LivePayload> {
|
|
context.signal.throwIfAborted();
|
|
try {
|
|
const result = this.liveDriver
|
|
? await this.liveDriver(computer, request, context)
|
|
: this.sandbox?.pageBrowser
|
|
? await this.sandbox.pageBrowser(computer, request, context)
|
|
: {
|
|
ok: false,
|
|
error: DETACHED_MESSAGE,
|
|
fallback: "computer_act" as const,
|
|
uncertain: false,
|
|
};
|
|
if (result?.ok === true) {
|
|
if (request.command === "state" || request.command === "reload") {
|
|
if (typeof result.url !== "string" || typeof result.title !== "string") {
|
|
throw new Error("Incomplete page browser response");
|
|
}
|
|
} else if (request.command === "back" || request.command === "forward") {
|
|
/* History commands may return navigation metadata only. */
|
|
} else if (typeof result.url !== "string" || typeof result.title !== "string") {
|
|
throw new Error("Incomplete page browser response");
|
|
}
|
|
if (
|
|
request.command === "snapshot" &&
|
|
(typeof result.tree !== "string" || !Array.isArray(result.elements))
|
|
) {
|
|
throw new Error("Incomplete page snapshot");
|
|
}
|
|
if (
|
|
request.command === "act" &&
|
|
(!Number.isInteger(result.completed) ||
|
|
result.completed !== request.actions.length ||
|
|
result.uncertain)
|
|
) {
|
|
throw new Error("Unconfirmed page actions");
|
|
}
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
context.signal.throwIfAborted();
|
|
return {
|
|
uncertain: request.command === "act",
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
fallback: "computer_act",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatTree(elements: BrowserSnapshotNode[]): string {
|
|
if (elements.length === 0) return "(no interactive elements)";
|
|
return elements
|
|
.map((el) => {
|
|
const value =
|
|
el.value !== undefined && el.value !== "" ? ` value=${JSON.stringify(el.value)}` : "";
|
|
return `- ${el.role} "${el.name}" [${el.ref}]${value}`;
|
|
})
|
|
.join("\n");
|
|
}
|