fix environments demo (#696)
* fix * fixes * fixes * update docs * fixes * fixes * sandbox tokens * better logging
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# --- Vercel Sandbox ---
|
||||
# Snapshot ID with agent-browser + Chromium pre-installed (optional, speeds up startup)
|
||||
# Create one with: npx tsx scripts/create-snapshot.ts
|
||||
# AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
|
||||
# --- Sandbox Authentication ---
|
||||
# On Vercel deployments, OIDC auth is automatic. For local development,
|
||||
# provide explicit credentials:
|
||||
# VERCEL_TOKEN=
|
||||
# VERCEL_TEAM_ID=
|
||||
# VERCEL_PROJECT_ID=
|
||||
|
||||
# --- Rate Limiting (Upstash / Vercel KV) ---
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
|
||||
# Optional: override default limits
|
||||
# RATE_LIMIT_PER_MINUTE=10
|
||||
# RATE_LIMIT_PER_DAY=100
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
@@ -0,0 +1,54 @@
|
||||
# agent-browser Environments
|
||||
|
||||
A demo of agent-browser running in a Vercel Sandbox. Enter a URL and take a screenshot or accessibility snapshot.
|
||||
|
||||
## How It Works
|
||||
|
||||
The app runs agent-browser + Chrome inside an ephemeral Vercel Sandbox microVM. A Linux VM spins up on demand, executes agent-browser commands, and shuts down. No binary size limits, no Chromium bundling complexity.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
cd examples/environments
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Sandbox Snapshots
|
||||
|
||||
Without optimization, each Sandbox run installs agent-browser + Chromium from scratch (~30s). A **sandbox snapshot** is a saved VM image with everything pre-installed -- the sandbox boots from the image instead of installing, bringing startup down to sub-second. (This is unrelated to agent-browser's *accessibility snapshot* feature, which dumps a page's accessibility tree.)
|
||||
|
||||
Create a sandbox snapshot by running the helper script once:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Add the ID to your Vercel project environment variables or `.env.local`. Recommended for production.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | Sandbox snapshot ID for sub-second startup (see above) |
|
||||
| `KV_REST_API_URL` | Upstash Redis URL for rate limiting (optional) |
|
||||
| `KV_REST_API_TOKEN` | Upstash Redis token for rate limiting (optional) |
|
||||
| `RATE_LIMIT_PER_MINUTE` | Max requests per minute per IP (default: 10) |
|
||||
| `RATE_LIMIT_PER_DAY` | Max requests per day per IP (default: 100) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
examples/environments/
|
||||
app/
|
||||
page.tsx # Demo UI
|
||||
actions/browse.ts # Server actions
|
||||
api/browse/route.ts # API route for programmatic access
|
||||
lib/
|
||||
agent-browser-sandbox.ts # Vercel Sandbox client
|
||||
constants.ts # Allowed URLs
|
||||
rate-limit.ts # Upstash rate limiting
|
||||
scripts/
|
||||
create-snapshot.ts # Create sandbox snapshot
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
"use server";
|
||||
|
||||
export type EnvStatus = {
|
||||
sandbox: {
|
||||
hasSnapshot: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getEnvStatus(): Promise<EnvStatus> {
|
||||
return {
|
||||
sandbox: {
|
||||
hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as sandbox from "@/lib/agent-browser-sandbox";
|
||||
import type { StepEvent } from "@/lib/agent-browser-sandbox";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const ip =
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const minute = await minuteRateLimit.limit(ip);
|
||||
if (!minute.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please wait a moment before trying again." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const daily = await dailyRateLimit.limit(ip);
|
||||
if (!daily.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Daily limit reached. Please try again tomorrow." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { url, action } = body;
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(ALLOWED_URLS as readonly string[]).includes(url)) {
|
||||
return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action !== "screenshot" && action !== "snapshot") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide 'action' as 'screenshot' or 'snapshot'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
};
|
||||
|
||||
const onStep = (step: StepEvent) => {
|
||||
send("step", step);
|
||||
};
|
||||
|
||||
try {
|
||||
if (action === "screenshot") {
|
||||
const result = await sandbox.screenshotUrl(url, {
|
||||
fullPage: body.fullPage,
|
||||
onStep,
|
||||
});
|
||||
send("result", { ok: true, ...result });
|
||||
} else {
|
||||
const result = await sandbox.snapshotUrl(url, {
|
||||
interactive: true,
|
||||
compact: true,
|
||||
onStep,
|
||||
});
|
||||
send("result", { ok: true, ...result });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
send("result", { ok: false, error: message });
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,139 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-surface: #fafafa;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--surface: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--surface: oklch(0.205 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular",
|
||||
"Roboto Mono", monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "agent-browser Environments",
|
||||
description: "Run agent-browser in different compute environments",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
||||
<body className="min-h-screen font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { getEnvStatus } from "./actions/browse";
|
||||
import type { EnvStatus } from "./actions/browse";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2, Monitor, CircleX, Sun, Moon, Check } from "lucide-react";
|
||||
|
||||
const MOBILE_QUERY = "(max-width: 767px)";
|
||||
const subscribe = (cb: () => void) => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
mql.addEventListener("change", cb);
|
||||
return () => mql.removeEventListener("change", cb);
|
||||
};
|
||||
const getMobileSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
function useIsMobile() {
|
||||
return useSyncExternalStore(subscribe, getMobileSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setThemeState] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
const initial =
|
||||
stored === "dark" ||
|
||||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "dark"
|
||||
: "light";
|
||||
setThemeState(initial);
|
||||
document.documentElement.classList.toggle("dark", initial === "dark");
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
setThemeState(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
localStorage.setItem("theme", next);
|
||||
};
|
||||
|
||||
return { theme, toggle };
|
||||
}
|
||||
|
||||
type Action = "screenshot" | "snapshot";
|
||||
|
||||
type StepInfo = {
|
||||
step: string;
|
||||
status: "running" | "done" | "error";
|
||||
elapsed?: number;
|
||||
};
|
||||
|
||||
type BrowseResult = {
|
||||
ok: boolean;
|
||||
screenshot?: string;
|
||||
snapshot?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function formatError(raw: string): string {
|
||||
let cleaned = raw.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const match = cleaned.match(/(?:error|Error)[:\s]*(.{1,200})/);
|
||||
if (match) cleaned = match[1].trim();
|
||||
if (cleaned.length > 300) cleaned = cleaned.slice(0, 300) + "...";
|
||||
return cleaned || raw.slice(0, 300);
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-input bg-muted p-0.5 w-full">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`
|
||||
flex-1 px-3 py-1.5 text-[13px] font-medium rounded-md transition-all cursor-pointer
|
||||
${
|
||||
value === opt.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIndicator({ step }: { step: StepInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="size-4 flex items-center justify-center shrink-0">
|
||||
{step.status === "running" ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : step.status === "done" ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<CircleX className="size-3.5 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[13px] ${
|
||||
step.status === "running"
|
||||
? "text-foreground"
|
||||
: step.status === "done"
|
||||
? "text-muted-foreground"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{step.step}
|
||||
</span>
|
||||
{step.elapsed != null && step.status !== "running" && (
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums ml-auto">
|
||||
{(step.elapsed / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDisplay({ error }: { error: string }) {
|
||||
const isHtml = /<[a-z][\s\S]*>/i.test(error);
|
||||
const message = isHtml ? formatError(error) : error;
|
||||
const showRaw = isHtml && error.length > 100;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl space-y-0">
|
||||
<Alert variant="destructive">
|
||||
<CircleX className="size-4" />
|
||||
<AlertTitle>Request failed</AlertTitle>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
{showRaw && (
|
||||
<details className="border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
<summary className="px-4 py-2 text-[11px] font-medium text-muted-foreground cursor-pointer hover:bg-muted transition-colors">
|
||||
Show raw response
|
||||
</summary>
|
||||
<pre className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground font-mono overflow-auto max-h-[200px] bg-muted/50">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function streamBrowse(
|
||||
url: string,
|
||||
action: Action,
|
||||
onStep: (step: StepInfo) => void,
|
||||
): Promise<BrowseResult> {
|
||||
const res = await fetch("/api/browse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, action }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, error: body?.error || `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return { ok: false, error: "No response stream" };
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: BrowseResult = { ok: false, error: "No result received" };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() || "";
|
||||
|
||||
for (const part of parts) {
|
||||
const eventMatch = part.match(/^event: (\w+)\ndata: ([\s\S]+)$/);
|
||||
if (!eventMatch) continue;
|
||||
|
||||
const [, event, data] = eventMatch;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (event === "step") {
|
||||
onStep(parsed as StepInfo);
|
||||
} else if (event === "result") {
|
||||
result = parsed as BrowseResult;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<Action>("screenshot");
|
||||
const [result, setResult] = useState<BrowseResult | null>(null);
|
||||
const [steps, setSteps] = useState<StepInfo[]>([]);
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null);
|
||||
const stepsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getEnvStatus().then(setEnvStatus);
|
||||
}, []);
|
||||
|
||||
function clearResults() {
|
||||
setResult(null);
|
||||
setSteps([]);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setSteps([]);
|
||||
|
||||
try {
|
||||
const browseResult = await streamBrowse(url, action, (step) => {
|
||||
setSteps((prev) => {
|
||||
const existing = prev.findIndex((s) => s.step === step.step);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = step;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, step];
|
||||
});
|
||||
});
|
||||
setResult(browseResult);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setResult({ ok: false, error: message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
stepsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [steps]);
|
||||
|
||||
const controlsForm = (
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="url-select"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Select
|
||||
value={url}
|
||||
onValueChange={(v) => {
|
||||
if (v) setUrl(v);
|
||||
clearResults();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="url-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALLOWED_URLS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u.replace("https://", "")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</Label>
|
||||
<SegmentedControl<Action>
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
clearResults();
|
||||
}}
|
||||
options={[
|
||||
{ value: "screenshot", label: "Screenshot" },
|
||||
{ value: "snapshot", label: "Snapshot" },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{action === "screenshot"
|
||||
? "Captures a full-page PNG image"
|
||||
: "Returns the accessibility tree"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{envStatus && !envStatus.sandbox.hasSnapshot && (
|
||||
<Alert>
|
||||
<AlertTitle className="text-[12px]">
|
||||
Sandbox snapshot not configured
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[11px]">
|
||||
Without a sandbox snapshot, the VM installs agent-browser +
|
||||
Chromium on every request (~30s). Create one with{" "}
|
||||
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
</code>{" "}
|
||||
and set{" "}
|
||||
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
|
||||
AGENT_BROWSER_SNAPSHOT_ID
|
||||
</code>{" "}
|
||||
for sub-second startup.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full" size="lg">
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
{loading ? "Running..." : "Run"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const showSteps = loading || (steps.length > 0 && !result);
|
||||
const hasResult = result && !loading;
|
||||
|
||||
const resultContent = showSteps ? (
|
||||
<div className="p-6 lg:p-10">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
<div ref={stepsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasResult ? (
|
||||
<div className="flex flex-col items-center p-6 lg:p-10">
|
||||
{result.ok && result.screenshot && (
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold truncate mr-3">
|
||||
{result.title}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[11px] shrink-0"
|
||||
>
|
||||
screenshot
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={`data:image/png;base64,${result.screenshot}`}
|
||||
alt={result.title}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
<details className="mt-4">
|
||||
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
|
||||
Show steps ({steps.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.ok && result.snapshot && (
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold truncate mr-3">
|
||||
{result.title}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[11px] shrink-0"
|
||||
>
|
||||
snapshot
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="bg-card rounded-xl border border-border p-5 overflow-auto text-[13px] leading-relaxed font-mono max-h-[calc(100vh-12rem)]">
|
||||
{result.snapshot}
|
||||
</pre>
|
||||
<details className="mt-4">
|
||||
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
|
||||
Show steps ({steps.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.ok && (
|
||||
<div className="w-full max-w-2xl space-y-4">
|
||||
<ErrorDisplay error={result.error ?? "Unknown error"} />
|
||||
{steps.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Monitor className="size-12 mb-4 opacity-30" strokeWidth={1} />
|
||||
<p className="text-sm font-medium mb-1">No result yet</p>
|
||||
<p className="text-[13px]">Pick a URL and click Run</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="border-b border-border shrink-0">
|
||||
<div className="px-4 md:px-6 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
agent-browser
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="size-8 inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments&env=AGENT_BROWSER_SNAPSHOT_ID&envDescription=Sandbox%20snapshot%20ID%20for%20fast%20startup.%20Create%20with%20npx%20tsx%20scripts%2Fcreate-snapshot.ts&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments%23sandbox-snapshots&project-name=agent-browser-environments&repository-name=agent-browser-environments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://vercel.com/button"
|
||||
alt="Deploy with Vercel"
|
||||
className="h-8"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="border-b border-border">{controlsForm}</div>
|
||||
<div className="bg-surface">{resultContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||
<ResizablePanel defaultSize="30%" minSize="20%" maxSize="50%">
|
||||
<aside className="h-full overflow-y-auto">{controlsForm}</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize="70%">
|
||||
<main className="h-full overflow-auto bg-surface">
|
||||
{resultContent}
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 min-w-8 px-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
|
||||
lg: "h-9 min-w-9 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Run agent-browser inside a Vercel Sandbox.
|
||||
*
|
||||
* No external server needed -- a Linux microVM spins up on demand,
|
||||
* runs agent-browser + headless Chrome, and shuts down when done.
|
||||
*
|
||||
* For production, create a snapshot with agent-browser and Chromium
|
||||
* pre-installed so startup is sub-second instead of ~30s.
|
||||
*/
|
||||
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
export type SandboxResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type StepEvent = {
|
||||
step: string;
|
||||
status: "running" | "done" | "error";
|
||||
elapsed?: number;
|
||||
};
|
||||
|
||||
export type OnStep = (event: StepEvent) => void;
|
||||
|
||||
const SNAPSHOT_ID = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
const CHROMIUM_SYSTEM_DEPS = [
|
||||
"nss",
|
||||
"nspr",
|
||||
"libxkbcommon",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"at-spi2-core",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"libXfixes",
|
||||
"libXcursor",
|
||||
"libXi",
|
||||
"libXtst",
|
||||
"libXScrnSaver",
|
||||
"libXext",
|
||||
"mesa-libgbm",
|
||||
"libdrm",
|
||||
"mesa-libGL",
|
||||
"mesa-libEGL",
|
||||
"cups-libs",
|
||||
"alsa-lib",
|
||||
"pango",
|
||||
"cairo",
|
||||
"gtk3",
|
||||
"dbus-libs",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns credentials to spread into Sandbox.create() calls.
|
||||
* When explicit env vars are set they take precedence; otherwise returns
|
||||
* an empty object so the SDK falls back to VERCEL_OIDC_TOKEN automatically.
|
||||
*/
|
||||
export function getSandboxCredentials():
|
||||
| { token: string; teamId: string; projectId: string }
|
||||
| Record<string, never> {
|
||||
if (
|
||||
process.env.VERCEL_TOKEN &&
|
||||
process.env.VERCEL_TEAM_ID &&
|
||||
process.env.VERCEL_PROJECT_ID
|
||||
) {
|
||||
return {
|
||||
token: process.env.VERCEL_TOKEN,
|
||||
teamId: process.env.VERCEL_TEAM_ID,
|
||||
projectId: process.env.VERCEL_PROJECT_ID,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function runStep<T>(
|
||||
step: string,
|
||||
fn: () => Promise<T>,
|
||||
onStep?: OnStep,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
onStep?.({ step, status: "running" });
|
||||
try {
|
||||
const result = await fn();
|
||||
onStep?.({ step, status: "done", elapsed: Date.now() - start });
|
||||
return result;
|
||||
} catch (err) {
|
||||
onStep?.({ step, status: "error", elapsed: Date.now() - start });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install system dependencies + agent-browser + Chromium into a fresh sandbox.
|
||||
* The sandbox base image is Amazon Linux (dnf).
|
||||
*/
|
||||
async function bootstrapSandbox(
|
||||
sandbox: InstanceType<typeof Sandbox>,
|
||||
onStep?: OnStep,
|
||||
): Promise<void> {
|
||||
await runStep("Installing system dependencies", async () => {
|
||||
await sandbox.runCommand("sh", [
|
||||
"-c",
|
||||
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
|
||||
]);
|
||||
}, onStep);
|
||||
|
||||
await runStep("Installing agent-browser", async () => {
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
}, onStep);
|
||||
}
|
||||
|
||||
async function createSandbox(
|
||||
onStep?: OnStep,
|
||||
): Promise<InstanceType<typeof Sandbox>> {
|
||||
const credentials = getSandboxCredentials();
|
||||
|
||||
return runStep(
|
||||
SNAPSHOT_ID ? "Booting sandbox from snapshot" : "Creating sandbox",
|
||||
async () => {
|
||||
if (SNAPSHOT_ID) {
|
||||
return Sandbox.create({
|
||||
...credentials,
|
||||
source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
const sb = await Sandbox.create({
|
||||
...credentials,
|
||||
runtime: "node24",
|
||||
timeout: 120_000,
|
||||
});
|
||||
await bootstrapSandbox(sb, onStep);
|
||||
return sb;
|
||||
},
|
||||
onStep,
|
||||
);
|
||||
}
|
||||
|
||||
async function exec(
|
||||
sandbox: InstanceType<typeof Sandbox>,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
onStep?: OnStep,
|
||||
stepLabel?: string,
|
||||
): Promise<SandboxResult> {
|
||||
const label = stepLabel || `${cmd} ${args.join(" ")}`;
|
||||
|
||||
return runStep(label, async () => {
|
||||
const result = await sandbox.runCommand(cmd, args);
|
||||
const stdout = await result.stdout();
|
||||
const stderr = await result.stderr();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command "${cmd} ${args.join(" ")}" failed (exit ${result.exitCode}): ${stderr || stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { exitCode: result.exitCode, stdout, stderr };
|
||||
}, onStep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot a URL using agent-browser inside a Vercel Sandbox.
|
||||
* Returns base64-encoded PNG.
|
||||
*/
|
||||
export async function screenshotUrl(
|
||||
url: string,
|
||||
opts: { fullPage?: boolean; onStep?: OnStep } = {},
|
||||
): Promise<{ screenshot: string; title: string }> {
|
||||
const { onStep } = opts;
|
||||
const sandbox = await createSandbox(onStep);
|
||||
|
||||
try {
|
||||
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
||||
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
||||
|
||||
const titleResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
["get", "title", "--json"],
|
||||
onStep,
|
||||
"Getting page title",
|
||||
);
|
||||
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
|
||||
|
||||
const screenshotArgs = ["screenshot", "--json"];
|
||||
if (opts.fullPage) screenshotArgs.push("--full");
|
||||
const ssResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
screenshotArgs,
|
||||
onStep,
|
||||
"Taking screenshot",
|
||||
);
|
||||
const ssData = tryParseJson(ssResult.stdout)?.data;
|
||||
const screenshotPath = ssData?.path;
|
||||
|
||||
if (!screenshotPath) {
|
||||
throw new Error(
|
||||
`Screenshot returned no file path. Raw output: ${ssResult.stdout.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const b64Result = await exec(
|
||||
sandbox,
|
||||
"base64",
|
||||
["-w", "0", screenshotPath],
|
||||
onStep,
|
||||
"Encoding screenshot",
|
||||
);
|
||||
const screenshot = b64Result.stdout.trim();
|
||||
|
||||
if (!screenshot) {
|
||||
throw new Error("Failed to read screenshot file from sandbox");
|
||||
}
|
||||
|
||||
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
||||
|
||||
return { screenshot, title };
|
||||
} finally {
|
||||
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a URL (accessibility tree) using agent-browser inside a Vercel Sandbox.
|
||||
*/
|
||||
export async function snapshotUrl(
|
||||
url: string,
|
||||
opts: { interactive?: boolean; compact?: boolean; onStep?: OnStep } = {},
|
||||
): Promise<{ snapshot: string; title: string }> {
|
||||
const { onStep } = opts;
|
||||
const sandbox = await createSandbox(onStep);
|
||||
|
||||
try {
|
||||
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
||||
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
||||
|
||||
const titleResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
["get", "title", "--json"],
|
||||
onStep,
|
||||
"Getting page title",
|
||||
);
|
||||
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
|
||||
|
||||
const snapshotArgs = ["snapshot"];
|
||||
if (opts.interactive) snapshotArgs.push("-i");
|
||||
if (opts.compact) snapshotArgs.push("-c");
|
||||
const snapResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
snapshotArgs,
|
||||
onStep,
|
||||
"Taking accessibility snapshot",
|
||||
);
|
||||
|
||||
if (!snapResult.stdout.trim()) {
|
||||
throw new Error("Snapshot returned empty data");
|
||||
}
|
||||
|
||||
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
||||
|
||||
return { snapshot: snapResult.stdout, title };
|
||||
} finally {
|
||||
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run arbitrary agent-browser commands inside a Vercel Sandbox.
|
||||
* Each command is a string array like ["open", "https://example.com"].
|
||||
*/
|
||||
export async function runCommands(
|
||||
commands: string[][],
|
||||
): Promise<SandboxResult[]> {
|
||||
const sandbox = await createSandbox();
|
||||
|
||||
try {
|
||||
const results: SandboxResult[] = [];
|
||||
for (const args of commands) {
|
||||
const result = await exec(sandbox, "agent-browser", args);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await sandbox.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reusable snapshot with agent-browser + Chromium pre-installed.
|
||||
* Run this once, then set AGENT_BROWSER_SNAPSHOT_ID for fast startup.
|
||||
*/
|
||||
export async function createSnapshot(): Promise<string> {
|
||||
const sandbox = await Sandbox.create({
|
||||
...getSandboxCredentials(),
|
||||
runtime: "node24",
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
await bootstrapSandbox(sandbox);
|
||||
|
||||
const snapshot = await sandbox.snapshot();
|
||||
return snapshot.snapshotId;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function tryParseJson(str: string): any {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const ALLOWED_URLS = [
|
||||
"https://example.com",
|
||||
"https://ai-sdk.dev",
|
||||
"https://useworkflow.dev",
|
||||
"https://vercel.com",
|
||||
] as const;
|
||||
|
||||
export type AllowedUrl = (typeof ALLOWED_URLS)[number];
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
let _minuteRateLimit: Ratelimit | null = null;
|
||||
let _dailyRateLimit: Ratelimit | null = null;
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
const url = process.env.KV_REST_API_URL;
|
||||
const token = process.env.KV_REST_API_TOKEN;
|
||||
|
||||
if (!url || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Redis({ url, token });
|
||||
}
|
||||
|
||||
const noopRateLimiter = {
|
||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||
};
|
||||
|
||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||
|
||||
export const minuteRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_minuteRateLimit) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||
prefix: "ratelimit:minute",
|
||||
});
|
||||
}
|
||||
return _minuteRateLimit.limit(identifier);
|
||||
},
|
||||
};
|
||||
|
||||
export const dailyRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_dailyRateLimit) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||
prefix: "ratelimit:daily",
|
||||
});
|
||||
}
|
||||
return _dailyRateLimit.limit(identifier);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
outputFileTracingRoot: path.resolve(import.meta.dirname, "../../"),
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "agent-browser-environments",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.4",
|
||||
"@vercel/sandbox": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.6",
|
||||
"postcss": "^8.5.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.7.2",
|
||||
"shadcn": "^4.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
Generated
+3995
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Create a Vercel Sandbox snapshot with agent-browser + Chromium pre-installed.
|
||||
*
|
||||
* Run once: npx tsx scripts/create-snapshot.ts
|
||||
* Then set: AGENT_BROWSER_SNAPSHOT_ID=<output id>
|
||||
*
|
||||
* Authentication (one of):
|
||||
* - VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID
|
||||
* - VERCEL_OIDC_TOKEN (automatically available on Vercel deployments)
|
||||
*
|
||||
* This makes sandbox creation sub-second instead of ~30s.
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import { createSnapshot, getSandboxCredentials } from "../lib/agent-browser-sandbox";
|
||||
|
||||
const hasExplicitCreds = !!(
|
||||
process.env.VERCEL_TOKEN &&
|
||||
process.env.VERCEL_TEAM_ID &&
|
||||
process.env.VERCEL_PROJECT_ID
|
||||
);
|
||||
const hasOidc = !!process.env.VERCEL_OIDC_TOKEN;
|
||||
|
||||
if (!hasExplicitCreds && !hasOidc) {
|
||||
console.error(
|
||||
"Missing sandbox credentials. Provide either:\n" +
|
||||
" 1. VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID\n" +
|
||||
" 2. VERCEL_OIDC_TOKEN",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const creds = getSandboxCredentials();
|
||||
console.log(
|
||||
creds.token
|
||||
? `Authenticating with explicit credentials (team: ${creds.teamId})`
|
||||
: "Authenticating via VERCEL_OIDC_TOKEN",
|
||||
);
|
||||
|
||||
async function main() {
|
||||
console.log("Creating Vercel Sandbox with agent-browser + Chromium...");
|
||||
console.log("This takes ~30-60 seconds on first run.\n");
|
||||
|
||||
const snapshotId = await createSnapshot();
|
||||
|
||||
console.log("\nSnapshot created successfully!");
|
||||
console.log(`\n AGENT_BROWSER_SNAPSHOT_ID=${snapshotId}\n`);
|
||||
console.log("Add this to your .env.local or Vercel environment variables.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to create snapshot:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"server"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user