{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-4",
  "title": "Agent 4: prompt playground with model compare",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A prompt playground that runs one prompt on two models side by side: system prompt, {{variables}} that become fields, Run with ⌘Enter, each answer with time, tokens and cost, ratings and Pick this one.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/kbd.json",
    "https://ui.ballmac.com/r/model-picker.json",
    "https://ui.ballmac.com/r/textarea.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/use-resource.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/agent-4/agent-4.tsx",
      "content": "// Ballmac UI: Agent 4. https://ui.ballmac.com/blocks/agent-4\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, Loader2, Play, ThumbsDown, ThumbsUp, Trophy } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Kbd } from \"@/components/ballmac/kbd\"\nimport { ModelPicker, type ModelOption } from \"@/components/ballmac/model-picker\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { frameProps } from \"@/components/ballmac/state-view\"\nimport type { BlockState } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype Agent4Result = {\n  text: string\n  /** Milliseconds until the full answer. */\n  latencyMs?: number\n  tokens?: { input: number; output: number }\n  /** Already formatted (\"$0.004\"). */\n  cost?: string\n}\n\ntype Agent4Request = {\n  model: string\n  system: string\n  /** The prompt with every {{variable}} filled in. */\n  prompt: string\n  variables: Record<string, string>\n}\n\ntype Agent4Props = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  title?: string\n  /** Models to choose from on each side. */\n  models?: ModelOption[]\n  /** The models selected at first, for side A and side B. */\n  defaultModels?: [string, string]\n  defaultSystem?: string\n  /** The prompt. Write {{name}} for a variable and a field appears for it. */\n  defaultPrompt?: string\n  defaultVariables?: Record<string, string>\n  /** Answers to show before the first run, for example the last saved comparison. */\n  defaultResults?: { a?: Agent4Result; b?: Agent4Result }\n  /** Runs one side. Resolve with the answer; throw to show the error on that side. Defaults to a canned answer after a moment. */\n  onRun?: (request: Agent4Request) => Promise<Agent4Result>\n  /** Called when a side is rated. */\n  onRate?: (side: \"a\" | \"b\", rating: \"up\" | \"down\" | null, request: Agent4Request) => void\n  /** Called by \"Pick this one\" with the winning side's request and answer. */\n  onPick?: (side: \"a\" | \"b\", request: Agent4Request, result: Agent4Result) => void\n  /** Drive both sides: `submitting` shows them running, `error` shows the error on both. Leave out to let the block run itself. */\n  state?: BlockState\n  errorState?: { title?: string; description?: string }\n  /** Locale for numbers. */\n  locale?: string\n}\n\nconst sampleModels: ModelOption[] = [\n  { id: \"lyra-xl\", name: \"Lyra XL\", provider: \"Lyra\", description: \"Best for hard problems and careful writing.\", capabilities: [\"vision\", \"reasoning\", \"tools\"], contextWindow: 1_000_000, cost: 3 },\n  { id: \"lyra-mini\", name: \"Lyra Mini\", provider: \"Lyra\", description: \"Near-instant replies for simple tasks.\", capabilities: [\"fast\"], contextWindow: 128_000, cost: 1 },\n  { id: \"orion-chat\", name: \"Orion Chat\", provider: \"Orion\", description: \"Friendly conversation with strong multilingual skill.\", capabilities: [\"vision\", \"fast\"], contextWindow: 128_000, cost: 1 },\n]\n\nconst canned: Record<string, Agent4Result> = {\n  \"lyra-xl\": {\n    text: \"Hi Jordan,\\n\\nYou were charged twice for order 4821. I’ve refunded the second charge of $129.00; it will reach your card in 5 to 10 business days. Your order is unaffected.\\n\\nSorry for the trouble,\\nSam from Acme\",\n    latencyMs: 2140,\n    tokens: { input: 212, output: 71 },\n    cost: \"$0.0041\",\n  },\n  \"lyra-mini\": {\n    text: \"Hi Jordan, we refunded the duplicate $129.00 charge on order 4821. Expect it in 5–10 business days. Sorry about that! – Sam\",\n    latencyMs: 480,\n    tokens: { input: 212, output: 38 },\n    cost: \"$0.0003\",\n  },\n}\n\nconst VARIABLE = /\\{\\{\\s*([a-zA-Z_][\\w]*)\\s*\\}\\}/g\nconst fill = (text: string, vars: Record<string, string>) => text.replace(VARIABLE, (_, name: string) => vars[name] ?? \"\")\n\ntype Side = { status: \"idle\" | \"running\" | \"done\" | \"error\"; result?: Agent4Result; error?: string; rating: \"up\" | \"down\" | null }\n\n/**\n * A prompt playground that runs the same prompt on two models side by side: a system prompt, a prompt with {{variables}} that turn\n * into fields, Run with ⌘Enter, each answer with its latency, tokens and cost, thumbs up or down, and \"Pick this one\".\n */\nfunction Agent4({\n  title = \"Compare models\",\n  models = sampleModels,\n  defaultModels = [\"lyra-xl\", \"lyra-mini\"],\n  defaultSystem = \"You are a support agent for Acme. Be warm, brief and specific. Never promise what you cannot do.\",\n  defaultPrompt = \"Write a reply to {{customer}} about their duplicate charge on order {{order}}. The refund was issued today.\",\n  defaultVariables = { customer: \"Jordan\", order: \"4821\" },\n  defaultResults,\n  onRun,\n  onRate,\n  onPick,\n  state,\n  errorState,\n  locale = \"en-US\",\n  className,\n  ...props\n}: Agent4Props) {\n  const id = React.useId()\n  const nf = new Intl.NumberFormat(locale)\n  const [system, setSystem] = React.useState(defaultSystem)\n  const [prompt, setPrompt] = React.useState(defaultPrompt)\n  const [vars, setVars] = React.useState(defaultVariables)\n  const [picked, setPicked] = React.useState<\"a\" | \"b\" | null>(null)\n  const [chosen, setChosen] = React.useState<{ a: string; b: string }>({ a: defaultModels[0], b: defaultModels[1] })\n  const [sides, setSides] = React.useState<{ a: Side; b: Side }>(() => {\n    const start = (result?: Agent4Result): Side => (result ? { status: \"done\", result, rating: null } : { status: \"idle\", rating: null })\n    return { a: start(defaultResults?.a), b: start(defaultResults?.b) }\n  })\n  const names = [...new Set([...prompt.matchAll(VARIABLE)].map((m) => m[1]!))]\n  const missing = names.filter((n) => !vars[n]?.trim())\n\n  const request = (side: \"a\" | \"b\"): Agent4Request => ({ model: chosen[side], system, prompt: fill(prompt, vars), variables: vars })\n\n  async function runSide(side: \"a\" | \"b\") {\n    setSides((s) => ({ ...s, [side]: { status: \"running\", rating: null } }))\n    try {\n      const req = request(side)\n      const result = onRun\n        ? await onRun(req)\n        : await new Promise<Agent4Result>((r) => setTimeout(() => r(canned[req.model] ?? { text: `(${req.model}) ${req.prompt}`, latencyMs: 900, tokens: { input: 200, output: 40 } }), side === \"a\" ? 1100 : 500))\n      setSides((s) => ({ ...s, [side]: { status: \"done\", result, rating: null } }))\n    } catch (err) {\n      setSides((s) => ({ ...s, [side]: { status: \"error\", error: err instanceof Error ? err.message : undefined, rating: null } }))\n    }\n  }\n\n  const run = () => {\n    if (missing.length || !prompt.trim()) return\n    setPicked(null)\n    runSide(\"a\")\n    runSide(\"b\")\n  }\n\n  const view = (side: \"a\" | \"b\"): Side =>\n    state === \"submitting\" ? { status: \"running\", rating: null } : state === \"error\" ? { status: \"error\", error: errorState?.description, rating: null } : sides[side]\n  const busy = view(\"a\").status === \"running\" || view(\"b\").status === \"running\"\n\n  return (\n    <section data-slot=\"agent-4\" data-state={state ?? (busy ? \"submitting\" : \"ready\")} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"flex flex-wrap items-center gap-3\">\n        <h2 id={`${id}-title`} className=\"flex-1 text-2xl font-semibold tracking-[-0.03em]\">\n          {title}\n        </h2>\n        <span className=\"text-muted-foreground hidden items-center gap-1 text-xs sm:flex\">\n          <Kbd>⌘</Kbd>\n          <Kbd>Enter</Kbd> to run\n        </span>\n        <Button onClick={run} disabled={busy || missing.length > 0 || !prompt.trim()} aria-describedby={missing.length ? `${id}-missing` : undefined}>\n          {busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Play aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />}\n          {busy ? \"Running…\" : \"Run both\"}\n        </Button>\n      </header>\n\n      <div\n        className=\"mt-6 grid gap-4 lg:grid-cols-2\"\n        onKeyDown={(e) => {\n          if ((e.metaKey || e.ctrlKey) && e.key === \"Enter\") {\n            e.preventDefault()\n            run()\n          }\n        }}\n      >\n        <div className=\"grid content-start gap-1.5\">\n          <label htmlFor={`${id}-system`} className=\"text-sm font-medium\">\n            System prompt\n          </label>\n          <Textarea id={`${id}-system`} rows={4} value={system} onChange={(e) => setSystem(e.target.value)} className=\"resize-y text-[13px]\" />\n        </div>\n        <div className=\"grid content-start gap-1.5\">\n          <label htmlFor={`${id}-prompt`} className=\"text-sm font-medium\">\n            Prompt\n          </label>\n          <Textarea id={`${id}-prompt`} rows={4} value={prompt} onChange={(e) => setPrompt(e.target.value)} aria-describedby={`${id}-vars-hint`} className=\"resize-y text-[13px]\" />\n          <p id={`${id}-vars-hint`} className=\"text-muted-foreground text-xs\">\n            Write <code dir=\"ltr\" className=\"font-mono\">{\"{{name}}\"}</code> for a variable.\n          </p>\n        </div>\n        {names.length ? (\n          <fieldset className=\"grid gap-3 rounded-xl border p-4 sm:grid-cols-2 lg:col-span-2 lg:grid-cols-4\">\n            <legend className=\"px-1 text-sm font-medium\">Variables</legend>\n            {names.map((n) => (\n              <div key={n} className=\"grid gap-1\">\n                <label htmlFor={`${id}-var-${n}`} dir=\"ltr\" className=\"text-start font-mono text-xs\">\n                  {n}\n                </label>\n                <input\n                  id={`${id}-var-${n}`}\n                  value={vars[n] ?? \"\"}\n                  onChange={(e) => setVars((v) => ({ ...v, [n]: e.target.value }))}\n                  aria-invalid={!vars[n]?.trim() || undefined}\n                  className=\"border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive h-8 rounded-md border px-2.5 text-sm outline-none focus-visible:ring-[3px]\"\n                />\n              </div>\n            ))}\n            {missing.length ? (\n              <p id={`${id}-missing`} className=\"text-destructive text-xs sm:col-span-2 lg:col-span-4\">\n                Fill in {missing.join(\", \")} to run.\n              </p>\n            ) : null}\n          </fieldset>\n        ) : null}\n      </div>\n\n      <div className=\"mt-6 grid gap-4 lg:grid-cols-2\">\n        {([\"a\", \"b\"] as const).map((side) => {\n          const s = view(side)\n          const label = side === \"a\" ? \"A\" : \"B\"\n          const model = models.find((m) => m.id === chosen[side])\n          return (\n            <article\n              key={side}\n              aria-label={`Side ${label}: ${model?.name ?? chosen[side]}`}\n              aria-busy={s.status === \"running\" || undefined}\n              className={cn(\"bg-card flex min-h-64 flex-col rounded-xl border\", picked === side && \"border-chart-2 ring-chart-2/25 ring-2\")}\n            >\n              <div className=\"flex items-center gap-2 border-b p-3\">\n                <span aria-hidden=\"true\" className=\"bg-muted flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-semibold\">\n                  {label}\n                </span>\n                <ModelPicker models={models} value={chosen[side]} onValueChange={(m) => setChosen((c) => ({ ...c, [side]: m }))} variant=\"compact\" disabled={busy} aria-label={`Model for side ${label}`} />\n                {picked === side ? (\n                  <span className=\"text-foreground ms-auto inline-flex items-center gap-1 text-xs font-medium\">\n                    <Trophy aria-hidden=\"true\" className=\"text-chart-2 size-3.5\" />\n                    Picked\n                  </span>\n                ) : null}\n              </div>\n              <div className=\"flex-1 p-4\" aria-live=\"polite\">\n                {s.status === \"running\" ? (\n                  <p className=\"text-muted-foreground flex items-center gap-2 text-sm\">\n                    <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />\n                    Running…\n                  </p>\n                ) : s.status === \"error\" ? (\n                  <div role=\"alert\" className=\"flex gap-2 text-sm\">\n                    <CircleAlert aria-hidden=\"true\" className=\"text-destructive mt-0.5 size-4 shrink-0\" />\n                    <div>\n                      <p className=\"font-medium\">{errorState?.title ?? \"This model did not answer\"}</p>\n                      <p className=\"text-muted-foreground mt-0.5 text-[13px]\">{s.error ?? \"Check the model’s limits and your key, then run again.\"}</p>\n                    </div>\n                  </div>\n                ) : s.result ? (\n                  <p className=\"text-sm leading-relaxed whitespace-pre-wrap text-pretty\">{s.result.text}</p>\n                ) : (\n                  <p className=\"text-muted-foreground text-sm\">Run to see what {model?.name ?? \"this model\"} answers.</p>\n                )}\n              </div>\n              {s.status === \"done\" && s.result ? (\n                <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-2.5\">\n                  <dl className=\"text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs tabular-nums\">\n                    {s.result.latencyMs !== undefined ? (\n                      <div className=\"flex gap-1\">\n                        <dt>Time</dt>\n                        <dd className=\"text-foreground\">{(s.result.latencyMs / 1000).toFixed(2)} s</dd>\n                      </div>\n                    ) : null}\n                    {s.result.tokens ? (\n                      <div className=\"flex gap-1\">\n                        <dt>Tokens</dt>\n                        <dd className=\"text-foreground\">\n                          {nf.format(s.result.tokens.input)} in · {nf.format(s.result.tokens.output)} out\n                        </dd>\n                      </div>\n                    ) : null}\n                    {s.result.cost ? (\n                      <div className=\"flex gap-1\">\n                        <dt>Cost</dt>\n                        <dd className=\"text-foreground\">{s.result.cost}</dd>\n                      </div>\n                    ) : null}\n                  </dl>\n                  <div className=\"ms-auto flex items-center gap-1\">\n                    {([\"up\", \"down\"] as const).map((r) => (\n                      <Button\n                        key={r}\n                        variant=\"ghost\"\n                        size=\"icon-sm\"\n                        aria-label={r === \"up\" ? `Good answer from side ${label}` : `Poor answer from side ${label}`}\n                        aria-pressed={s.rating === r}\n                        className=\"aria-pressed:bg-accent\"\n                        onClick={() => {\n                          const next = s.rating === r ? null : r\n                          setSides((all) => ({ ...all, [side]: { ...all[side], rating: next } }))\n                          onRate?.(side, next, request(side))\n                        }}\n                      >\n                        {r === \"up\" ? <ThumbsUp aria-hidden=\"true\" /> : <ThumbsDown aria-hidden=\"true\" />}\n                      </Button>\n                    ))}\n                    <Button\n                      variant={picked === side ? \"secondary\" : \"outline\"}\n                      size=\"sm\"\n                      aria-pressed={picked === side}\n                      onClick={() => {\n                        setPicked(side)\n                        onPick?.(side, request(side), s.result!)\n                      }}\n                    >\n                      Pick this one\n                    </Button>\n                  </div>\n                </div>\n              ) : null}\n            </article>\n          )\n        })}\n      </div>\n    </section>\n  )\n}\n\nexport { Agent4, type Agent4Props, type Agent4Request, type Agent4Result }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/agent-4/agent-4.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "llm",
      "prompt",
      "playground",
      "compare",
      "models",
      "evaluation",
      "a/b",
      "variables",
      "prompt engineering"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A side-by-side prompt playground. <Agent4 models={ModelOption[]} defaultModels={['a', 'b']} defaultSystem defaultPrompt=\"… {{customer}} …\" defaultVariables={{ customer: 'Jordan' }} onRun={async ({ model, system, prompt, variables }) => ({ text, latencyMs, tokens: { input, output }, cost })} onRate onPick />. Variables are detected from {{name}} in the prompt and must be filled before running.",
      "whenToUse": [
        "Choosing a model or prompt for a feature by comparing real answers",
        "An internal tool for prompt iteration with ratings saved for evaluation"
      ],
      "whenNotToUse": [
        "A chat with one model (use ai-chat-1)",
        "Watching an agent run (use agent-1)"
      ],
      "composesWith": [
        "agent-3",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "⌘Enter or Ctrl+Enter in any field",
          "action": "Runs both sides"
        },
        {
          "keys": "Answers",
          "action": "Each side is a live region and is marked busy while running; errors are alerts"
        },
        {
          "keys": "Tab",
          "action": "Rating buttons are toggles named for their side"
        }
      ],
      "customization": [
        "AI SDK: onRun={async ({ model, system, prompt }) => { const t = performance.now(); const r = await generateText({ model, system, prompt }); return { text: r.text, latencyMs: performance.now() - t, tokens: { input: r.usage.inputTokens, output: r.usage.outputTokens } } }}",
        "defaultResults shows a saved comparison before the first run",
        "onRate and onPick receive the full request, so ratings can be stored as eval data"
      ]
    },
    "states": [
      {
        "id": "submitting",
        "title": "Running",
        "example": "agent-4-running",
        "trigger": "state=\"submitting\"",
        "note": "Both sides show Running…, are marked busy, and Run and the model pickers are disabled."
      },
      {
        "id": "error",
        "title": "A model did not answer",
        "example": "agent-4-error",
        "trigger": "state=\"error\" (or onRun throws)",
        "note": "The side shows an alert with the reason; errorState sets the copy. A thrown Error's message is shown on that side only."
      }
    ],
    "examples": [
      "agent-4-demo",
      "agent-4-fresh",
      "agent-4-running",
      "agent-4-error"
    ],
    "url": "https://ui.ballmac.com/components/agent-4"
  },
  "categories": [
    "blocks",
    "agents"
  ],
  "type": "registry:block"
}