feat(browser): add native action commands
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserStatus = {
|
||||
@@ -21,11 +22,6 @@ export type BrowserTab = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type BrowserToolResponse = {
|
||||
ok: true;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ScreenshotResult = {
|
||||
ok: true;
|
||||
path: string;
|
||||
@@ -117,74 +113,6 @@ export type SnapshotResult =
|
||||
snapshot: string;
|
||||
};
|
||||
|
||||
function unwrapCause(err: unknown): unknown {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
return cause ?? null;
|
||||
}
|
||||
|
||||
function enhanceBrowserFetchError(
|
||||
url: string,
|
||||
err: unknown,
|
||||
timeoutMs: number,
|
||||
): Error {
|
||||
const cause = unwrapCause(err);
|
||||
const code =
|
||||
(cause && typeof cause === "object" && "code" in cause
|
||||
? String((cause as { code?: unknown }).code ?? "")
|
||||
: "") ||
|
||||
(err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code ?? "")
|
||||
: "");
|
||||
|
||||
const hint =
|
||||
"Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again.";
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
|
||||
);
|
||||
}
|
||||
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
const msg = String(err);
|
||||
if (msg.toLowerCase().includes("abort")) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit);
|
||||
} catch (err) {
|
||||
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
@@ -193,19 +121,27 @@ export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||
}
|
||||
|
||||
export async function browserStatus(baseUrl: string): Promise<BrowserStatus> {
|
||||
return await fetchJson<BrowserStatus>(`${baseUrl}/`, { timeoutMs: 1500 });
|
||||
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/`, {
|
||||
timeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStart(baseUrl: string): Promise<void> {
|
||||
await fetchJson(`${baseUrl}/start`, { method: "POST", timeoutMs: 15000 });
|
||||
await fetchBrowserJson(`${baseUrl}/start`, {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStop(baseUrl: string): Promise<void> {
|
||||
await fetchJson(`${baseUrl}/stop`, { method: "POST", timeoutMs: 15000 });
|
||||
await fetchBrowserJson(`${baseUrl}/stop`, {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
||||
const res = await fetchJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
`${baseUrl}/tabs`,
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
@@ -216,7 +152,7 @@ export async function browserOpenTab(
|
||||
baseUrl: string,
|
||||
url: string,
|
||||
): Promise<BrowserTab> {
|
||||
return await fetchJson<BrowserTab>(`${baseUrl}/tabs/open`, {
|
||||
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
@@ -228,7 +164,7 @@ export async function browserFocusTab(
|
||||
baseUrl: string,
|
||||
targetId: string,
|
||||
): Promise<void> {
|
||||
await fetchJson(`${baseUrl}/tabs/focus`, {
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/focus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId }),
|
||||
@@ -240,7 +176,7 @@ export async function browserCloseTab(
|
||||
baseUrl: string,
|
||||
targetId: string,
|
||||
): Promise<void> {
|
||||
await fetchJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
@@ -257,9 +193,12 @@ export async function browserScreenshot(
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (opts.fullPage) q.set("fullPage", "true");
|
||||
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||
return await fetchJson<ScreenshotResult>(`${baseUrl}/screenshot${suffix}`, {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<ScreenshotResult>(
|
||||
`${baseUrl}/screenshot${suffix}`,
|
||||
{
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserEval(
|
||||
@@ -270,7 +209,7 @@ export async function browserEval(
|
||||
awaitPromise?: boolean;
|
||||
},
|
||||
): Promise<EvalResult> {
|
||||
return await fetchJson<EvalResult>(`${baseUrl}/eval`, {
|
||||
return await fetchBrowserJson<EvalResult>(`${baseUrl}/eval`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -294,9 +233,12 @@ export async function browserQuery(
|
||||
q.set("selector", opts.selector);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
|
||||
return await fetchJson<QueryResult>(`${baseUrl}/query?${q.toString()}`, {
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
return await fetchBrowserJson<QueryResult>(
|
||||
`${baseUrl}/query?${q.toString()}`,
|
||||
{
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserDom(
|
||||
@@ -314,7 +256,7 @@ export async function browserDom(
|
||||
if (typeof opts.maxChars === "number")
|
||||
q.set("maxChars", String(opts.maxChars));
|
||||
if (opts.selector) q.set("selector", opts.selector);
|
||||
return await fetchJson<DomResult>(`${baseUrl}/dom?${q.toString()}`, {
|
||||
return await fetchBrowserJson<DomResult>(`${baseUrl}/dom?${q.toString()}`, {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
@@ -331,7 +273,7 @@ export async function browserSnapshot(
|
||||
q.set("format", opts.format);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
|
||||
return await fetchJson<SnapshotResult>(
|
||||
return await fetchBrowserJson<SnapshotResult>(
|
||||
`${baseUrl}/snapshot?${q.toString()}`,
|
||||
{
|
||||
timeoutMs: 20000,
|
||||
@@ -346,7 +288,7 @@ export async function browserClickRef(
|
||||
targetId?: string;
|
||||
},
|
||||
): Promise<{ ok: true; targetId: string; url: string }> {
|
||||
return await fetchJson<{ ok: true; targetId: string; url: string }>(
|
||||
return await fetchBrowserJson<{ ok: true; targetId: string; url: string }>(
|
||||
`${baseUrl}/click`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -360,22 +302,4 @@ export async function browserClickRef(
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserTool(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
targetId?: string;
|
||||
},
|
||||
): Promise<BrowserToolResponse> {
|
||||
return await fetchJson<BrowserToolResponse>(`${baseUrl}/tool`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
args: opts.args ?? {},
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
// Actions beyond the basic read-only commands live in client-actions.ts.
|
||||
|
||||
Reference in New Issue
Block a user