From cc3c70dc86f9a84841c03c03b2724cb25668f041 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 9 Mar 2026 15:24:44 -0500 Subject: [PATCH] next.js example (#694) * next.js guide * better * shadcn * fixes * fix: correct screenshot test assertion to check path instead of base64 The daemon returns { path: savePath } for screenshot commands, not base64. * fix: cross-platform Chrome detection and gitignore hardening - Replace hardcoded macOS Chrome path with findLocalChrome() that searches common paths on macOS, Linux, and WSL, with a clear error message when no Chrome is found. - Add .env and .env*.local to .gitignore to prevent accidental secret commits. * fix: correct Vercel deploy button repo URL to vercel-labs/agent-browser * clean up * demo * next page --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com> --- README.md | 35 +- docs/src/app/next/page.mdx | 207 + docs/src/components/header.tsx | 2 +- docs/src/lib/docs-navigation.ts | 1 + docs/src/lib/page-titles.ts | 1 + examples/demo/.env.example | 9 + examples/demo/.gitignore | 5 + examples/demo/README.md | 53 + examples/demo/app/actions/browse.ts | 98 + examples/demo/app/api/browse/route.ts | 44 + examples/demo/app/globals.css | 139 + examples/demo/app/layout.tsx | 21 + examples/demo/app/page.tsx | 468 ++ examples/demo/components.json | 25 + examples/demo/components/ui/alert.tsx | 76 + examples/demo/components/ui/badge.tsx | 52 + examples/demo/components/ui/button.tsx | 60 + examples/demo/components/ui/input.tsx | 20 + examples/demo/components/ui/label.tsx | 20 + examples/demo/components/ui/resizable.tsx | 50 + examples/demo/components/ui/separator.tsx | 25 + examples/demo/components/ui/toggle-group.tsx | 89 + examples/demo/components/ui/toggle.tsx | 44 + examples/demo/lib/agent-browser-sandbox.ts | 164 + examples/demo/lib/agent-browser.ts | 105 + examples/demo/lib/utils.ts | 6 + examples/demo/next-env.d.ts | 6 + examples/demo/next.config.ts | 8 + examples/demo/package.json | 37 + examples/demo/pnpm-lock.yaml | 4411 ++++++++++++++++++ examples/demo/postcss.config.mjs | 7 + examples/demo/scripts/create-snapshot.ts | 26 + examples/demo/tsconfig.json | 42 + skills/next/SKILL.md | 189 + skills/vercel-sandbox/SKILL.md | 221 + 35 files changed, 6764 insertions(+), 2 deletions(-) create mode 100644 docs/src/app/next/page.mdx create mode 100644 examples/demo/.env.example create mode 100644 examples/demo/.gitignore create mode 100644 examples/demo/README.md create mode 100644 examples/demo/app/actions/browse.ts create mode 100644 examples/demo/app/api/browse/route.ts create mode 100644 examples/demo/app/globals.css create mode 100644 examples/demo/app/layout.tsx create mode 100644 examples/demo/app/page.tsx create mode 100644 examples/demo/components.json create mode 100644 examples/demo/components/ui/alert.tsx create mode 100644 examples/demo/components/ui/badge.tsx create mode 100644 examples/demo/components/ui/button.tsx create mode 100644 examples/demo/components/ui/input.tsx create mode 100644 examples/demo/components/ui/label.tsx create mode 100644 examples/demo/components/ui/resizable.tsx create mode 100644 examples/demo/components/ui/separator.tsx create mode 100644 examples/demo/components/ui/toggle-group.tsx create mode 100644 examples/demo/components/ui/toggle.tsx create mode 100644 examples/demo/lib/agent-browser-sandbox.ts create mode 100644 examples/demo/lib/agent-browser.ts create mode 100644 examples/demo/lib/utils.ts create mode 100644 examples/demo/next-env.d.ts create mode 100644 examples/demo/next.config.ts create mode 100644 examples/demo/package.json create mode 100644 examples/demo/pnpm-lock.yaml create mode 100644 examples/demo/postcss.config.mjs create mode 100644 examples/demo/scripts/create-snapshot.ts create mode 100644 examples/demo/tsconfig.json create mode 100644 skills/next/SKILL.md create mode 100644 skills/vercel-sandbox/SKILL.md diff --git a/README.md b/README.md index eddae0f..2ffd043 100644 --- a/README.md +++ b/README.md @@ -713,7 +713,40 @@ agent-browser --executable-path /path/to/chromium open example.com AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com ``` -### Serverless Example (Vercel/AWS Lambda) +### Serverless (Vercel) + +Two patterns for using agent-browser from Next.js server actions on Vercel: + +**Vercel Sandbox** -- run agent-browser + Chrome in an ephemeral microVM. No external server needed: + +```typescript +import { Sandbox } from "@vercel/sandbox"; + +const sandbox = await Sandbox.create({ runtime: "node24" }); +await sandbox.runCommand("agent-browser", ["open", "https://example.com"]); +const result = await sandbox.runCommand("agent-browser", ["screenshot", "--json"]); +await sandbox.stop(); +``` + +**External API server** -- host agent-browser on a server, call it from Vercel via HTTP: + +```typescript +const res = await fetch(`${process.env.AGENT_BROWSER_API_URL}/api/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + commands: [ + { action: "launch", headless: true }, + { action: "navigate", url: "https://example.com" }, + { action: "screenshot" }, + ], + }), +}); +``` + +See the [demo app](examples/demo/) for a visual demo of agent-browser's core capabilities across different compute environments. + +### Serverless (AWS Lambda) ```typescript import chromium from '@sparticuz/chromium'; diff --git a/docs/src/app/next/page.mdx b/docs/src/app/next/page.mdx new file mode 100644 index 0000000..bf974ad --- /dev/null +++ b/docs/src/app/next/page.mdx @@ -0,0 +1,207 @@ +import { pageMetadata } from "@/lib/page-metadata" + +export const metadata = pageMetadata("next") + +# Next.js + Vercel + +Two patterns for running agent-browser from Next.js on Vercel. + +## Pattern 1: Serverless Function + +Run `@sparticuz/chromium` + `puppeteer-core` directly inside a Vercel +serverless function. No external server needed. + +### Setup + +```bash +pnpm add @sparticuz/chromium puppeteer-core +``` + +### Browser launcher + +```ts +import puppeteer from "puppeteer-core"; +import chromium from "@sparticuz/chromium"; +import fs from "node:fs"; + +const CHROME_PATHS = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +]; + +function findLocalChrome(): string { + for (const p of CHROME_PATHS) { + if (fs.existsSync(p)) return p; + } + throw new Error("Chrome not found. Set CHROMIUM_PATH."); +} + +async function launchBrowser() { + const isLambda = + !!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME; + + return puppeteer.launch({ + args: isLambda ? chromium.args : ["--no-sandbox"], + executablePath: isLambda + ? await chromium.executablePath() + : process.env.CHROMIUM_PATH || findLocalChrome(), + headless: true, + defaultViewport: { width: 1280, height: 720 }, + }); +} +``` + +On Vercel, `@sparticuz/chromium` provides the binary automatically. +Locally, the launcher finds your system Chrome or uses `CHROMIUM_PATH`. + +### Server action + +```ts +"use server"; + +export async function screenshotUrl(url: string) { + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 }); + const title = await page.title(); + const screenshot = await page.screenshot({ encoding: "base64" }); + return { ok: true, title, screenshot }; + } finally { + await browser.close(); + } +} +``` + +### Next.js config + +```ts +// next.config.ts +const nextConfig = { + serverExternalPackages: ["@sparticuz/chromium"], +}; +export default nextConfig; +``` + +## Pattern 2: Vercel Sandbox + +A Linux microVM spins up on demand, runs agent-browser + Chrome, and +shuts down. No binary size limits. Best for multi-step workflows or +when you need full Chrome capabilities. + +### Server action + +```ts +"use server"; +import { Sandbox } from "@vercel/sandbox"; + +export async function screenshotUrl(url: string) { + const snapshotId = process.env.AGENT_BROWSER_SNAPSHOT_ID; + + const sandbox = snapshotId + ? await Sandbox.create({ + source: { type: "snapshot", snapshotId }, + timeout: 120_000, + }) + : await Sandbox.create({ runtime: "node24", timeout: 120_000 }); + + try { + if (!snapshotId) { + await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]); + await sandbox.runCommand("npx", ["agent-browser", "install"]); + } + + await sandbox.runCommand("agent-browser", ["open", url]); + + const result = await sandbox.runCommand("agent-browser", [ + "screenshot", "--json", + ]); + const stdout = await result.stdout(); + const data = JSON.parse(stdout); + + await sandbox.runCommand("agent-browser", ["close"]); + return { ok: true, screenshot: data.data.base64 }; + } finally { + await sandbox.stop(); + } +} +``` + +### Optimize with snapshots + +Installing agent-browser + Chromium takes ~30 seconds. Create a +snapshot to make sandbox creation sub-second: + +```bash +npx tsx scripts/create-snapshot.ts +# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx +``` + +Add the snapshot ID to your Vercel environment variables or `.env.local`. + +## Scheduled workflows (cron) + +For recurring tasks like daily monitoring, use Vercel Cron Jobs with +either pattern: + +```ts +// app/api/cron/monitor/route.ts +export async function GET() { + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + await page.goto("https://example.com/pricing", { + waitUntil: "networkidle2", + }); + const snapshot = await page.accessibility.snapshot(); + // Process results, send alerts, store in database... + return Response.json({ ok: true, snapshot }); + } finally { + await browser.close(); + } +} +``` + +```json +// vercel.json +{ + "crons": [ + { "path": "/api/cron/monitor", "schedule": "0 9 * * *" } + ] +} +``` + +## When to use which pattern + + + + + + + + + + + + + +
Serverless FunctionVercel Sandbox
InfrastructureNone (runs in-function)None (Vercel manages VMs)
Startup time~2-3 secondsSub-second with snapshot
Binary size limit50MB compressedNone
Multi-step workflowsSingle request onlyYes (persistent session)
Task durationUp to 300s (Pro)Up to ~2 minutes
Best forFast single-request screenshotsComplex workflows, full Chrome
+ +## Environment variables + + + + + + + + + +
VariablePatternDescription
CHROMIUM_PATHServerlessPath to local Chrome/Chromium (auto-detected on Vercel)
AGENT_BROWSER_SNAPSHOT_IDSandboxPre-built snapshot for fast startup
+ +## Demo app + +A working demo with both patterns, a UI, and a deploy-to-Vercel button is at +[`examples/demo/`](https://github.com/agent-browser/agent-browser/tree/main/examples/demo). diff --git a/docs/src/components/header.tsx b/docs/src/components/header.tsx index 1704fdb..23b2d2e 100644 --- a/docs/src/components/header.tsx +++ b/docs/src/components/header.tsx @@ -66,7 +66,7 @@ export function Header() { > - 16k + 20k = { security: "Security", "engines/chrome": "Chrome", "engines/lightpanda": "Lightpanda", + next: "Next.js + Vercel", "native-mode": "Native Mode (Experimental)", changelog: "Changelog", }; diff --git a/examples/demo/.env.example b/examples/demo/.env.example new file mode 100644 index 0000000..5127dd3 --- /dev/null +++ b/examples/demo/.env.example @@ -0,0 +1,9 @@ +# --- Serverless function mode (@sparticuz/chromium) --- +# Optional: path to Chromium binary for local development. +# On Vercel, @sparticuz/chromium provides this automatically. +# CHROMIUM_PATH=/usr/bin/chromium-browser + +# --- Vercel Sandbox mode --- +# 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 diff --git a/examples/demo/.gitignore b/examples/demo/.gitignore new file mode 100644 index 0000000..a87db43 --- /dev/null +++ b/examples/demo/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env +.env.local +.env*.local diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..8d3b37c --- /dev/null +++ b/examples/demo/README.md @@ -0,0 +1,53 @@ +# agent-browser Demo + +A visual demo of agent-browser's core capabilities. Enter a URL, pick a compute environment, and take a screenshot or accessibility snapshot. + +## Environments + +- **Serverless Function** -- `@sparticuz/chromium` + `puppeteer-core` running directly inside a Vercel serverless function +- **Vercel Sandbox** -- agent-browser + Chrome in an ephemeral Linux microVM + +## Getting Started + +```bash +cd examples/demo +pnpm install +pnpm dev +``` + +## Serverless Function + +Runs headless Chrome directly in the serverless function. On Vercel, `@sparticuz/chromium` provides the binary automatically. Locally, the app finds your system Chrome or uses `CHROMIUM_PATH`. + +## Vercel Sandbox + +Spins up a Linux microVM on demand, installs agent-browser + Chrome, runs the commands, and shuts down. No binary size limits. Create a snapshot to make startup sub-second: + +```bash +npx tsx scripts/create-snapshot.ts +# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx +``` + +Add the snapshot ID to your Vercel environment variables or `.env.local`. + +## Environment Variables + +| Variable | Environment | Description | +|---|---|---| +| `CHROMIUM_PATH` | Serverless | Path to local Chrome/Chromium binary (auto-detected on Vercel) | +| `AGENT_BROWSER_SNAPSHOT_ID` | Sandbox | Pre-built snapshot ID for fast startup | + +## Project Structure + +``` +examples/demo/ + app/ + page.tsx # Demo UI + actions/browse.ts # Server actions (all environments) + api/browse/route.ts # API route for programmatic access + lib/ + agent-browser.ts # Serverless: @sparticuz/chromium + puppeteer-core + agent-browser-sandbox.ts # Sandbox: Vercel Sandbox client + scripts/ + create-snapshot.ts # Create sandbox snapshot +``` diff --git a/examples/demo/app/actions/browse.ts b/examples/demo/app/actions/browse.ts new file mode 100644 index 0000000..6008c49 --- /dev/null +++ b/examples/demo/app/actions/browse.ts @@ -0,0 +1,98 @@ +"use server"; + +import * as serverless from "@/lib/agent-browser"; +import * as sandbox from "@/lib/agent-browser-sandbox"; + +export type EnvStatus = { + serverless: { + hasChromiumPath: boolean; + isVercel: boolean; + }; + sandbox: { + hasSnapshot: boolean; + }; +}; + +export async function getEnvStatus(): Promise { + return { + serverless: { + hasChromiumPath: !!process.env.CHROMIUM_PATH, + isVercel: + !!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME, + }, + sandbox: { + hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID, + }, + }; +} + +export type ScreenshotResult = { + ok: boolean; + screenshot?: string; + title?: string; + error?: string; +}; + +export type SnapshotResult = { + ok: boolean; + snapshot?: string; + title?: string; + error?: string; +}; + +export type Mode = "serverless" | "sandbox"; + +/** + * Server action: screenshot a URL. + * + * mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function + * mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM + */ +export async function takeScreenshot( + url: string, + mode: Mode = "serverless", +): Promise { + try { + if (mode === "sandbox") { + const { screenshot, title } = await sandbox.screenshotUrl(url); + return { ok: true, screenshot, title }; + } + + const { screenshot, title } = await serverless.screenshotUrl(url); + return { ok: true, screenshot, title }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Server action: snapshot a URL (accessibility tree). + * + * mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function + * mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM + */ +export async function takeSnapshot( + url: string, + mode: Mode = "serverless", +): Promise { + try { + if (mode === "sandbox") { + const { snapshot, title } = await sandbox.snapshotUrl(url, { + interactive: true, + compact: true, + }); + return { ok: true, snapshot, title }; + } + + const { snapshot, title } = await serverless.snapshotUrl(url); + return { ok: true, snapshot, title }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/examples/demo/app/api/browse/route.ts b/examples/demo/app/api/browse/route.ts new file mode 100644 index 0000000..583ae7a --- /dev/null +++ b/examples/demo/app/api/browse/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as ab from "@/lib/agent-browser"; + +/** + * POST /api/browse + * + * Programmatic API route for browser automation. + * Uses @sparticuz/chromium + puppeteer-core directly in the function. + * + * Body: { "action": "screenshot", "url": "https://example.com" } + * Or: { "action": "snapshot", "url": "https://example.com" } + */ +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const url = body.url; + + if (!url) { + return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 }); + } + + if (body.action === "screenshot") { + const result = await ab.screenshotUrl(url, { + fullPage: body.fullPage, + }); + return NextResponse.json(result); + } + + if (body.action === "snapshot") { + const result = await ab.snapshotUrl(url, { + selector: body.selector, + }); + return NextResponse.json(result); + } + + return NextResponse.json( + { error: "Provide 'action' as 'screenshot' or 'snapshot'" }, + { status: 400 }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/examples/demo/app/globals.css b/examples/demo/app/globals.css new file mode 100644 index 0000000..4f3da66 --- /dev/null +++ b/examples/demo/app/globals.css @@ -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); +} diff --git a/examples/demo/app/layout.tsx b/examples/demo/app/layout.tsx new file mode 100644 index 0000000..9ae3292 --- /dev/null +++ b/examples/demo/app/layout.tsx @@ -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 Demo", + description: "A visual demo of agent-browser's core capabilities", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/examples/demo/app/page.tsx b/examples/demo/app/page.tsx new file mode 100644 index 0000000..3d7af7b --- /dev/null +++ b/examples/demo/app/page.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useState, useEffect, useSyncExternalStore } from "react"; +import { takeScreenshot, takeSnapshot, getEnvStatus } from "./actions/browse"; +import type { + ScreenshotResult, + SnapshotResult, + Mode, + EnvStatus, +} from "./actions/browse"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Loader2, Monitor, TriangleAlert, CircleX } 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 getSnapshot = () => window.matchMedia(MOBILE_QUERY).matches; +const getServerSnapshot = () => false; + +function useIsMobile() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +type Action = "screenshot" | "snapshot"; + +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({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function EnvBadge({ + label, + value, + status, +}: { + label: string; + value?: string; + status: "ok" | "warn" | "missing"; +}) { + const variant = + status === "ok" + ? "outline" + : status === "warn" + ? "secondary" + : "destructive"; + const icon = + status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717"; + + return ( + + {icon} + {label} + {value && {value}} + + ); +} + +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 ( +
+ + + Request failed + {message} + + {showRaw && ( +
+ + Show raw response + +
+            {error}
+          
+
+ )} +
+ ); +} + +function ModeCard({ + selected, + onSelect, + title, + description, + badges, +}: { + selected: boolean; + onSelect: () => void; + title: string; + description: string; + badges?: React.ReactNode; +}) { + return ( + + ); +} + +export default function Home() { + const isMobile = useIsMobile(); + const [url, setUrl] = useState("https://example.com"); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState("screenshot"); + const [mode, setMode] = useState("serverless"); + const [screenshotResult, setScreenshotResult] = + useState(null); + const [snapshotResult, setSnapshotResult] = + useState(null); + const [envStatus, setEnvStatus] = useState(null); + + useEffect(() => { + getEnvStatus().then(setEnvStatus); + }, []); + + function clearResults() { + setScreenshotResult(null); + setSnapshotResult(null); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setScreenshotResult(null); + setSnapshotResult(null); + + try { + if (action === "screenshot") { + const result = await takeScreenshot(url, mode); + setScreenshotResult(result); + } else { + const result = await takeSnapshot(url, mode); + setSnapshotResult(result); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (action === "screenshot") { + setScreenshotResult({ ok: false, error: message }); + } else { + setSnapshotResult({ ok: false, error: message }); + } + } finally { + setLoading(false); + } + } + + const hasResult = screenshotResult || snapshotResult; + + const envWarning = + envStatus && + mode === "serverless" && + !envStatus.serverless.isVercel && + !envStatus.serverless.hasChromiumPath + ? "Running locally without CHROMIUM_PATH. The app will try to use your system Chrome. Set CHROMIUM_PATH if Chrome is not in the default location." + : null; + + const controlsForm = ( +
+
+ + { + setUrl(e.target.value); + clearResults(); + }} + placeholder="https://example.com" + required + /> +
+ +
+ + + value={action} + onChange={(v) => { + setAction(v); + clearResults(); + }} + options={[ + { value: "screenshot", label: "Screenshot" }, + { value: "snapshot", label: "Snapshot" }, + ]} + /> +

+ {action === "screenshot" + ? "Captures a full-page PNG image" + : "Returns the accessibility tree"} +

+
+ +
+ +
+ { + setMode("serverless"); + clearResults(); + }} + title="Serverless Function" + description="Runs @sparticuz/chromium + puppeteer-core directly in a Vercel function." + badges={ + envStatus && ( + + ) + } + /> + { + setMode("sandbox"); + clearResults(); + }} + title="Vercel Sandbox" + description="Ephemeral microVM with agent-browser + Chrome. No binary size limits." + badges={ + envStatus && ( + + ) + } + /> +
+
+ + {envWarning && ( + + + Local development + {envWarning} + + )} + + +
+ ); + + const resultContent = loading ? ( +
+ +

Taking {action}...

+
+ ) : hasResult ? ( +
+ {screenshotResult && + (screenshotResult.ok ? ( +
+
+

+ {screenshotResult.title} +

+ + screenshot + +
+
+ {screenshotResult.title} +
+
+ ) : ( + + ))} + + {snapshotResult && + (snapshotResult.ok ? ( +
+
+

+ {snapshotResult.title} +

+ + snapshot + +
+
+              {snapshotResult.snapshot}
+            
+
+ ) : ( + + ))} +
+ ) : ( +
+ +

No result yet

+

Enter a URL and click Run

+
+ ); + + return ( +
+
+
+
+ + agent-browser + + / + + Demo + +
+
+ Deploy with Vercel + +
+
+ + {isMobile ? ( +
+
{controlsForm}
+
{resultContent}
+
+ ) : ( + + + + + + + + +
+ {resultContent} +
+
+
+ )} +
+ ); +} diff --git a/examples/demo/components.json b/examples/demo/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/examples/demo/components.json @@ -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": {} +} diff --git a/examples/demo/components/ui/alert.tsx b/examples/demo/components/ui/alert.tsx new file mode 100644 index 0000000..1fe3176 --- /dev/null +++ b/examples/demo/components/ui/alert.tsx @@ -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) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
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 ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/examples/demo/components/ui/badge.tsx b/examples/demo/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/examples/demo/components/ui/badge.tsx @@ -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) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/examples/demo/components/ui/button.tsx b/examples/demo/components/ui/button.tsx new file mode 100644 index 0000000..9710b0a --- /dev/null +++ b/examples/demo/components/ui/button.tsx @@ -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) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/examples/demo/components/ui/input.tsx b/examples/demo/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/examples/demo/components/ui/input.tsx @@ -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 ( + + ) +} + +export { Input } diff --git a/examples/demo/components/ui/label.tsx b/examples/demo/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/examples/demo/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +