{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-8",
  "title": "Agent 8: knowledge manager",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A knowledge manager for an AI agent: a health bar across its sources, each source with sync progress or failure and a retry, a switch for whether the agent may use it, and a panel showing which passages come back for a question.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/blur-fade.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dropdown-menu.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/agent-8/agent-8.tsx",
      "content": "// Ballmac UI: Agent 8. https://ui.ballmac.com/blocks/agent-8\n\"use client\"\n\nimport * as React from \"react\"\nimport { BookOpen, ChevronDown, CircleAlert, FileText, Globe, Loader2, Plus, RefreshCw, Search, SearchX, Trash2, Type } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { BlurFadeGroup } from \"@/components/ballmac/blur-fade\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from \"@/components/ballmac/dropdown-menu\"\nimport { countMatches, MatchHighlight, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { cn } from \"@/lib/utils\"\n\ntype Agent8Kind = \"website\" | \"file\" | \"text\"\n\ntype Agent8Source = {\n  id: string\n  name: string\n  kind: Agent8Kind\n  /** A web address, a file name or a short note about where it came from. */\n  location: string\n  status: \"ready\" | \"syncing\" | \"failed\"\n  /** From 0 to 1 while syncing. */\n  progress?: number\n  /** How many passages the agent can find in it. */\n  passages?: number\n  /** Already written for people (\"Synced 2 hours ago\"). */\n  synced?: string\n  /** Why it failed. */\n  error?: string\n  /** Whether the agent may use it. */\n  enabled?: boolean\n  /** A few representative passages. The built-in test searches these. */\n  excerpts?: string[]\n}\n\ntype Agent8Passage = { sourceId: string; text: string; /** 0 to 1 */ score?: number }\n\ntype Agent8Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    sources?: Agent8Source[]\n    title?: string\n    /** Questions offered as shortcuts in the test panel. */\n    exampleQuestions?: string[]\n    /** Called by Add source with the kind chosen. */\n    onAdd?: (kind: Agent8Kind) => void\n    /** Called by Sync now on a source, or Retry on a failed one. */\n    onResync?: (id: string) => void\n    /** Called after Remove is confirmed. */\n    onRemove?: (id: string) => void\n    /** Called when a source is turned on or off for the agent. */\n    onToggle?: (id: string, enabled: boolean) => void\n    /**\n     * Finds what the agent would find for a question. Return the passages with their source, and optionally an answer. Without it the\n     * block searches the sources' excerpts, so you can see the idea working.\n     */\n    onAsk?: (question: string, sources: Agent8Source[]) => Promise<{ passages: Agent8Passage[]; answer?: string }>\n    /** The question shown in the test panel at first, and run straight away. */\n    defaultQuestion?: string\n  }\n\nconst sample: Agent8Source[] = [\n  {\n    id: \"help\",\n    name: \"Help center\",\n    kind: \"website\",\n    location: \"help.acme.com\",\n    status: \"ready\",\n    passages: 612,\n    synced: \"Synced 2 hours ago\",\n    enabled: true,\n    excerpts: [\n      \"To download an invoice, open Billing, choose Invoices and select the PDF icon next to the month you need. Invoices include your company name and tax number.\",\n      \"A refund for a duplicate charge usually reaches the customer's card within 5 to 10 business days, depending on their bank.\",\n      \"To reset your password, choose Forgot password on the sign-in page. The link we email you works for one hour.\",\n    ],\n  },\n  {\n    id: \"policy\",\n    name: \"Refund policy.pdf\",\n    kind: \"file\",\n    location: \"Uploaded last week · 14 pages\",\n    status: \"ready\",\n    passages: 48,\n    synced: \"Added last week\",\n    enabled: true,\n    excerpts: [\n      \"Refunds under $200 for duplicate charges may be issued by support without approval. Larger refunds need a manager's approval and a written reason.\",\n      \"Annual plans can be refunded in full within 30 days of purchase. After that, unused months are credited to the account instead.\",\n    ],\n  },\n  { id: \"macros\", name: \"Support macros\", kind: \"file\", location: \"macros.csv · 220 rows\", status: \"syncing\", progress: 0.58, synced: \"Reading rows…\", enabled: true },\n  { id: \"wiki\", name: \"Internal wiki\", kind: \"website\", location: \"wiki.acme.com\", status: \"failed\", error: \"Could not sign in. The saved password has expired.\", enabled: false },\n  { id: \"tone\", name: \"Tone of voice\", kind: \"text\", location: \"Pasted note\", status: \"ready\", passages: 6, synced: \"Added 3 weeks ago\", enabled: true, excerpts: [\"Write the way a thoughtful colleague would speak: warm, brief and specific. Never use exclamation marks in refunds or incident messages.\"] },\n]\n\nconst kindIcon = { website: Globe, file: FileText, text: Type } as const\nconst kindTone = { website: \"bg-chart-1/20\", file: \"bg-chart-4/20\", text: \"bg-chart-3/25\" } as const\nconst addOptions: { kind: Agent8Kind; label: string; hint: string }[] = [\n  { kind: \"website\", label: \"A website\", hint: \"Read pages from an address\" },\n  { kind: \"file\", label: \"Files\", hint: \"PDFs, documents and spreadsheets\" },\n  { kind: \"text\", label: \"Pasted text\", hint: \"Notes, policies, a few paragraphs\" },\n]\n\nfunction KnowledgeSkeleton() {\n  return (\n    <div className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]\" aria-hidden=\"true\">\n      <div className=\"grid gap-3\">\n        <Skeleton className=\"h-24 rounded-2xl\" />\n        {[0, 1, 2].map((i) => (\n          <Skeleton key={i} className=\"h-28 rounded-2xl\" />\n        ))}\n      </div>\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A knowledge manager for an AI agent: how healthy its sources are at a glance, each source with its sync state, progress or failure\n * (and a way to retry), a switch for whether the agent may use it, and a panel that shows which passages the sources return for a question.\n */\nfunction Agent8({\n  sources: sourcesProp = sample,\n  title = \"Knowledge\",\n  exampleQuestions = [\"How long does a refund take?\", \"Can support refund $150 without approval?\", \"How do I download an invoice?\"],\n  onAdd,\n  onResync,\n  onRemove,\n  onToggle,\n  onAsk,\n  defaultQuestion,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Agent8Props) {\n  const id = React.useId()\n  const [sources, setSources] = React.useState(sourcesProp)\n  React.useEffect(() => setSources(sourcesProp), [sourcesProp])\n  const [confirm, setConfirm] = React.useState<Agent8Source | null>(null)\n  const returnFocus = useReturnFocus(!!confirm)\n  const [question, setQuestion] = React.useState(defaultQuestion ?? \"\")\n  const [asking, setAsking] = React.useState(false)\n  const [asked, setAsked] = React.useState<string | null>(null)\n  const [found, setFound] = React.useState<{ passages: Agent8Passage[]; answer?: string } | null>(null)\n  const [askError, setAskError] = React.useState<string>()\n  const [announce, setAnnounce] = React.useState(\"\")\n  const ready = state === \"ready\" || state === \"empty\"\n  const list = state === \"empty\" ? [] : sources\n\n  const counts = { ready: list.filter((s) => s.status === \"ready\").length, syncing: list.filter((s) => s.status === \"syncing\").length, failed: list.filter((s) => s.status === \"failed\").length }\n  const passages = list.reduce((sum, s) => sum + (s.passages ?? 0), 0)\n  const usable = list.filter((s) => s.enabled && s.status === \"ready\")\n\n  async function ask(q: string) {\n    const text = q.trim()\n    if (!text) return\n    setAsking(true)\n    setAskError(undefined)\n    setAsked(text)\n    try {\n      if (onAsk) setFound(await onAsk(text, usable))\n      else {\n        await new Promise((r) => setTimeout(r, 450))\n        const tokens = queryTokens(text).filter((t) => t.length > 2)\n        const hits = usable\n          .flatMap((s) => (s.excerpts ?? []).map((p) => ({ sourceId: s.id, text: p, raw: countMatches(p, tokens) })))\n          .filter((h) => h.raw > 0)\n          .sort((a, b) => b.raw - a.raw)\n          .slice(0, 4)\n        const max = Math.max(1, ...hits.map((h) => h.raw))\n        setFound({ passages: hits.map((h) => ({ sourceId: h.sourceId, text: h.text, score: h.raw / max })) })\n      }\n    } catch (err) {\n      setFound(null)\n      setAskError(err instanceof Error && err.message ? err.message : \"The test could not run. Try again.\")\n    } finally {\n      setAsking(false)\n    }\n  }\n\n  // run the starting question once, so the panel opens showing what it does\n  const started = React.useRef(false)\n  React.useEffect(() => {\n    if (defaultQuestion && !started.current && ready) {\n      started.current = true\n      ask(defaultQuestion)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  const sourceOf = (sid: string) => sources.find((s) => s.id === sid)\n\n  const addMenu = (\n    <DropdownMenu>\n      <DropdownMenuTrigger className=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md px-4 text-sm font-medium outline-none focus-visible:ring-[3px]\">\n        <Plus aria-hidden=\"true\" className=\"size-4\" />\n        Add source\n        <ChevronDown aria-hidden=\"true\" className=\"size-3.5 opacity-70\" />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-64\">\n        {addOptions.map((o) => {\n          const Icon = kindIcon[o.kind]\n          return (\n            <DropdownMenuItem key={o.kind} onSelect={() => onAdd?.(o.kind)} className=\"items-start gap-3 py-2\">\n              <Icon aria-hidden=\"true\" className=\"mt-0.5 size-4\" />\n              <span>\n                <span className=\"block text-sm font-medium\">{o.label}</span>\n                <span className=\"text-muted-foreground block text-xs\">{o.hint}</span>\n              </span>\n            </DropdownMenuItem>\n          )\n        })}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-3xl border px-6 py-14 text-center sm:px-12\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-14 items-center justify-center rounded-2xl\">\n        <BookOpen className=\"size-7\" />\n      </span>\n      <h2 className=\"mt-5 text-xl font-semibold tracking-tight\">{emptyState?.title ?? \"Teach your agent what you know\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-2 max-w-md text-sm text-pretty\">{emptyState?.description ?? \"Add the pages, files and notes it should read before it answers. It will only use what you give it.\"}</p>\n      <div className=\"mx-auto mt-8 grid max-w-2xl gap-3 sm:grid-cols-3\">\n        {addOptions.map((o) => {\n          const Icon = kindIcon[o.kind]\n          return (\n            <button key={o.kind} type=\"button\" onClick={() => onAdd?.(o.kind)} className=\"hover:bg-accent/50 hover:border-foreground/25 focus-visible:ring-ring/50 grid justify-items-center gap-2 rounded-2xl border p-5 text-center outline-none transition-colors focus-visible:ring-[3px]\">\n              <span aria-hidden=\"true\" className={cn(\"flex size-10 items-center justify-center rounded-xl [&_svg]:size-5\", kindTone[o.kind])}>\n                <Icon />\n              </span>\n              <span className=\"text-sm font-semibold\">{o.label}</span>\n              <span className=\"text-muted-foreground text-xs text-pretty\">{o.hint}</span>\n            </button>\n          )\n        })}\n      </div>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]\">\n      <div className=\"grid min-w-0 gap-4\">\n        <div className=\"bg-card rounded-2xl border p-5\">\n          <div role=\"img\" aria-label={`${counts.ready} ready, ${counts.syncing} syncing, ${counts.failed} failed`} className=\"bg-muted flex h-2 gap-0.5 overflow-hidden rounded-full\">\n            {counts.ready ? <span className=\"bg-chart-2\" style={{ flexGrow: counts.ready }} /> : null}\n            {counts.syncing ? <span className=\"bg-chart-3\" style={{ flexGrow: counts.syncing }} /> : null}\n            {counts.failed ? <span className=\"bg-destructive\" style={{ flexGrow: counts.failed }} /> : null}\n          </div>\n          <div className=\"mt-4 flex flex-wrap items-end justify-between gap-x-8 gap-y-3\">\n            <dl className=\"flex flex-wrap gap-x-8 gap-y-2\">\n              <div>\n                <dt className=\"text-muted-foreground text-xs\">Sources</dt>\n                <dd className=\"text-2xl font-semibold tracking-tight tabular-nums\">{list.length}</dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground text-xs\">Passages</dt>\n                <dd className=\"text-2xl font-semibold tracking-tight tabular-nums\">{new Intl.NumberFormat(\"en-US\").format(passages)}</dd>\n              </div>\n            </dl>\n            <ul className=\"text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs\">\n              <li className=\"flex items-center gap-1.5\"><span aria-hidden=\"true\" className=\"bg-chart-2 size-2 rounded-full\" />{counts.ready} ready</li>\n              <li className=\"flex items-center gap-1.5\"><span aria-hidden=\"true\" className=\"bg-chart-3 size-2 rounded-full\" />{counts.syncing} syncing</li>\n              <li className=\"flex items-center gap-1.5\"><span aria-hidden=\"true\" className=\"bg-destructive size-2 rounded-full\" />{counts.failed} failed</li>\n            </ul>\n          </div>\n        </div>\n\n        <BlurFadeGroup role=\"list\" itemAs=\"li\" stagger={0.05} item={{ direction: \"up\", offset: 8, blur: 4 }} className=\"grid list-none grid-cols-1 gap-3\">\n          {list.map((s) => {\n            const Icon = kindIcon[s.kind]\n            return (\n              <div key={s.id} className={cn(\"bg-card rounded-2xl border p-4 transition-colors duration-200\", s.status === \"failed\" && \"border-destructive/40\")}>\n                <div className=\"flex items-start gap-4\">\n                  <span aria-hidden=\"true\" className={cn(\"flex size-11 shrink-0 items-center justify-center rounded-xl [&_svg]:size-5\", kindTone[s.kind])}>\n                    <Icon />\n                  </span>\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"flex flex-wrap items-center gap-x-2.5 gap-y-1\">\n                      <h3 id={`${id}-${s.id}`} className=\"text-sm font-semibold\">\n                        {s.name}\n                      </h3>\n                      {s.status === \"ready\" ? <Badge status=\"success\" className=\"font-normal\">Ready</Badge> : s.status === \"syncing\" ? <Badge status=\"warning\" className=\"font-normal\">Syncing</Badge> : <Badge status=\"error\" className=\"font-normal\">Failed</Badge>}\n                    </div>\n                    <p dir=\"ltr\" className=\"text-muted-foreground mt-0.5 truncate text-start text-xs\">\n                      {s.location}\n                    </p>\n                    {s.status === \"syncing\" ? (\n                      <div className=\"mt-3\">\n                        <div role=\"progressbar\" aria-label={`${s.name} sync`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((s.progress ?? 0) * 100)} className=\"bg-muted h-1.5 overflow-hidden rounded-full\">\n                          <div className=\"bg-chart-3 h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\" style={{ width: `${Math.round((s.progress ?? 0) * 100)}%` }} />\n                        </div>\n                        <p className=\"text-muted-foreground mt-1.5 flex items-center gap-1.5 text-xs tabular-nums\">\n                          <Loader2 aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n                          {Math.round((s.progress ?? 0) * 100)}% · {s.synced ?? \"Syncing\"}\n                        </p>\n                      </div>\n                    ) : s.status === \"failed\" ? (\n                      <p role=\"alert\" className=\"text-destructive mt-2 flex items-start gap-1.5 text-xs text-pretty\">\n                        <CircleAlert aria-hidden=\"true\" className=\"mt-px size-3.5 shrink-0\" />\n                        {s.error ?? \"Could not read this source.\"}\n                      </p>\n                    ) : (\n                      <p className=\"text-muted-foreground mt-2 text-xs tabular-nums\">\n                        {s.passages !== undefined ? `${new Intl.NumberFormat(\"en-US\").format(s.passages)} passages` : \"Ready\"}\n                        {s.synced ? ` · ${s.synced}` : \"\"}\n                      </p>\n                    )}\n                  </div>\n                  <div className=\"flex shrink-0 items-center gap-1\">\n                    {s.status === \"failed\" ? (\n                      <Button variant=\"outline\" size=\"sm\" onClick={() => onResync?.(s.id)}>\n                        <RefreshCw aria-hidden=\"true\" />\n                        Retry\n                      </Button>\n                    ) : s.status === \"ready\" && s.kind === \"website\" ? (\n                      <Button variant=\"ghost\" size=\"icon-sm\" aria-label={`Sync ${s.name} now`} onClick={() => onResync?.(s.id)}>\n                        <RefreshCw aria-hidden=\"true\" />\n                      </Button>\n                    ) : null}\n                    <Button variant=\"ghost\" size=\"icon-sm\" aria-label={`Remove ${s.name}`} onClick={() => setConfirm(s)} className=\"hover:text-destructive\">\n                      <Trash2 aria-hidden=\"true\" />\n                    </Button>\n                    <Switch\n                      checked={!!s.enabled}\n                      disabled={s.status === \"failed\"}\n                      onCheckedChange={(on) => {\n                        setSources((l) => l.map((x) => (x.id === s.id ? { ...x, enabled: on } : x)))\n                        onToggle?.(s.id, on)\n                        setAnnounce(`${s.name} ${on ? \"turned on\" : \"turned off\"} for the agent`)\n                      }}\n                      aria-labelledby={`${id}-${s.id}`}\n                      aria-describedby={`${id}-use`}\n                    />\n                  </div>\n                </div>\n              </div>\n            )\n          })}\n        </BlurFadeGroup>\n        <p id={`${id}-use`} className=\"text-muted-foreground text-xs text-pretty\">\n          The switch decides whether the agent may use a source in its answers. Failed sources are off until they work again.\n        </p>\n      </div>\n\n      <aside aria-labelledby={`${id}-try`} className=\"bg-card min-w-0 rounded-2xl border lg:sticky lg:top-6\">\n        <div className=\"border-b p-5 pb-4\">\n          <h2 id={`${id}-try`} className=\"text-sm font-semibold\">\n            Test what it finds\n          </h2>\n          <p className=\"text-muted-foreground mt-0.5 text-xs text-pretty\">Ask a question and see which passages your {usable.length === 1 ? \"source\" : `${usable.length} sources`} return for it.</p>\n        </div>\n        <form\n          className=\"grid gap-3 p-5\"\n          onSubmit={(e) => {\n            e.preventDefault()\n            ask(question)\n          }}\n        >\n          <label htmlFor={`${id}-q`} className=\"sr-only\">\n            Question to test\n          </label>\n          <div className=\"bg-background focus-within:ring-ring/50 flex items-center gap-2 rounded-xl border px-3 focus-within:ring-[3px]\">\n            <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4 shrink-0\" />\n            <input id={`${id}-q`} value={question} onChange={(e) => setQuestion(e.target.value)} placeholder=\"Ask a question\" disabled={!usable.length} className=\"placeholder:text-muted-foreground h-10 min-w-0 flex-1 bg-transparent text-sm outline-none disabled:opacity-50\" />\n            <Button type=\"submit\" size=\"sm\" disabled={asking || !question.trim() || !usable.length}>\n              {asking ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n              Test\n            </Button>\n          </div>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {exampleQuestions.map((q) => (\n              <button\n                key={q}\n                type=\"button\"\n                disabled={!usable.length}\n                onClick={() => {\n                  setQuestion(q)\n                  ask(q)\n                }}\n                className=\"hover:bg-accent focus-visible:ring-ring/50 rounded-full border px-2.5 py-1 text-start text-xs outline-none focus-visible:ring-[3px] disabled:opacity-50\"\n              >\n                {q}\n              </button>\n            ))}\n          </div>\n        </form>\n        <div className=\"border-t p-5 pt-4\" aria-live=\"polite\" aria-busy={asking || undefined}>\n          {asking ? (\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              Searching {usable.length} {usable.length === 1 ? \"source\" : \"sources\"}…\n            </p>\n          ) : askError ? (\n            <p role=\"alert\" className=\"text-destructive text-sm\">\n              {askError}\n            </p>\n          ) : found && asked ? (\n            found.passages.length ? (\n              <div className=\"grid gap-3\">\n                <p className=\"text-xs font-medium tabular-nums\">\n                  {found.passages.length} {found.passages.length === 1 ? \"passage\" : \"passages\"} from {new Set(found.passages.map((p) => p.sourceId)).size} {new Set(found.passages.map((p) => p.sourceId)).size === 1 ? \"source\" : \"sources\"}\n                </p>\n                {found.answer ? <p className=\"bg-muted/60 rounded-lg p-3 text-sm text-pretty\">{found.answer}</p> : null}\n                <ol className=\"grid gap-2.5\">\n                  {found.passages.map((p, i) => {\n                    const src = sourceOf(p.sourceId)\n                    const Icon = src ? kindIcon[src.kind] : FileText\n                    return (\n                      <li key={i} className=\"rounded-xl border p-3\">\n                        <div className=\"flex items-center gap-2 text-xs\">\n                          <Icon aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n                          <span className=\"min-w-0 flex-1 truncate font-medium\">{src?.name ?? \"Source\"}</span>\n                          {p.score !== undefined ? (\n                            <span className=\"text-muted-foreground flex items-center gap-1.5 tabular-nums\">\n                              <span aria-hidden=\"true\" className=\"bg-muted relative h-1 w-10 overflow-hidden rounded-full\">\n                                <span className=\"bg-chart-1 absolute inset-y-0 start-0 rounded-full\" style={{ width: `${Math.max(8, Math.round(p.score * 100))}%` }} />\n                              </span>\n                              <span>{Math.round(p.score * 100)}% match</span>\n                            </span>\n                          ) : null}\n                        </div>\n                        <p className=\"text-muted-foreground mt-2 text-[13px] leading-relaxed text-pretty\">\n                          <MatchHighlight text={p.text} query={queryTokens(asked).filter((t) => t.length > 2)} />\n                        </p>\n                      </li>\n                    )\n                  })}\n                </ol>\n              </div>\n            ) : (\n              <div className=\"text-center\">\n                <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-10 items-center justify-center rounded-full\">\n                  <SearchX className=\"size-5\" />\n                </span>\n                <p className=\"mt-3 text-sm font-medium\">Your sources do not cover this</p>\n                <p className=\"text-muted-foreground mt-1 text-xs text-pretty\">The agent would have nothing to base an answer on. Add a source about “{asked}”, or turn one on.</p>\n              </div>\n            )\n          ) : (\n            <p className=\"text-muted-foreground text-sm text-pretty\">Results appear here. If nothing comes back, your agent would have to say it does not know.</p>\n          )}\n        </div>\n      </aside>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"agent-8\" data-state={state} 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=\"mb-8 flex flex-wrap items-end gap-4\">\n        <div className=\"min-w-0 flex-1 basis-72\">\n          <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em]\">\n            {title}\n          </h1>\n          <p className=\"text-muted-foreground mt-2 max-w-xl text-sm text-pretty\">What your agent reads before it answers. It uses only what is turned on here, and it can tell you which source it used.</p>\n        </div>\n        {ready && list.length ? addMenu : null}\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading knowledge\" loading={<KnowledgeSkeleton />} error={{ title: errorState?.title ?? \"Your sources could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n\n      <AlertDialog open={!!confirm} onOpenChange={(o) => !o && setConfirm(null)}>\n        <AlertDialogContent {...returnFocus}>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Remove {confirm?.name}?</AlertDialogTitle>\n            <AlertDialogDescription>The agent stops using it straight away. You can add it again later, but it will be read from scratch.</AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              onClick={() => {\n                if (!confirm) return\n                setSources((l) => l.filter((x) => x.id !== confirm.id))\n                onRemove?.(confirm.id)\n                setAnnounce(`${confirm.name} removed`)\n              }}\n            >\n              Remove\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Agent8, type Agent8Props, type Agent8Source, type Agent8Passage, type Agent8Kind }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/agent-8/agent-8.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agent",
      "knowledge",
      "rag",
      "sources",
      "retrieval",
      "documents",
      "sync",
      "training data",
      "knowledge base"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "An agent knowledge page. <Agent8 sources={[{ id, name, kind: 'website' | 'file' | 'text', location, status: 'ready' | 'syncing' | 'failed', progress, passages, synced, error, enabled, excerpts }]} onAdd={(kind) => …} onResync onRemove onToggle onAsk={async (question, sources) => ({ passages: [{ sourceId, text, score }], answer })} defaultQuestion />. The test panel shows retrieval, not a generated answer; onAsk returns your vector search's top passages. Without onAsk it searches each source's excerpts.",
      "whenToUse": [
        "Managing what a support, docs or internal agent may read (RAG sources)",
        "Checking that a question is covered before shipping an agent"
      ],
      "whenNotToUse": [
        "Showing the sources an answer used (use sources-list and citation)",
        "Configuring tools and instructions (use agent-7)"
      ],
      "composesWith": [
        "agent-7",
        "agent-6",
        "agent-3",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Each source has a switch named for it, a retry or sync button and a remove button; failures are announced as alerts"
        },
        {
          "keys": "Remove",
          "action": "Asks for confirmation in an alert dialog first"
        },
        {
          "keys": "Test",
          "action": "Results are a polite live region marked busy while searching; matches are real mark elements and scores are also written as text"
        }
      ],
      "customization": [
        "title, exampleQuestions, defaultQuestion (runs once at the start)",
        "progress (0 to 1) drives the bar of a syncing source",
        "onAsk gets the enabled, ready sources; return passages with a score from 0 to 1 and, if you have one, an answer"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "agent-8-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton of the summary, the source cards and the test panel under the real heading."
      },
      {
        "id": "empty",
        "title": "No sources yet",
        "example": "agent-8-empty",
        "trigger": "state=\"empty\" (or sources={[]})",
        "note": "Explains what knowledge is and offers the three ways to add it as large buttons (website, files, pasted text); emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "agent-8-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A single source that fails to sync shows its reason and Retry on its own card."
      }
    ],
    "examples": [
      "agent-8-demo",
      "agent-8-no-match",
      "agent-8-empty",
      "agent-8-loading",
      "agent-8-error"
    ],
    "url": "https://ui.ballmac.com/components/agent-8"
  },
  "categories": [
    "blocks",
    "agents"
  ],
  "type": "registry:block"
}