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>
This commit is contained in:
@@ -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<EnvStatus> {
|
||||
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<ScreenshotResult> {
|
||||
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<SnapshotResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 Demo",
|
||||
description: "A visual demo of agent-browser's core capabilities",
|
||||
};
|
||||
|
||||
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,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<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 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 (
|
||||
<Badge variant={variant} className="gap-1 font-mono text-[10px]">
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
{value && <span className="opacity-60">{value}</span>}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
badges?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`
|
||||
w-full text-left rounded-lg border p-3 transition-all cursor-pointer
|
||||
${
|
||||
selected
|
||||
? "border-ring bg-background ring-1 ring-ring/20"
|
||||
: "border-input bg-background hover:border-ring/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div
|
||||
className={`
|
||||
size-3.5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors
|
||||
${selected ? "border-foreground" : "border-muted-foreground/30"}
|
||||
`}
|
||||
>
|
||||
{selected && (
|
||||
<div className="size-1.5 rounded-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold">{title}</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted-foreground leading-relaxed pl-[22px] mb-2">
|
||||
{description}
|
||||
</p>
|
||||
{badges && (
|
||||
<div className="flex flex-wrap gap-1.5 pl-[22px]">{badges}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
const [url, setUrl] = useState("https://example.com");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<Action>("screenshot");
|
||||
const [mode, setMode] = useState<Mode>("serverless");
|
||||
const [screenshotResult, setScreenshotResult] =
|
||||
useState<ScreenshotResult | null>(null);
|
||||
const [snapshotResult, setSnapshotResult] =
|
||||
useState<SnapshotResult | null>(null);
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(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 = (
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="url-input"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url-input"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
clearResults();
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Environment
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<ModeCard
|
||||
selected={mode === "serverless"}
|
||||
onSelect={() => {
|
||||
setMode("serverless");
|
||||
clearResults();
|
||||
}}
|
||||
title="Serverless Function"
|
||||
description="Runs @sparticuz/chromium + puppeteer-core directly in a Vercel function."
|
||||
badges={
|
||||
envStatus && (
|
||||
<EnvBadge
|
||||
label="@sparticuz/chromium"
|
||||
status={
|
||||
envStatus.serverless.isVercel
|
||||
? "ok"
|
||||
: envStatus.serverless.hasChromiumPath
|
||||
? "ok"
|
||||
: "warn"
|
||||
}
|
||||
value={
|
||||
envStatus.serverless.isVercel
|
||||
? "auto"
|
||||
: envStatus.serverless.hasChromiumPath
|
||||
? "CHROMIUM_PATH"
|
||||
: "system Chrome"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ModeCard
|
||||
selected={mode === "sandbox"}
|
||||
onSelect={() => {
|
||||
setMode("sandbox");
|
||||
clearResults();
|
||||
}}
|
||||
title="Vercel Sandbox"
|
||||
description="Ephemeral microVM with agent-browser + Chrome. No binary size limits."
|
||||
badges={
|
||||
envStatus && (
|
||||
<EnvBadge
|
||||
label="AGENT_BROWSER_SNAPSHOT_ID"
|
||||
status={
|
||||
envStatus.sandbox.hasSnapshot ? "ok" : "warn"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{envWarning && (
|
||||
<Alert>
|
||||
<TriangleAlert className="size-4" />
|
||||
<AlertTitle>Local development</AlertTitle>
|
||||
<AlertDescription>{envWarning}</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 resultContent = loading ? (
|
||||
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Taking {action}...</p>
|
||||
</div>
|
||||
) : hasResult ? (
|
||||
<div className="flex flex-col items-center p-6 lg:p-10">
|
||||
{screenshotResult &&
|
||||
(screenshotResult.ok ? (
|
||||
<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">
|
||||
{screenshotResult.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,${screenshotResult.screenshot}`}
|
||||
alt={screenshotResult.title}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorDisplay
|
||||
error={screenshotResult.error ?? "Unknown error"}
|
||||
/>
|
||||
))}
|
||||
|
||||
{snapshotResult &&
|
||||
(snapshotResult.ok ? (
|
||||
<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">
|
||||
{snapshotResult.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)]">
|
||||
{snapshotResult.snapshot}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorDisplay
|
||||
error={snapshotResult.error ?? "Unknown error"}
|
||||
/>
|
||||
))}
|
||||
</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]">Enter 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>
|
||||
<span className="text-muted-foreground text-sm hidden sm:inline">/</span>
|
||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||
Demo
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo&env=CHROMIUM_PATH&envDescription=Optional%20path%20to%20Chromium%20binary.%20Not%20needed%20on%20Vercel.&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo%23environment-variables&project-name=agent-browser-demo&repository-name=agent-browser-demo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://vercel.com/button"
|
||||
alt="Deploy with Vercel"
|
||||
className="h-8"
|
||||
/>
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user