chore(fork): drop upstream-only docs/, evals/, packages/dashboard, schema

These directories are TypeScript-side tooling that the fork dropped at
v0.24.0 to keep the repo focused on the stealth CLI binary. Upstream
either kept evolving them (docs, packages/dashboard) or added new ones
(evals/) — they came back during the v0.27.0 rebase, so prune again.

Also include skill-data/ in package.json `files` so the specialized
skills (electron, slack, dogfood, etc.) that upstream relocated from
skills/ to skill-data/ still ship in the npm tarball.
This commit is contained in:
leeguooooo
2026-05-09 00:24:27 +09:00
parent 9202c1c919
commit 86c4cff26e
30 changed files with 1 additions and 3048 deletions
-166
View File
@@ -1,166 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Agent Browser Configuration",
"description": "Configuration file for agent-browser (e.g., agent-browser.json or ~/.agent-browser/config.json)",
"type": "object",
"properties": {
"headed": {
"type": "boolean",
"description": "Show browser window instead of running headless."
},
"json": {
"type": "boolean",
"description": "Output in JSON format."
},
"debug": {
"type": "boolean",
"description": "Enable debug output."
},
"session": {
"type": "string",
"description": "Session identifier."
},
"sessionName": {
"type": "string",
"description": "Auto-save/load state persistence name."
},
"executablePath": {
"type": "string",
"description": "Path to a custom browser executable."
},
"extensions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Paths to browser extensions. Extensions from user-level and project-level configs are concatenated."
},
"profile": {
"type": "string",
"description": "Path to the browser profile data directory."
},
"state": {
"type": "string",
"description": "Path to load/save browser state."
},
"proxy": {
"type": "string",
"description": "Proxy server URL (e.g., http://localhost:8080)."
},
"proxyBypass": {
"type": "string",
"description": "Comma-separated domains to bypass the proxy (e.g., localhost,*.internal.com)."
},
"args": {
"type": "string",
"description": "Additional comma-separated launch arguments for the browser."
},
"userAgent": {
"type": "string",
"description": "Custom User-Agent string."
},
"provider": {
"type": "string",
"description": "Provider to use, such as 'ios'."
},
"device": {
"type": "string",
"description": "Device name or identifier for emulation or providers (e.g., 'iPhone 16 Pro')."
},
"ignoreHttpsErrors": {
"type": "boolean",
"description": "Ignore HTTPS errors during navigation."
},
"allowFileAccess": {
"type": "boolean",
"description": "Allow file:// URLs to access local files."
},
"cdp": {
"type": "string",
"description": "Chrome DevTools Protocol endpoint URL."
},
"autoConnect": {
"type": "boolean",
"description": "Auto-discover and connect to a running Chrome instance."
},
"annotate": {
"type": "boolean",
"description": "Annotated screenshot with numbered element labels."
},
"colorScheme": {
"type": "string",
"enum": ["dark", "light", "no-preference"],
"description": "Color scheme preference."
},
"downloadPath": {
"type": "string",
"description": "Default directory for browser downloads."
},
"contentBoundaries": {
"type": "boolean",
"description": "Wrap page output in boundary markers for LLM safety."
},
"maxOutput": {
"type": "integer",
"minimum": 0,
"description": "Max characters for page output (truncates beyond limit)."
},
"allowedDomains": {
"type": "array",
"items": {
"type": "string"
},
"description": "Allowed domain patterns (e.g., ['example.com', '*.example.com'])."
},
"actionPolicy": {
"type": "string",
"description": "Path to action policy JSON file."
},
"confirmActions": {
"type": "string",
"description": "Comma-separated action categories requiring confirmation."
},
"confirmInteractive": {
"type": "boolean",
"description": "Enable interactive confirmation prompts (auto-denies if stdin is not a TTY)."
},
"engine": {
"type": "string",
"enum": ["chrome", "lightpanda"],
"default": "chrome",
"description": "Browser engine to use."
},
"screenshotDir": {
"type": "string",
"description": "Default screenshot output directory."
},
"screenshotQuality": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "JPEG quality for screenshots (0-100)."
},
"screenshotFormat": {
"type": "string",
"enum": ["png", "jpeg"],
"description": "Screenshot format."
},
"idleTimeout": {
"type": "string",
"description": "Auto-shutdown the daemon after inactivity (e.g., '30s', '5m', '1h', or raw milliseconds like '60000')."
},
"model": {
"type": "string",
"description": "AI model for chat command (e.g., 'openai/gpt-4o')."
},
"noAutoDialog": {
"type": "boolean",
"description": "Disable automatic dismissal of alert/beforeunload dialogs."
},
"headers": {
"type": "string",
"description": "Custom HTTP headers supplied as a JSON-formatted string."
}
},
"additionalProperties": true
}
-166
View File
@@ -1,166 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Agent Browser Configuration",
"description": "Configuration file for agent-browser (e.g., agent-browser.json or ~/.agent-browser/config.json)",
"type": "object",
"properties": {
"headed": {
"type": "boolean",
"description": "Show browser window instead of running headless."
},
"json": {
"type": "boolean",
"description": "Output in JSON format."
},
"debug": {
"type": "boolean",
"description": "Enable debug output."
},
"session": {
"type": "string",
"description": "Session identifier."
},
"sessionName": {
"type": "string",
"description": "Auto-save/load state persistence name."
},
"executablePath": {
"type": "string",
"description": "Path to a custom browser executable."
},
"extensions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Paths to browser extensions. Extensions from user-level and project-level configs are concatenated."
},
"profile": {
"type": "string",
"description": "Path to the browser profile data directory."
},
"state": {
"type": "string",
"description": "Path to load/save browser state."
},
"proxy": {
"type": "string",
"description": "Proxy server URL (e.g., http://localhost:8080)."
},
"proxyBypass": {
"type": "string",
"description": "Comma-separated domains to bypass the proxy (e.g., localhost,*.internal.com)."
},
"args": {
"type": "string",
"description": "Additional comma-separated launch arguments for the browser."
},
"userAgent": {
"type": "string",
"description": "Custom User-Agent string."
},
"provider": {
"type": "string",
"description": "Provider to use, such as 'ios'."
},
"device": {
"type": "string",
"description": "Device name or identifier for emulation or providers (e.g., 'iPhone 16 Pro')."
},
"ignoreHttpsErrors": {
"type": "boolean",
"description": "Ignore HTTPS errors during navigation."
},
"allowFileAccess": {
"type": "boolean",
"description": "Allow file:// URLs to access local files."
},
"cdp": {
"type": "string",
"description": "Chrome DevTools Protocol endpoint URL."
},
"autoConnect": {
"type": "boolean",
"description": "Auto-discover and connect to a running Chrome instance."
},
"annotate": {
"type": "boolean",
"description": "Annotated screenshot with numbered element labels."
},
"colorScheme": {
"type": "string",
"enum": ["dark", "light", "no-preference"],
"description": "Color scheme preference."
},
"downloadPath": {
"type": "string",
"description": "Default directory for browser downloads."
},
"contentBoundaries": {
"type": "boolean",
"description": "Wrap page output in boundary markers for LLM safety."
},
"maxOutput": {
"type": "integer",
"minimum": 0,
"description": "Max characters for page output (truncates beyond limit)."
},
"allowedDomains": {
"type": "array",
"items": {
"type": "string"
},
"description": "Allowed domain patterns (e.g., ['example.com', '*.example.com'])."
},
"actionPolicy": {
"type": "string",
"description": "Path to action policy JSON file."
},
"confirmActions": {
"type": "string",
"description": "Comma-separated action categories requiring confirmation."
},
"confirmInteractive": {
"type": "boolean",
"description": "Enable interactive confirmation prompts (auto-denies if stdin is not a TTY)."
},
"engine": {
"type": "string",
"enum": ["chrome", "lightpanda"],
"default": "chrome",
"description": "Browser engine to use."
},
"screenshotDir": {
"type": "string",
"description": "Default screenshot output directory."
},
"screenshotQuality": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "JPEG quality for screenshots (0-100)."
},
"screenshotFormat": {
"type": "string",
"enum": ["png", "jpeg"],
"description": "Screenshot format."
},
"idleTimeout": {
"type": "string",
"description": "Auto-shutdown the daemon after inactivity (e.g., '30s', '5m', '1h', or raw milliseconds like '60000')."
},
"model": {
"type": "string",
"description": "AI model for chat command (e.g., 'openai/gpt-4o')."
},
"noAutoDialog": {
"type": "boolean",
"description": "Disable automatic dismissal of alert/beforeunload dialogs."
},
"headers": {
"type": "string",
"description": "Custom HTTP headers supplied as a JSON-formatted string."
}
},
"additionalProperties": true
}
-20
View File
@@ -1,20 +0,0 @@
const REPO = "vercel-labs/agent-browser";
const REVALIDATE = 86400;
export async function getStarCount(): Promise<string> {
try {
const res = await fetch(`https://api.github.com/repos/${REPO}`, {
headers: { Accept: "application/vnd.github.v3+json" },
next: { revalidate: REVALIDATE },
});
if (!res.ok) return "";
const data = await res.json();
const count = data.stargazers_count;
if (typeof count !== "number") return "";
if (count >= 1000)
return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`;
return String(count);
} catch {
return "";
}
}
-2
View File
@@ -1,2 +0,0 @@
# Vercel AI Gateway key (required)
AI_GATEWAY_API_KEY=
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
bun.lockb
-127
View File
@@ -1,127 +0,0 @@
# Skills Evals
Tests whether the thin SKILL.md + CLI-served skills approach works: do agents load the right skill via `agent-browser skills get`, then produce correct agent-browser commands?
## Prerequisites
- [Bun](https://bun.sh) installed
- `AI_GATEWAY_API_KEY` set (Vercel AI Gateway key)
- One or both CLIs installed:
- `claude` CLI (`npm i -g @anthropic-ai/claude-code`) for the Claude provider
- `codex` CLI (`npm i -g @openai/codex`) for the Codex provider
The evals route all calls through the Vercel AI Gateway (`https://ai-gateway.vercel.sh`). Set your key before running:
```bash
export AI_GATEWAY_API_KEY=gw_your_key_here
```
Or copy `.env.example` to `.env` and source it.
## Usage
```bash
cd evals
# Run all evals (default: Claude provider)
bun run run.ts
# Use Codex provider
bun run run.ts --provider codex
# Filter by category
bun run run.ts --category skill-loading
bun run run.ts --category skill-selection
bun run run.ts --category command-usage
# Use a specific model (overrides provider default)
bun run run.ts --model anthropic/claude-opus-4.6
bun run run.ts --provider codex --model openai/gpt-4.1
# Enable LLM judge for quality scoring (1-5)
bun run run.ts --judge
# JSON output (for CI or further analysis)
bun run run.ts --json
# Combine options
bun run run.ts --provider codex --category skill-selection --judge
```
Or via package scripts:
```bash
bun run eval # run all (Claude)
bun run eval:claude # run all (Claude, explicit)
bun run eval:codex # run all (Codex)
bun run eval:judge # run all with LLM judge
bun run eval:json # JSON output
```
## Providers
<table>
<tr><th>Provider</th><th>CLI</th><th>Default Model</th><th>Notes</th></tr>
<tr><td>claude</td><td><code>claude -p</code></td><td>anthropic/claude-sonnet-4.6</td><td>Uses ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL env vars</td></tr>
<tr><td>codex</td><td><code>codex exec --json</code></td><td>openai/o3</td><td>Writes ~/.codex/config.toml with AI Gateway config</td></tr>
</table>
The LLM judge always uses Claude (anthropic/claude-opus-4.6), regardless of the eval provider.
## Eval Categories
### skill-loading
Tests that the agent runs `agent-browser skills get` before issuing browser commands. The thin SKILL.md instructs agents to load skills first; these evals verify compliance.
### skill-selection
Tests that the agent picks the correct specialized skill for the task. For example, a Slack task should load the `slack` skill, not the generic `agent-browser` skill.
### command-usage
Tests that the agent produces correct agent-browser commands for common workflows: navigation + screenshot, form filling with snapshot-interact pattern, diffing, authentication, data extraction.
## How It Works
1. Each eval case provides a user task prompt
2. The thin `skills/agent-browser/SKILL.md` is injected as context (simulating a skill installation)
3. The chosen provider CLI is called to get a single response
4. Pattern matching checks for expected/forbidden command patterns (pass/fail)
5. Optionally, a second Claude call judges response quality on a 1-5 scale
## Adding Cases
Create or edit files in `cases/`. Each file exports a `cases` array of `EvalCase` objects:
```typescript
import type { EvalCase } from "../lib/types.ts";
export const cases: EvalCase[] = [
{
id: "xx-01",
name: "Description of what this tests",
category: "skill-loading",
prompt: "The user task to send to the model",
expectedPatterns: ["regex.*that.*must.*match"],
forbiddenPatterns: ["regex.*that.*must.*not.*match"],
rubric: "1 - worst ... 5 - best",
},
];
```
Then import and add the cases to `ALL_CASES` in `run.ts`.
## Output
Console mode shows pass/fail per case with failed pattern details:
```
skill-loading
----------------------------------------------------------------------
✓ Loads skill before opening a page PASS 3200ms
✗ Loads skill before form interaction FAIL 2800ms
✗ Expected pattern not found: agent-browser skills get
```
JSON mode (`--json`) outputs structured results for programmatic consumption.
-19
View File
@@ -1,19 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "agent-browser-evals",
"dependencies": {
"bun-types": "^1.3.12",
},
},
},
"packages": {
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
-120
View File
@@ -1,120 +0,0 @@
import type { EvalCase } from "../lib/types.ts";
const RUBRIC = `
1 - Agent does not produce valid agent-browser commands
2 - Agent uses agent-browser but with wrong commands or missing steps
3 - Agent uses correct commands but skips the snapshot-interact workflow
4 - Agent follows the correct workflow with appropriate commands
5 - Agent follows the optimal workflow: navigate, snapshot, interact with refs, re-snapshot as needed
`.trim();
const COMMAND_CONTEXT = `You already ran \`agent-browser skills get core\` and loaded these commands:
- agent-browser open <url> (navigate to a page)
- agent-browser snapshot -i (get interactive elements with refs like @e1, @e2)
- agent-browser click @ref (click element)
- agent-browser fill @ref "text" (clear and type)
- agent-browser type @ref "text" (type without clearing)
- agent-browser select @ref "option" (select dropdown)
- agent-browser screenshot (screenshot to temp dir)
- agent-browser screenshot --full (full page screenshot)
- agent-browser diff url <url1> <url2> (compare two pages)
- agent-browser diff snapshot (compare current vs last snapshot)
- agent-browser state save ./file.json (save auth state)
- agent-browser state load ./file.json (restore auth state)
- agent-browser get text @ref (get element text)
- agent-browser wait <selector|ms> (wait for element or time)
- agent-browser --session-name <name> open <url> (named session with auto-save)
Workflow: open -> snapshot -i -> interact with refs -> re-snapshot after changes.`;
export const cases: EvalCase[] = [
{
id: "cu-01",
name: "Navigate and screenshot workflow",
category: "command-usage",
prompt: "Open example.com and take a screenshot",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate)",
"agent-browser\\s+screenshot",
],
rubric: RUBRIC,
},
{
id: "cu-02",
name: "Form filling workflow",
category: "command-usage",
prompt:
"Go to example.com/signup, fill in name as 'Jane Doe' and email as 'jane@test.com', then submit",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate)",
"agent-browser\\s+snapshot",
"agent-browser\\s+(fill|type)",
"agent-browser\\s+(click|press|key)",
],
rubric: RUBRIC,
},
{
id: "cu-03",
name: "Snapshot with element refs",
category: "command-usage",
prompt: "Get all interactive elements on example.com",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate)",
"agent-browser\\s+snapshot",
],
rubric: RUBRIC,
},
{
id: "cu-04",
name: "Diff comparison workflow",
category: "command-usage",
prompt:
"Compare the homepage of staging.example.com and prod.example.com",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+diff|staging\\.example\\.com.*prod\\.example\\.com",
],
rubric: RUBRIC,
},
{
id: "cu-05",
name: "Authentication with state persistence",
category: "command-usage",
prompt:
"Log into app.example.com, then save the auth state for future sessions",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate)",
"state\\s+save|--session-name|auth\\s+save",
],
rubric: RUBRIC,
},
{
id: "cu-06",
name: "Data extraction workflow",
category: "command-usage",
prompt:
"Extract the text content of the main heading on example.com",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate)",
"snapshot|get\\s+text",
],
rubric: RUBRIC,
},
{
id: "cu-07",
name: "Full-page screenshot",
category: "command-usage",
prompt: "Take a full-page screenshot of example.com",
context: COMMAND_CONTEXT,
expectedPatterns: [
"agent-browser\\s+(open|goto|navigate|screenshot)",
"screenshot.*--full",
],
rubric: RUBRIC,
},
];
-66
View File
@@ -1,66 +0,0 @@
import type { EvalCase } from "../lib/types.ts";
const RUBRIC = `
1 - Agent does not mention agent-browser skills or load any skill
2 - Agent mentions skills but does not run the skills get command
3 - Agent runs skills get but for the wrong skill or at the wrong time
4 - Agent runs skills get for the correct skill before using agent-browser
5 - Agent runs skills get first, then follows the loaded skill's workflow correctly
`.trim();
export const cases: EvalCase[] = [
{
id: "sl-01",
name: "Loads skill before opening a page",
category: "skill-loading",
prompt: "Open example.com and take a screenshot",
expectedPatterns: [
"agent-browser skills get",
],
rubric: RUBRIC,
},
{
id: "sl-02",
name: "Loads skill before form interaction",
category: "skill-loading",
prompt:
"Go to example.com/contact and fill out the contact form with test data",
expectedPatterns: [
"agent-browser skills get",
],
rubric: RUBRIC,
},
{
id: "sl-03",
name: "Loads skill before data extraction",
category: "skill-loading",
prompt:
"Scrape all product names and prices from shop.example.com",
expectedPatterns: [
"agent-browser skills get",
],
rubric: RUBRIC,
},
{
id: "sl-04",
name: "Loads skill before authentication task",
category: "skill-loading",
prompt:
"Log into my GitHub account and check my notifications",
expectedPatterns: [
"agent-browser skills get",
],
rubric: RUBRIC,
},
{
id: "sl-05",
name: "Uses skills list to discover available skills",
category: "skill-loading",
prompt:
"I need to automate some browser tasks. What skills are available for agent-browser?",
expectedPatterns: [
"agent-browser skills (list|get)",
],
rubric: RUBRIC,
},
];
-94
View File
@@ -1,94 +0,0 @@
import type { EvalCase } from "../lib/types.ts";
const RUBRIC = `
1 - Agent does not load any skill or loads a completely wrong one
2 - Agent loads the generic agent-browser skill when a specialized one exists
3 - Agent loads a related but suboptimal skill
4 - Agent loads the correct specialized skill
5 - Agent loads the correct skill and explains why it chose it
`.trim();
export const cases: EvalCase[] = [
{
id: "ss-01",
name: "Selects slack skill for Slack tasks",
category: "skill-selection",
prompt: "Check my Slack unreads and summarize any messages mentioning me",
expectedPatterns: [
"skills get slack",
],
rubric: RUBRIC,
},
{
id: "ss-02",
name: "Selects electron skill for VS Code automation",
category: "skill-selection",
prompt: "Automate VS Code to open a project and run a terminal command",
expectedPatterns: [
"skills get electron",
],
rubric: RUBRIC,
},
{
id: "ss-03",
name: "Selects dogfood skill for QA/testing",
category: "skill-selection",
prompt: "QA test http://localhost:3000 and find any bugs or UX issues",
expectedPatterns: [
"skills get dogfood",
],
rubric: RUBRIC,
},
{
id: "ss-04",
name: "Selects agentcore skill for AWS cloud browsers",
category: "skill-selection",
prompt:
"Run browser automation on AWS using AgentCore cloud browsers",
expectedPatterns: [
"skills get agentcore",
],
rubric: RUBRIC,
},
{
id: "ss-05",
name: "Selects vercel-sandbox skill for Vercel environments",
category: "skill-selection",
prompt:
"Run headless Chrome inside a Vercel Sandbox microVM to test my deployed Next.js app",
expectedPatterns: [
"skills get vercel-sandbox",
],
rubric: RUBRIC,
},
{
id: "ss-06",
name: "Selects electron skill for Discord automation",
category: "skill-selection",
prompt: "Automate the Discord desktop app to send a message in a channel",
expectedPatterns: [
"skills get electron",
],
rubric: RUBRIC,
},
{
id: "ss-07",
name: "Selects dogfood skill for exploratory testing",
category: "skill-selection",
prompt: "Dogfood vercel.com and write up a bug report",
expectedPatterns: [
"skills get dogfood",
],
rubric: RUBRIC,
},
{
id: "ss-08",
name: "Selects core skill for general browser tasks",
category: "skill-selection",
prompt: "Navigate to hacker news and screenshot the front page",
expectedPatterns: [
"skills get core",
],
rubric: RUBRIC,
},
];
-142
View File
@@ -1,142 +0,0 @@
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import type { Provider, ProviderOptions, ProviderResponse } from "./types.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_PATH = resolve(__dirname, "../../skills/agent-browser/SKILL.md");
const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh";
const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
let cachedSkillContent: string | null = null;
function getSkillContent(): string {
if (!cachedSkillContent) {
cachedSkillContent = readFileSync(SKILL_PATH, "utf-8");
}
return cachedSkillContent;
}
function buildPrompt(userTask: string, context?: string): string {
const skill = getSkillContent();
const parts = [
"You have the following skill installed:\n",
"<skill>",
skill,
"</skill>\n",
];
if (context) {
parts.push(context + "\n");
}
parts.push(
`Complete this task: ${userTask}\n`,
"Show the exact shell commands you would run. Do not explain, just show the commands.",
);
return parts.join("\n");
}
function getGatewayEnv(): Record<string, string> {
const apiKey = process.env.AI_GATEWAY_API_KEY;
if (!apiKey) {
throw new Error(
"AI_GATEWAY_API_KEY is not set. Export it before running evals.",
);
}
return {
...(process.env as Record<string, string>),
ANTHROPIC_API_KEY: apiKey,
ANTHROPIC_BASE_URL: AI_GATEWAY_URL,
};
}
function spawnClaude(
prompt: string,
model: string,
timeout: number,
): Promise<{ output: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["claude", "-p", "--output-format", "text", "--model", model, prompt],
{
stdout: "pipe",
stderr: "pipe",
env: getGatewayEnv(),
},
);
return Promise.race([
(async () => {
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
return { output, stderr, exitCode };
})(),
new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new Error(`Timed out after ${timeout}ms`));
}, timeout),
),
]);
}
export const claudeProvider: Provider = {
name: "claude",
defaultModel: DEFAULT_MODEL,
async call(
userPrompt: string,
options: ProviderOptions = {},
context?: string,
): Promise<ProviderResponse> {
const { model = DEFAULT_MODEL, timeout = 60_000 } = options;
const prompt = buildPrompt(userPrompt, context);
const start = performance.now();
try {
const result = await spawnClaude(prompt, model, timeout);
const durationMs = Math.round(performance.now() - start);
if (result.exitCode !== 0) {
return {
output: "",
durationMs,
error: `claude exited with code ${result.exitCode}: ${result.stderr}`,
};
}
return { output: result.output.trim(), durationMs };
} catch (err) {
const durationMs = Math.round(performance.now() - start);
const message = err instanceof Error ? err.message : String(err);
return { output: "", durationMs, error: message };
}
},
async callRaw(
prompt: string,
options: ProviderOptions = {},
): Promise<ProviderResponse> {
const { model = DEFAULT_MODEL, timeout = 60_000 } = options;
const start = performance.now();
try {
const result = await spawnClaude(prompt, model, timeout);
const durationMs = Math.round(performance.now() - start);
if (result.exitCode !== 0) {
return {
output: "",
durationMs,
error: `claude exited with code ${result.exitCode}: ${result.stderr}`,
};
}
return { output: result.output.trim(), durationMs };
} catch (err) {
const durationMs = Math.round(performance.now() - start);
const message = err instanceof Error ? err.message : String(err);
return { output: "", durationMs, error: message };
}
},
};
-205
View File
@@ -1,205 +0,0 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { resolve, dirname, join } from "path";
import { fileURLToPath } from "url";
import { tmpdir } from "os";
import type { Provider, ProviderOptions, ProviderResponse } from "./types.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_PATH = resolve(__dirname, "../../skills/agent-browser/SKILL.md");
const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh/v1";
const DEFAULT_MODEL = "openai/o3";
let cachedSkillContent: string | null = null;
function getSkillContent(): string {
if (!cachedSkillContent) {
cachedSkillContent = readFileSync(SKILL_PATH, "utf-8");
}
return cachedSkillContent;
}
function buildPrompt(userTask: string, context?: string): string {
const skill = getSkillContent();
const parts = [
"You have the following skill installed:\n",
"<skill>",
skill,
"</skill>\n",
];
if (context) {
parts.push(context + "\n");
}
parts.push(
`Complete this task: ${userTask}\n`,
"Show the exact shell commands you would run. Do not explain, just show the commands.",
);
return parts.join("\n");
}
let evalHome: string | null = null;
function getEvalHome(model: string): string {
if (!evalHome) {
evalHome = join(tmpdir(), `agent-browser-evals-${process.pid}`);
}
const configDir = join(evalHome, ".codex");
const configPath = join(configDir, "config.toml");
const config = `model = "${model}"
model_provider = "vercel-ai-gateway"
[model_providers.vercel-ai-gateway]
name = "Vercel AI Gateway"
base_url = "${AI_GATEWAY_URL}"
env_key = "AI_GATEWAY_API_KEY"
wire_api = "responses"
`;
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
writeFileSync(configPath, config, "utf-8");
return evalHome;
}
function getCodexEnv(model: string): Record<string, string> {
const apiKey = process.env.AI_GATEWAY_API_KEY;
if (!apiKey) {
throw new Error(
"AI_GATEWAY_API_KEY is not set. Export it before running evals.",
);
}
return {
...(process.env as Record<string, string>),
HOME: getEvalHome(model),
AI_GATEWAY_API_KEY: apiKey,
};
}
function parseJsonlOutput(raw: string): string {
const lines = raw.split("\n");
const textParts: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
const eventType = parsed.type as string;
if (eventType === "item.completed") {
const item = parsed.item as Record<string, unknown> | undefined;
if (item?.type === "agent_message") {
const text = item.text as string | undefined;
if (text) textParts.push(text);
}
}
} catch {
// Non-JSON line (e.g. stderr leak), skip
}
}
return textParts.join("\n\n").trim();
}
function spawnCodex(
prompt: string,
model: string,
timeout: number,
): Promise<{ output: string; stderr: string; exitCode: number }> {
const escaped = prompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const proc = Bun.spawn(
[
"codex",
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"--json",
escaped,
],
{
stdout: "pipe",
stderr: "pipe",
env: getCodexEnv(model),
},
);
return Promise.race([
(async () => {
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
return { output, stderr, exitCode };
})(),
new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new Error(`Timed out after ${timeout}ms`));
}, timeout),
),
]);
}
export const codexProvider: Provider = {
name: "codex",
defaultModel: DEFAULT_MODEL,
async call(
userPrompt: string,
options: ProviderOptions = {},
context?: string,
): Promise<ProviderResponse> {
const { model = DEFAULT_MODEL, timeout = 120_000 } = options;
const prompt = buildPrompt(userPrompt, context);
const start = performance.now();
try {
const result = await spawnCodex(prompt, model, timeout);
const durationMs = Math.round(performance.now() - start);
if (result.exitCode !== 0) {
return {
output: "",
durationMs,
error: `codex exited with code ${result.exitCode}: ${result.stderr}`,
};
}
const output = parseJsonlOutput(result.output);
return { output, durationMs };
} catch (err) {
const durationMs = Math.round(performance.now() - start);
const message = err instanceof Error ? err.message : String(err);
return { output: "", durationMs, error: message };
}
},
async callRaw(
prompt: string,
options: ProviderOptions = {},
): Promise<ProviderResponse> {
const { model = DEFAULT_MODEL, timeout = 120_000 } = options;
const start = performance.now();
try {
const result = await spawnCodex(prompt, model, timeout);
const durationMs = Math.round(performance.now() - start);
if (result.exitCode !== 0) {
return {
output: "",
durationMs,
error: `codex exited with code ${result.exitCode}: ${result.stderr}`,
};
}
const output = parseJsonlOutput(result.output);
return { output, durationMs };
} catch (err) {
const durationMs = Math.round(performance.now() - start);
const message = err instanceof Error ? err.message : String(err);
return { output: "", durationMs, error: message };
}
},
};
-140
View File
@@ -1,140 +0,0 @@
import type {
EvalCase,
PatternResult,
JudgeResult,
EvalResult,
Provider,
ProviderOptions,
} from "./types.ts";
import { claudeProvider } from "./claude.ts";
function testPatterns(
response: string,
evalCase: EvalCase,
): { pass: boolean; results: PatternResult[] } {
const results: PatternResult[] = [];
let pass = true;
for (const pattern of evalCase.expectedPatterns) {
const regex = new RegExp(pattern, "is");
const matched = regex.test(response);
results.push({ pattern, matched, type: "expected" });
if (!matched) pass = false;
}
if (evalCase.forbiddenPatterns) {
for (const pattern of evalCase.forbiddenPatterns) {
const regex = new RegExp(pattern, "is");
const matched = regex.test(response);
results.push({ pattern, matched, type: "forbidden" });
if (matched) pass = false;
}
}
return { pass, results };
}
const JUDGE_PROMPT_TEMPLATE = `You are an eval judge scoring an AI agent's response to a browser automation task.
The agent was given a task and a skill file that instructs it to use agent-browser CLI commands.
Score the response on a scale of 1-5 based on the rubric below.
Rubric:
{rubric}
Response to judge:
<response>
{response}
</response>
Reply with ONLY a JSON object (no markdown fences, no other text):
{{"score": <1-5>, "reasoning": "<one sentence>"}}`;
const JUDGE_MODEL = "anthropic/claude-opus-4.6";
async function runLLMJudge(
response: string,
rubric: string,
options: ProviderOptions,
): Promise<JudgeResult> {
const prompt = JUDGE_PROMPT_TEMPLATE.replace("{rubric}", rubric).replace(
"{response}",
response,
);
// Judge always uses Claude regardless of eval provider
const result = await claudeProvider.callRaw(prompt, {
model: JUDGE_MODEL,
timeout: options.timeout ?? 30_000,
});
if (result.error) {
return { score: 0, reasoning: `Judge error: ${result.error}` };
}
try {
const cleaned = result.output.replace(/```json\n?|```\n?/g, "").trim();
const parsed = JSON.parse(cleaned);
return {
score: Math.max(0, Math.min(5, Number(parsed.score) || 0)),
reasoning: String(parsed.reasoning || ""),
};
} catch {
return {
score: 0,
reasoning: `Failed to parse judge response: ${result.output.slice(0, 200)}`,
};
}
}
export async function evaluate(
evalCase: EvalCase,
provider: Provider,
options: { model?: string; judge?: boolean; timeout?: number } = {},
): Promise<EvalResult> {
const providerOptions: ProviderOptions = {
model: options.model,
timeout: options.timeout,
};
const response = await provider.call(
evalCase.prompt,
providerOptions,
evalCase.context,
);
if (response.error) {
return {
caseId: evalCase.id,
caseName: evalCase.name,
category: evalCase.category,
pass: false,
patternResults: [],
response: "",
durationMs: response.durationMs,
error: response.error,
};
}
const { pass, results } = testPatterns(response.output, evalCase);
let judge: JudgeResult | undefined;
if (options.judge && evalCase.rubric) {
judge = await runLLMJudge(
response.output,
evalCase.rubric,
providerOptions,
);
}
return {
caseId: evalCase.id,
caseName: evalCase.name,
category: evalCase.category,
pass,
patternResults: results,
judge,
response: response.output,
durationMs: response.durationMs,
};
}
-16
View File
@@ -1,16 +0,0 @@
import type { Provider, ProviderName } from "./types.ts";
import { claudeProvider } from "./claude.ts";
import { codexProvider } from "./codex.ts";
const providers: Record<ProviderName, Provider> = {
claude: claudeProvider,
codex: codexProvider,
};
export function getProvider(name: ProviderName): Provider {
const provider = providers[name];
if (!provider) {
throw new Error(`Unknown provider: ${name}. Use "claude" or "codex".`);
}
return provider;
}
-142
View File
@@ -1,142 +0,0 @@
import type { EvalResult, EvalSummary, Category } from "./types.ts";
const PASS = "\x1b[32m\u2713\x1b[0m";
const FAIL = "\x1b[31m\u2717\x1b[0m";
const ERR = "\x1b[33m!\x1b[0m";
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
function padRight(str: string, len: number): string {
return str + " ".repeat(Math.max(0, len - str.length));
}
export function printResult(result: EvalResult): void {
const icon = result.error ? ERR : result.pass ? PASS : FAIL;
const status = result.error ? "ERROR" : result.pass ? "PASS" : "FAIL";
const duration = `${DIM}${result.durationMs}ms${RESET}`;
console.log(` ${icon} ${padRight(result.caseName, 50)} ${status} ${duration}`);
if (result.error) {
console.log(` ${DIM}Error: ${result.error}${RESET}`);
return;
}
const failedExpected = result.patternResults.filter(
(p) => p.type === "expected" && !p.matched,
);
const matchedForbidden = result.patternResults.filter(
(p) => p.type === "forbidden" && p.matched,
);
for (const p of failedExpected) {
console.log(` ${FAIL} Expected pattern not found: ${DIM}${p.pattern}${RESET}`);
}
for (const p of matchedForbidden) {
console.log(` ${FAIL} Forbidden pattern matched: ${DIM}${p.pattern}${RESET}`);
}
if (result.judge) {
console.log(
` ${DIM}Judge: ${result.judge.score}/5 - ${result.judge.reasoning}${RESET}`,
);
}
}
export function printCategoryHeader(category: string): void {
console.log(`\n${BOLD}${category}${RESET}`);
console.log(`${"─".repeat(70)}`);
}
export function computeSummary(
results: EvalResult[],
totalDurationMs: number,
): EvalSummary {
const byCategory: Record<Category, { total: number; passed: number }> = {
"skill-loading": { total: 0, passed: 0 },
"skill-selection": { total: 0, passed: 0 },
"command-usage": { total: 0, passed: 0 },
};
let passed = 0;
let failed = 0;
let errors = 0;
for (const r of results) {
byCategory[r.category].total++;
if (r.error) {
errors++;
} else if (r.pass) {
passed++;
byCategory[r.category].passed++;
} else {
failed++;
}
}
return {
total: results.length,
passed,
failed,
errors,
byCategory,
durationMs: totalDurationMs,
};
}
export function printSummary(summary: EvalSummary): void {
console.log(`\n${BOLD}Summary${RESET}`);
console.log(`${"═".repeat(70)}`);
for (const [cat, stats] of Object.entries(summary.byCategory)) {
if (stats.total === 0) continue;
const pct = Math.round((stats.passed / stats.total) * 100);
const bar = stats.passed === stats.total ? PASS : FAIL;
console.log(` ${bar} ${padRight(cat, 20)} ${stats.passed}/${stats.total} (${pct}%)`);
}
console.log(`${"─".repeat(70)}`);
const totalPct = summary.total > 0
? Math.round((summary.passed / summary.total) * 100)
: 0;
const icon = summary.failed === 0 && summary.errors === 0 ? PASS : FAIL;
console.log(
` ${icon} ${BOLD}Total: ${summary.passed}/${summary.total} passed (${totalPct}%)${RESET}`,
);
if (summary.errors > 0) {
console.log(` ${ERR} ${summary.errors} error(s)`);
}
console.log(` ${DIM}Duration: ${(summary.durationMs / 1000).toFixed(1)}s${RESET}\n`);
}
export function printResultsJson(
results: EvalResult[],
summary: EvalSummary,
): void {
const output = {
summary: {
total: summary.total,
passed: summary.passed,
failed: summary.failed,
errors: summary.errors,
passRate: summary.total > 0
? Math.round((summary.passed / summary.total) * 100)
: 0,
durationMs: summary.durationMs,
byCategory: summary.byCategory,
},
results: results.map((r) => ({
id: r.caseId,
name: r.caseName,
category: r.category,
pass: r.pass,
durationMs: r.durationMs,
error: r.error,
patterns: r.patternResults,
judge: r.judge,
response: r.response,
})),
};
console.log(JSON.stringify(output, null, 2));
}
-78
View File
@@ -1,78 +0,0 @@
export type Category = "skill-loading" | "skill-selection" | "command-usage";
export type ProviderName = "claude" | "codex";
export interface ProviderOptions {
model?: string;
timeout?: number;
}
export interface ProviderResponse {
output: string;
durationMs: number;
error?: string;
}
export interface Provider {
name: ProviderName;
defaultModel: string;
call(prompt: string, options?: ProviderOptions, context?: string): Promise<ProviderResponse>;
callRaw(prompt: string, options?: ProviderOptions): Promise<ProviderResponse>;
}
export interface EvalCase {
id: string;
name: string;
category: Category;
/** The user task prompt sent to the model */
prompt: string;
/** Additional context injected after the skill content (e.g., simulated skill output) */
context?: string;
/** Regex patterns that must all match in the response */
expectedPatterns: string[];
/** Regex patterns that must NOT match in the response */
forbiddenPatterns?: string[];
/** Rubric for LLM judge quality scoring (1-5) */
rubric?: string;
}
export interface PatternResult {
pattern: string;
matched: boolean;
type: "expected" | "forbidden";
}
export interface JudgeResult {
score: number;
reasoning: string;
}
export interface EvalResult {
caseId: string;
caseName: string;
category: Category;
pass: boolean;
patternResults: PatternResult[];
judge?: JudgeResult;
response: string;
durationMs: number;
error?: string;
}
export interface EvalSummary {
total: number;
passed: number;
failed: number;
errors: number;
byCategory: Record<Category, { total: number; passed: number }>;
durationMs: number;
}
export interface RunOptions {
provider: ProviderName;
model: string;
category?: Category;
judge: boolean;
json: boolean;
concurrency: number;
timeout: number;
}
-16
View File
@@ -1,16 +0,0 @@
{
"name": "agent-browser-evals",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"eval": "bun run run.ts",
"eval:claude": "bun run run.ts --provider claude",
"eval:codex": "bun run run.ts --provider codex",
"eval:judge": "bun run run.ts --judge",
"eval:json": "bun run run.ts --json"
},
"devDependencies": {
"bun-types": "^1.3.12"
}
}
-149
View File
@@ -1,149 +0,0 @@
import type {
EvalCase,
EvalResult,
Category,
ProviderName,
RunOptions,
} from "./lib/types.ts";
import { getProvider } from "./lib/providers.ts";
import { evaluate } from "./lib/judge.ts";
import {
printResult,
printCategoryHeader,
computeSummary,
printSummary,
printResultsJson,
} from "./lib/reporter.ts";
import { cases as skillLoadingCases } from "./cases/skill-loading.ts";
import { cases as skillSelectionCases } from "./cases/skill-selection.ts";
import { cases as commandUsageCases } from "./cases/command-usage.ts";
const ALL_CASES: EvalCase[] = [
...skillLoadingCases,
...skillSelectionCases,
...commandUsageCases,
];
function parseArgs(args: string[]): RunOptions {
const options: RunOptions = {
provider: "claude",
model: "",
judge: false,
json: false,
concurrency: 1,
timeout: 60_000,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--provider":
options.provider = (args[++i] ?? "claude") as ProviderName;
break;
case "--model":
options.model = args[++i] ?? "";
break;
case "--category":
options.category = args[++i] as Category;
break;
case "--judge":
options.judge = true;
break;
case "--json":
options.json = true;
break;
case "--timeout":
options.timeout = parseInt(args[++i] ?? "60000", 10);
break;
case "--help":
case "-h":
printUsage();
process.exit(0);
}
}
return options;
}
function printUsage(): void {
console.log(
`
agent-browser skills evals
Usage: bun run evals/run.ts [options]
Options:
--provider <name> Provider to use: claude, codex (default: claude)
--model <name> Model override (default: provider's default model)
--category <cat> Filter by category: skill-loading, skill-selection, command-usage
--judge Enable LLM judge for quality scoring (costs extra API calls)
--json Output results as JSON
--timeout <ms> Timeout per eval case in milliseconds (default: 60000)
--help, -h Show this help
Providers:
claude Uses Claude CLI via Vercel AI Gateway (default model: anthropic/claude-sonnet-4.6)
codex Uses Codex CLI via Vercel AI Gateway (default model: openai/o3)
`.trim(),
);
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const provider = getProvider(options.provider);
const model = options.model || provider.defaultModel;
let cases = ALL_CASES;
if (options.category) {
cases = cases.filter((c) => c.category === options.category);
}
if (cases.length === 0) {
console.error("No eval cases match the given filters.");
process.exit(1);
}
if (!options.json) {
console.log(
`\nRunning ${cases.length} eval(s) with provider=${provider.name} model=${model}` +
(options.judge ? " + LLM judge" : ""),
);
}
const results: EvalResult[] = [];
const startTime = performance.now();
let currentCategory: string | null = null;
for (const evalCase of cases) {
if (!options.json && evalCase.category !== currentCategory) {
currentCategory = evalCase.category;
printCategoryHeader(currentCategory);
}
const result = await evaluate(evalCase, provider, {
model,
judge: options.judge,
timeout: options.timeout,
});
results.push(result);
if (!options.json) {
printResult(result);
}
}
const totalDurationMs = Math.round(performance.now() - startTime);
const summary = computeSummary(results, totalDurationMs);
if (options.json) {
printResultsJson(results, summary);
} else {
printSummary(summary);
}
const exitCode = summary.failed > 0 || summary.errors > 0 ? 1 : 0;
process.exit(exitCode);
}
main();
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"types": ["bun-types"]
},
"include": ["*.ts", "lib/**/*.ts", "cases/**/*.ts"]
}
+1
View File
@@ -7,6 +7,7 @@
"bin",
"scripts",
"skills",
"skill-data",
"extensions"
],
"bin": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -1,822 +0,0 @@
"use client";
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
import { useAtomValue } from "jotai/react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import { getChatApiUrl, chatModelAtom, availableModelsAtom } from "@/store/chat";
import { activeSessionNameAtom } from "@/store/sessions";
import { ModelSelector } from "@/components/model-selector";
import { shikiTheme } from "@/lib/shiki-theme";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ArrowUp, Square, Trash2, ChevronRight, ImagePlus, X, Loader, Copy, Check, Download } from "lucide-react";
type ExtraProps = { node?: unknown };
type MdImgProps = React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps;
type MdHeadingProps = React.HTMLAttributes<HTMLHeadingElement> & ExtraProps;
type MdAnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
type MdPreProps = React.HTMLAttributes<HTMLPreElement> & ExtraProps;
type MdCodeProps = React.HTMLAttributes<HTMLElement> & ExtraProps;
const chatComponents = {
img: ({ node: _node, src, alt, ...props }: MdImgProps) => {
if (typeof src === "string" && src.startsWith("data:image/")) {
return <img src={src} alt={alt} className="rounded-md border border-border max-w-full my-1" {...props} />;
}
return null;
},
h1: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
h2: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
h3: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
h4: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
h5: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
h6: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
a: ({ node: _node, href, children, ...props }: MdAnchorProps) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
{...props}
>
{children}
</a>
),
pre: ({ node: _node, ...props }: MdPreProps) => (
<pre
className="text-[11px] bg-background border border-border rounded-md p-2 my-1.5 whitespace-pre-wrap break-all"
{...props}
/>
),
code: ({ className, children, node: _node, ...props }: MdCodeProps) => {
if (className?.includes("language-")) {
return <code className={className} {...props}>{children}</code>;
}
return (
<span
className="text-[11px] bg-secondary/60 px-1 py-0.5 rounded text-foreground font-mono break-all"
{...props}
>
{children}
</span>
);
},
};
const STORAGE_PREFIX = "dashboard-chat-";
const IMAGE_DATA_URL_RE = /data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g;
function stripImagesForStorage(messages: unknown[]): unknown[] {
const json = JSON.stringify(messages);
return JSON.parse(json.replace(IMAGE_DATA_URL_RE, "[image stripped]"));
}
const SUGGESTIONS = [
"Go to vercel.com",
"Take a screenshot",
"What's on the page?",
"Click the first link",
];
interface ToolInvocationPart {
type: string;
toolCallId: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
}
function isToolPart(part: { type: string }): part is ToolInvocationPart {
return part.type.startsWith("tool-");
}
function truncateOutput(text: string, maxLines = 30): string {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`;
}
function parseOutputObject(raw: unknown): Record<string, unknown> | null {
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch { /* not JSON */ }
return null;
}
if (typeof raw === "object" && raw !== null) return raw as Record<string, unknown>;
return null;
}
function formatOutput(raw: unknown): string | null {
if (typeof raw === "string") {
if (!raw.trim()) return null;
const obj = parseOutputObject(raw);
if (obj) {
if (typeof obj.text === "string" && obj.image) return obj.text as string;
const { image: _, ...rest } = obj;
return JSON.stringify(rest, null, 2);
}
return raw;
}
if (typeof raw === "object" && raw !== null) {
const r = raw as Record<string, unknown>;
if (typeof r.text === "string" && r.image) return r.text as string;
const { image: _, ...rest } = r;
return JSON.stringify(rest, null, 2);
}
return null;
}
function extractImageUrl(raw: unknown): string | null {
const obj = parseOutputObject(raw);
if (!obj) return null;
const img = obj.image;
if (typeof img === "string" && img.startsWith("data:image/")) return img;
return null;
}
function ToolCallBlock({ part, onImageLoad }: { part: ToolInvocationPart; onImageLoad?: () => void }) {
const [expanded, setExpanded] = useState(false);
const toolName = part.type.split("-").slice(1).join("-");
const command = (part.input as { command?: string })?.command ?? toolName;
const isDone = part.state === "output-available";
const isRunning = !isDone;
const output = isDone ? formatOutput(part.output) : null;
const hasOutput = !!output;
const imageUrl = isDone ? extractImageUrl(part.output) : null;
const canExpand = hasOutput && !isRunning;
return (
<div className="space-y-1.5">
<div
className={cn(
"rounded-md text-[10px] font-mono overflow-hidden border border-border",
canExpand && "cursor-pointer",
)}
onClick={() => canExpand && setExpanded(!expanded)}
>
<div className={cn(
"px-2 py-1 flex items-center gap-2",
expanded && hasOutput ? "border-b border-border bg-secondary/30" : "bg-secondary/30",
)}>
{isRunning ? (
<Loader className="size-3 shrink-0 animate-spin text-muted-foreground" />
) : (
<ChevronRight
className={cn(
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
expanded && "rotate-90",
)}
/>
)}
<span className={cn(
"truncate",
isRunning ? "text-foreground/80 shimmer-text" : "text-foreground/80",
)}>{command}</span>
</div>
{expanded && hasOutput && (
<div className="max-h-[300px] overflow-y-auto">
<pre className="px-2 py-1.5 text-foreground/80 whitespace-pre-wrap break-all leading-relaxed">
{truncateOutput(output)}
</pre>
</div>
)}
</div>
{imageUrl && (
<img
src={imageUrl}
alt="Screenshot"
className="rounded-md border border-border max-w-full"
onLoad={onImageLoad}
/>
)}
</div>
);
}
const DEFAULT_CONTEXT_WINDOW = 128000;
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n}`;
}
function ContextMeter({ used, total }: { used: number; total: number }) {
const ratio = Math.min(used / total, 1);
const size = 16;
const strokeWidth = 2;
const r = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * r;
const offset = circumference * (1 - ratio);
const color =
ratio > 0.9 ? "text-destructive" : ratio > 0.7 ? "text-yellow-500" : "text-muted-foreground/50";
return (
<div
className="relative shrink-0"
title={`${formatTokenCount(used)} / ${formatTokenCount(total)} tokens`}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-border"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className={cn(color, "transition-[stroke-dashoffset] duration-300")}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
</div>
);
}
const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
function useTimeAgo(ts: number | undefined) {
const [, setTick] = useState(0);
useEffect(() => {
if (!ts) return;
const id = setInterval(() => setTick((t) => t + 1), 30_000);
return () => clearInterval(id);
}, [ts]);
if (!ts) return "";
const diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 5) return "just now";
if (diff < 60) return `${diff}s ago`;
const mins = Math.floor(diff / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ago`;
}
function MessageFooter({ model, timestamp, text }: { model: string; timestamp?: number; text: string }) {
const [copied, setCopied] = useState(false);
const timeAgo = useTimeAgo(timestamp);
const shortModel = model.includes("/") ? model.split("/").pop()! : model;
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [text]);
return (
<div className="flex items-center gap-2 pt-0.5 text-[10px] text-muted-foreground/50">
<span>{shortModel}</span>
{timeAgo && (
<>
<span>·</span>
<span>{timeAgo}</span>
</>
)}
<button
type="button"
onClick={handleCopy}
className="ml-auto hover:text-muted-foreground transition-colors"
aria-label="Copy message"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
</div>
);
}
interface PendingImage {
file: File;
preview: string;
}
export function ChatPanel() {
const [input, setInput] = useState("");
const [errorDismissed, setErrorDismissed] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const defaultModel = useAtomValue(chatModelAtom);
const [selectedModel, setSelectedModel] = useState<string>(defaultModel || DEFAULT_MODEL);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const sessionName = useAtomValue(activeSessionNameAtom);
const chatId = sessionName || "default";
const storageKey = `${STORAGE_PREFIX}${chatId}`;
const sessionRef = useRef(chatId);
sessionRef.current = chatId;
const modelRef = useRef(selectedModel);
modelRef.current = selectedModel;
const messageTimestamps = useRef<Record<string, number>>({});
useEffect(() => {
if (defaultModel) setSelectedModel(defaultModel);
}, [defaultModel]);
const transport = useRef(
new DefaultChatTransport({
api: getChatApiUrl(),
body: () => ({
session: sessionRef.current,
model: modelRef.current,
}),
}),
).current;
const { messages, sendMessage, stop, status, setMessages, error } = useChat({
id: chatId,
transport,
onError: () => setErrorDismissed(false),
});
const visibleError = error && !errorDismissed ? error : undefined;
const isLoading = status === "streaming" || status === "submitted";
const hasMessages = messages.length > 0 || !!visibleError;
useEffect(() => {
for (const msg of messages) {
if (msg.role === "assistant" && !messageTimestamps.current[msg.id]) {
messageTimestamps.current[msg.id] = Date.now();
}
}
}, [messages]);
const models = useAtomValue(availableModelsAtom);
const estimatedTokens = useMemo(() => {
let total = 0;
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "text") total += estimateTokens(part.text);
else if (isToolPart(part)) {
if (part.input) total += estimateTokens(JSON.stringify(part.input));
if (part.output) {
const raw = typeof part.output === "string" ? part.output : JSON.stringify(part.output);
const stripped = raw.replace(/"image"\s*:\s*"data:[^"]*"/g, '"image":"[omitted]"');
total += estimateTokens(stripped);
}
}
}
}
return total;
}, [messages]);
const contextWindow = useMemo(() => {
const match = models.find((m) => m.id === selectedModel);
return match?.context_window ?? DEFAULT_CONTEXT_WINDOW;
}, [models, selectedModel]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, visibleError, scrollToBottom]);
// Restore messages from localStorage when chatId changes
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
return;
}
}
} catch {
// ignore
}
setMessages([]);
}, [chatId, storageKey, setMessages]);
// Persist messages to localStorage (strip base64 images to save space)
useEffect(() => {
if (isLoading) return;
if (messages.length === 0) {
localStorage.removeItem(storageKey);
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify(stripImagesForStorage(messages)));
} catch {
// ignore quota
}
}, [messages, isLoading, storageKey]);
const addImages = useCallback((files: FileList | null) => {
if (!files) return;
const images = Array.from(files).filter((f) => f.type.startsWith("image/"));
setPendingImages((prev) => [
...prev,
...images.map((file) => ({ file, preview: URL.createObjectURL(file) })),
]);
}, []);
const removeImage = useCallback((index: number) => {
setPendingImages((prev) => {
const next = [...prev];
URL.revokeObjectURL(next[index].preview);
next.splice(index, 1);
return next;
});
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if ((!input.trim() && pendingImages.length === 0) || isLoading) return;
const dt = new DataTransfer();
for (const img of pendingImages) dt.items.add(img.file);
const files = dt.files.length > 0 ? dt.files : undefined;
sendMessage({ text: input, files });
setInput("");
setPendingImages((prev) => {
for (const p of prev) URL.revokeObjectURL(p.preview);
return [];
});
},
[input, isLoading, sendMessage, pendingImages],
);
const lastCompactedId = useRef<string | null>(null);
useEffect(() => {
if (isLoading || messages.length === 0) return;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
if (!lastAssistant) return;
if (lastAssistant.id === lastCompactedId.current) return;
const meta = (lastAssistant as any).metadata as
| { compacted?: boolean; summary?: string; keepLastN?: number }
| undefined;
if (!meta?.compacted || typeof meta.keepLastN !== "number") return;
lastCompactedId.current = lastAssistant.id;
const keep = meta.keepLastN;
if (keep >= messages.length) return;
const summaryMsg = {
id: `compaction-${Date.now()}`,
role: "assistant" as const,
parts: [
{
type: "text" as const,
text: `*Earlier messages were summarized to stay within the context window.*`,
},
],
};
const kept = messages.slice(messages.length - keep);
setMessages([summaryMsg as any, ...kept]);
}, [isLoading, messages, setMessages]);
const handleClear = useCallback(() => {
setMessages([]);
setErrorDismissed(true);
localStorage.removeItem(storageKey);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setMessages, storageKey]);
const handleDownload = useCallback(() => {
const data = messages.map((msg) => ({
id: msg.id,
role: msg.role,
parts: msg.parts.map((p) => {
if (p.type === "text") return { type: "text", text: p.text };
if (p.type === "file") return { type: "file", filename: (p as any).filename };
if (isToolPart(p)) {
const out = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
const stripped = out?.replace(/"image":"data:[^"]*"/g, '"image":"[stripped]"');
return {
type: p.type,
toolName: (p as any).toolName,
state: (p as any).state,
input: (p as any).input,
output: stripped,
};
}
return { type: p.type };
}),
}));
const json = JSON.stringify({ session: chatId, model: selectedModel, messages: data }, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${chatId}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}, [messages, chatId, selectedModel]);
const hasVisibleContent = (parts: (typeof messages)[number]["parts"]): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || p.type === "file" || isToolPart(p),
);
};
return (
<div className="flex h-full flex-col">
{hasMessages && (
<div className="flex items-center justify-end gap-2 px-3 py-1.5 shrink-0 border-b border-border/40">
<button
onClick={handleDownload}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label="Download conversation"
>
<Download className="size-3" />
</button>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label="Clear conversation"
>
<Trash2 className="size-3" />
</button>
</div>
)}
<ScrollArea className="flex-1 min-h-0">
<div className="p-3 space-y-3">
{!hasMessages && !isLoading && (
<div className="space-y-2 pt-2">
<p className="text-[11px] text-muted-foreground">
Control the browser with natural language:
</p>
<div className="flex flex-wrap gap-1.5">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => sendMessage({ text: s })}
className="text-[10px] px-2 py-1 rounded-md border bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((message) => {
if (message.id.startsWith("compaction-")) {
return (
<div key={message.id} className="flex items-center gap-2 text-[10px] text-muted-foreground/60">
<div className="flex-1 border-t border-border/40" />
<span>Earlier messages summarized</span>
<div className="flex-1 border-t border-border/40" />
</div>
);
}
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="space-y-1.5">
{message.parts.some((p) => p.type === "file") && (
<div className="flex flex-wrap gap-1.5">
{message.parts
.filter((p): p is Extract<typeof p, { type: "file" }> => p.type === "file")
.map((p, i) => (
<img
key={i}
src={p.url}
alt={p.filename ?? "uploaded image"}
className="max-h-24 rounded-md border border-border object-cover"
/>
))}
</div>
)}
<div className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text")
.map((p) => p.text)
.join("")}
</div>
</div>
) : (
<div className="space-y-1.5">
{(() => {
type Group = { type: "tools" | "text"; items: (typeof message.parts)[number][] };
const groups: Group[] = [];
for (const part of message.parts) {
const groupType = isToolPart(part) ? "tools" : "text";
const last = groups[groups.length - 1];
if (last && last.type === groupType) {
last.items.push(part);
} else {
groups.push({ type: groupType, items: [part] });
}
}
return groups.map((group, gi) => {
if (group.type === "tools") {
return (
<div key={gi} className="space-y-0.5">
{group.items.map((part) => {
if (!isToolPart(part)) return null;
return <ToolCallBlock key={part.toolCallId} part={part} onImageLoad={scrollToBottom} />;
})}
</div>
);
}
const combinedText = group.items
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text" && !!p.text)
.map((p) => p.text)
.join("");
if (!combinedText) return null;
return (
<div key={gi} className="text-xs text-foreground">
<Streamdown
shikiTheme={shikiTheme}
controls={false}
components={chatComponents}
>
{combinedText}
</Streamdown>
</div>
);
});
})()}
{(() => {
const isLast = message === messages[messages.length - 1];
const isComplete = !isLast || !isLoading;
if (!isComplete) return null;
const fullText = message.parts
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text" && !!p.text)
.map((p) => p.text)
.join("");
return (
<MessageFooter
model={selectedModel}
timestamp={messageTimestamps.current[message.id]}
text={fullText}
/>
);
})()}
</div>
)}
</div>
);
})}
{isLoading && messages.length > 0 && (() => {
const lastMsg = messages[messages.length - 1];
const lastPart = lastMsg?.parts[lastMsg.parts.length - 1];
const noVisibleContent = !lastMsg || !hasVisibleContent(lastMsg.parts);
const lastIsCompletedTool = lastPart && isToolPart(lastPart) && lastPart.state === "output-available";
if (noVisibleContent || lastIsCompletedTool) {
return (
<span className="text-[11px] text-muted-foreground shimmer-text">
Working...
</span>
);
}
return null;
})()}
{visibleError && (
<div className="text-[10px] text-destructive/80 bg-destructive/10 rounded-md px-2 py-1.5">
{(() => {
try {
const parsed = JSON.parse(visibleError.message);
return parsed.message || parsed.error || visibleError.message;
} catch {
return visibleError.message || "Something went wrong.";
}
})()}
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<div className="shrink-0 border-t border-border">
<form onSubmit={handleSubmit}>
{pendingImages.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
{pendingImages.map((img, i) => (
<div key={img.preview} className="group relative">
<img
src={img.preview}
alt={img.file.name}
className="h-14 rounded-md border border-border object-cover"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -top-1.5 -right-1.5 hidden group-hover:flex size-4 items-center justify-center rounded-full bg-background border border-border text-muted-foreground hover:text-foreground"
>
<X className="size-2.5" />
</button>
</div>
))}
</div>
)}
<div className="px-3 pt-2 pb-1.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
placeholder="Ask something..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
const dt = new DataTransfer();
for (const f of imageFiles) dt.items.add(f);
addImages(dt.files);
}
}}
className="w-full bg-transparent text-xs text-foreground outline-none resize-none max-h-24 leading-relaxed placeholder:text-muted-foreground"
/>
</div>
<div className="flex items-center justify-between px-3 pb-2">
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
<div className="flex items-center gap-2">
{hasMessages && (
<ContextMeter used={estimatedTokens} total={contextWindow} />
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
addImages(e.target.files);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0 p-1"
aria-label="Attach image"
>
<ImagePlus className="size-3.5" />
</button>
{isLoading ? (
<button
type="button"
onClick={() => stop()}
className="bg-primary text-primary-foreground rounded-full p-1 hover:bg-primary/90 transition-colors shrink-0"
aria-label="Stop"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
disabled={!input.trim() && pendingImages.length === 0}
className="bg-primary text-primary-foreground rounded-full p-1 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
aria-label="Send message"
>
<ArrowUp className="size-3" />
</button>
)}
</div>
</div>
</form>
</div>
</div>
);
}
@@ -1,97 +0,0 @@
"use client";
import { useState } from "react";
import { useAtomValue } from "jotai/react";
import { availableModelsAtom } from "@/store/chat";
import { ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
function formatModelLabel(id: string): string {
const parts = id.split("/");
return parts.length > 1 ? parts.slice(1).join("/") : id;
}
function formatProvider(id: string): string {
const parts = id.split("/");
if (parts.length > 1) return parts[0];
return "";
}
interface ModelSelectorProps {
value: string;
onChange: (model: string) => void;
}
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const models = useAtomValue(availableModelsAtom);
const providers = new Map<string, typeof models>();
for (const m of models) {
const provider = formatProvider(m.id) || "other";
if (!providers.has(provider)) providers.set(provider, []);
providers.get(provider)!.push(m);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors truncate max-w-[180px]"
aria-label="Select model"
>
<span className="truncate">{formatModelLabel(value)}</span>
<ChevronDown className="h-2.5 w-2.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" side="top">
<Command>
<CommandInput placeholder="Filter models..." />
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{models.length > 0 ? (
Array.from(providers.entries()).map(([provider, providerModels]) => (
<CommandGroup key={provider} heading={provider}>
{providerModels.map((m) => (
<CommandItem
key={m.id}
value={m.id}
onSelect={() => {
onChange(m.id);
setOpen(false);
}}
>
<Check
className={cn(
"h-3 w-3 shrink-0",
value === m.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{formatModelLabel(m.id)}</span>
</CommandItem>
))}
</CommandGroup>
))
) : (
<CommandGroup>
<CommandItem value={value} onSelect={() => setOpen(false)}>
<Check className="h-3 w-3 shrink-0 opacity-100" />
<span className="truncate">{value}</span>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,10 +0,0 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -1,23 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
title={resolvedTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{resolvedTheme === "dark" ? (
<Sun className="size-3" />
) : (
<Moon className="size-3" />
)}
</button>
);
}
@@ -1,98 +0,0 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-2" cmdk-input-wrapper="">
<Search className="mr-1.5 h-3 w-3 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-8 w-full rounded-md bg-transparent py-1.5 text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[200px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-4 text-center text-xs text-muted-foreground" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1 text-xs outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};
@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -1,42 +0,0 @@
/**
* Centralized route building for dashboard API calls.
* All routes stay on the current dashboard origin so the UI also works
* behind forwarded or reverse-proxied URLs.
*/
/** Build a dashboard API path such as "/api/sessions". */
export function getDashboardApiPath(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
assertDashboardApiPath(normalizedPath);
return normalizedPath;
}
/** Build the same-origin per-session tabs endpoint proxied through the dashboard. */
export function getSessionTabsPath(port: number): string {
assertValidPort(port);
return `/api/session/${port}/tabs`;
}
/** Build the same-origin WebSocket URL for a session stream. */
export function getSessionStreamUrl(port: number): string {
assertValidPort(port);
const streamPath = `/api/session/${port}/stream`;
if (typeof window === "undefined") {
return streamPath;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${streamPath}`;
}
function assertDashboardApiPath(path: string): asserts path is string {
if (!path.startsWith("/api/")) {
throw new Error(`Assertion failed: Expected dashboard API path, got: ${path}`);
}
}
function assertValidPort(port: number): asserts port is number {
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Assertion failed: Invalid session port: ${port}`);
}
}
-160
View File
@@ -1,160 +0,0 @@
import type { ThemeRegistrationAny } from "streamdown";
const lightTheme: ThemeRegistrationAny = {
name: "dashboard-light",
type: "light",
colors: {
"editor.background": "transparent",
"editor.foreground": "#171717",
},
settings: [
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#6B7280" },
},
{
scope: [
"string",
"string.quoted",
"string.template",
"punctuation.definition.string",
],
settings: { foreground: "#067A6E" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#0070C0" },
},
{
scope: ["keyword", "storage.type", "storage.modifier"],
settings: { foreground: "#D6409F" },
},
{
scope: ["keyword.operator", "keyword.control"],
settings: { foreground: "#D6409F" },
},
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#6E56CF" },
},
{
scope: ["variable", "variable.other"],
settings: { foreground: "#171717" },
},
{
scope: ["variable.parameter"],
settings: { foreground: "#B45309" },
},
{
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
settings: { foreground: "#D6409F" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#6B7280" },
},
{
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
"punctuation.support.type.property-name",
],
settings: { foreground: "#D6409F" },
},
{
scope: ["entity.other.attribute-name"],
settings: { foreground: "#067A6E" },
},
{
scope: ["support.type.primitive", "entity.name.type.primitive"],
settings: { foreground: "#067A6E" },
},
],
};
const darkTheme: ThemeRegistrationAny = {
name: "dashboard-dark",
type: "dark",
colors: {
"editor.background": "transparent",
"editor.foreground": "#EDEDED",
},
settings: [
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#A1A1A1" },
},
{
scope: [
"string",
"string.quoted",
"string.template",
"punctuation.definition.string",
],
settings: { foreground: "#00CA50" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#47A8FF" },
},
{
scope: ["keyword", "storage.type", "storage.modifier"],
settings: { foreground: "#FF4D8D" },
},
{
scope: ["keyword.operator", "keyword.control"],
settings: { foreground: "#FF4D8D" },
},
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#C472FB" },
},
{
scope: ["variable", "variable.other"],
settings: { foreground: "#EDEDED" },
},
{
scope: ["variable.parameter"],
settings: { foreground: "#FF9300" },
},
{
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
settings: { foreground: "#FF4D8D" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#EDEDED" },
},
{
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
"punctuation.support.type.property-name",
],
settings: { foreground: "#FF4D8D" },
},
{
scope: ["entity.other.attribute-name"],
settings: { foreground: "#00CA50" },
},
{
scope: ["support.type.primitive", "entity.name.type.primitive"],
settings: { foreground: "#00CA50" },
},
],
};
export const shikiTheme: [ThemeRegistrationAny, ThemeRegistrationAny] = [
lightTheme,
darkTheme,
];
-81
View File
@@ -1,81 +0,0 @@
"use client";
import { atom } from "jotai";
import { useEffect } from "react";
import { useAtomCallback } from "jotai/utils";
import { useCallback } from "react";
const DAEMON_URL = process.env.NEXT_PUBLIC_DAEMON_URL || "";
function daemonBase(): string {
if (typeof window === "undefined" || !DAEMON_URL) return "";
try {
const daemon = new URL(DAEMON_URL);
if (window.location.host === daemon.host) return "";
return DAEMON_URL;
} catch {
return "";
}
}
function getChatStatusUrl(): string {
return `${daemonBase()}/api/chat/status`;
}
export function getChatApiUrl(): string {
return `${daemonBase()}/api/chat`;
}
export function getModelsApiUrl(): string {
return `${daemonBase()}/api/models`;
}
export interface ModelInfo {
id: string;
name?: string;
owned_by?: string;
context_window?: number;
}
export const chatEnabledAtom = atom(false);
export const chatModelAtom = atom<string | undefined>(undefined);
export const availableModelsAtom = atom<ModelInfo[]>([]);
export function useChatStatusSync() {
const fetchStatus = useAtomCallback(
useCallback(async (_get, set) => {
try {
const resp = await fetch(getChatStatusUrl());
if (resp.ok) {
const data = await resp.json();
set(chatEnabledAtom, !!data.enabled);
if (data.model) set(chatModelAtom, data.model);
}
} catch {
set(chatEnabledAtom, false);
}
try {
const resp = await fetch(getModelsApiUrl());
if (resp.ok) {
const data = await resp.json();
if (Array.isArray(data?.data)) {
const models: ModelInfo[] = data.data.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || undefined,
owned_by: (m.owned_by as string) || undefined,
context_window: typeof m.context_window === "number" ? m.context_window : undefined,
}));
models.sort((a, b) => a.id.localeCompare(b.id));
set(availableModelsAtom, models);
}
}
} catch {
// models fetch failed, leave empty
}
}, []),
);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
}