{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-chat",
  "title": "AI chat",
  "description": "The AI chat UI for the layouts' aiChat option, built on the AI SDK: AIChatProvider, AIChatPanel, AIChatTrigger and AIChatSearch, with streamed Markdown answers and translated strings. Installs into components/ai/chat, where Fumadocs' CLI puts it.",
  "dependencies": [
    "@ai-sdk/react@^4.0.0",
    "ai@^7.0.0",
    "cn",
    "@fuma-translate/react@^1.0.2",
    "fumadocs-core@^16.17.8",
    "lucide-react",
    "remark@^15.0.1",
    "remark-gfm@^4.0.1",
    "remark-rehype@^11.1.2",
    "remend@^1.4.0"
  ],
  "devDependencies": [
    "@types/hast"
  ],
  "registryDependencies": [
    "button",
    "collapsible",
    "tooltip",
    "@docscn/codeblock",
    "@docscn/dynamic-codeblock",
    "@docscn/mdx",
    "@docscn/docs-utils"
  ],
  "files": [
    {
      "path": "registry/base/ai/chat/index.tsx",
      "content": "// Adapted from Fumadocs UI (https://github.com/fuma-nama/fumadocs)\n// Copyright (c) 2023 Fuma, MIT License\n\"use client\";\nimport type { UseChatHelpers } from \"@ai-sdk/react\";\nimport { useTranslations } from \"@fuma-translate/react\";\nimport type { UIMessage } from \"ai\";\nimport Link from \"fumadocs-core/link\";\nimport { useCopyButton } from \"@/components/docs/utils/use-copy-button\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n  ArrowUpIcon,\n  CheckIcon,\n  ChevronRightIcon,\n  CopyIcon,\n  MessageCircleIcon,\n  RefreshCwIcon,\n  SearchIcon,\n  SquareIcon,\n  SquarePenIcon,\n  XIcon,\n} from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type CSSProperties,\n  Fragment,\n  memo,\n  type ReactNode,\n  type RefObject,\n  use,\n  useEffect,\n  useEffectEvent,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { Conversation, Turn } from \"./conversation\";\nimport { Markdown } from \"./markdown\";\nimport { cn } from \"cn\";\n\n/** the page a question is asked from, sent as a `data-client` part */\nexport interface AIChatClientData {\n  location: string;\n  title: string;\n}\n\nexport interface AIChatOptions<Message extends UIMessage = UIMessage> {\n  chat: UseChatHelpers<Message>;\n  /** the message to send for a question, defaults to its text with `AIChatClientData` */\n  toMessage?: (\n    text: string,\n  ) => Parameters<UseChatHelpers<Message>[\"sendMessage\"]>[0];\n  /** renders a part other than text, such as a tool call, keep it stable so settled messages skip re-rendering */\n  renderPart?: (part: Message[\"parts\"][number], live: boolean) => ReactNode;\n  /** under the title of a new chat */\n  description?: ReactNode;\n  suggestions?: string[];\n}\n\ninterface ChatState extends AIChatOptions {\n  busy: boolean;\n  send: (text: string) => void;\n  inputRef: RefObject<HTMLTextAreaElement | null>;\n}\n\n// separate from the chat, so layouts reading `open` skip the updates of a streaming answer\nconst OpenContext = createContext<{\n  open: boolean;\n  setOpen: (open: boolean) => void;\n} | null>(null);\nconst ChatContext = createContext<ChatState | null>(null);\n\n/**\n * The chat state, `Ctrl + /` opens it and `Escape` closes it.\n */\nexport function AIChatProvider<Message extends UIMessage>({\n  children,\n  ...options\n}: AIChatOptions<Message> & { children: ReactNode }) {\n  const [open, setOpen] = useState(false);\n  const openState = useMemo(() => ({ open, setOpen }), [open]);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const { chat, toMessage } = options as unknown as AIChatOptions;\n\n  const onKeyDown = useEffectEvent((e: KeyboardEvent) => {\n    if (e.key === \"Escape\" && open) {\n      setOpen(false);\n      e.preventDefault();\n    } else if (e.key === \"/\" && (e.metaKey || e.ctrlKey) && !open) {\n      setOpen(true);\n      e.preventDefault();\n    }\n  });\n\n  useEffect(() => {\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, []);\n\n  return (\n    <OpenContext value={openState}>\n      <ChatContext\n        value={{\n          ...(options as unknown as AIChatOptions),\n          busy: chat.status === \"streaming\" || chat.status === \"submitted\",\n          send: (text) =>\n            void chat.sendMessage(\n              toMessage?.(text) ?? {\n                role: \"user\",\n                parts: [\n                  {\n                    type: \"data-client\",\n                    data: {\n                      location: location.href,\n                      title: document.title,\n                    } satisfies AIChatClientData,\n                  },\n                  { type: \"text\", text },\n                ],\n              },\n            ),\n          inputRef,\n        }}\n      >\n        {children}\n      </ChatContext>\n    </OpenContext>\n  );\n}\n\n/** whether the chat is open */\nexport function useAIChat() {\n  return use(OpenContext)!;\n}\n\nfunction useChatState() {\n  return use(ChatContext)!;\n}\n\n/** the chat panel, for the `aiChat` option of docs layouts */\nexport function AIChatPanel() {\n  return (\n    <div className=\"flex size-full flex-col\">\n      <AIChatHeader />\n      <AIChatMessages className=\"flex-1\" />\n      <AIChatInput className=\"mx-3 mb-3\" />\n    </div>\n  );\n}\n\nexport function AIChatHeader() {\n  const { setOpen } = useAIChat();\n  const { chat, inputRef } = useChatState();\n  const t = useTranslations({ note: \"AI chat\" });\n  const first = chat.messages.find((message) => message.role === \"user\");\n  const title = first ? textOf(first) : t(\"Ask AI\");\n\n  return (\n    <div className=\"flex h-12 shrink-0 items-center gap-0.5 ps-3 pe-1.5\">\n      <p\n        key={title}\n        className=\"flex-1 truncate text-sm text-muted-foreground motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\"\n      >\n        {title}\n      </p>\n      {first && (\n        <Action\n          label={t(\"New chat\")}\n          className=\"motion-safe:animate-in motion-safe:zoom-in-95 motion-safe:fade-in\"\n          onClick={() => {\n            void chat.stop();\n            chat.setMessages([]);\n            inputRef.current?.focus();\n          }}\n        >\n          <SquarePenIcon />\n        </Action>\n      )}\n      <Action label={t(\"Close\")} onClick={() => setOpen(false)}>\n        <XIcon />\n      </Action>\n    </div>\n  );\n}\n\nexport function AIChatMessages({ className }: { className?: string }) {\n  const { chat, busy, renderPart, description, suggestions, send, inputRef } =\n    useChatState();\n  const t = useTranslations({ note: \"AI chat\" });\n  const last = chat.messages.at(-1);\n  const turns: UIMessage[][] = [];\n  let current: UIMessage[] | undefined;\n\n  for (const message of chat.messages) {\n    if (message.role === \"system\") continue;\n    if (message.role === \"user\" || !current) turns.push((current = [message]));\n    else current.push(message);\n  }\n\n  if (turns.length === 0) {\n    return (\n      <Conversation className={className}>\n        <div className=\"mt-auto flex flex-col\">\n          <p className=\"text-lg font-medium tracking-tight motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\">\n            {t(\"What do you want to know?\")}\n          </p>\n          <p\n            className=\"mt-1 text-sm text-muted-foreground motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\"\n            style={stagger()}\n          >\n            {description ??\n              t(\"Answers come from the docs, AI can make mistakes.\")}\n          </p>\n          <ul\n            aria-label={t(\"Suggestions\", { note: \"aria-label\" })}\n            className=\"mt-4 flex flex-wrap gap-1.5\"\n          >\n            {(\n              suggestions ?? [\n                t(\"Summarize this page\"),\n                t(\"How do I get started?\"),\n                t(\"What can I customize?\"),\n              ]\n            ).map((question) => (\n              <li\n                key={question}\n                className=\"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\"\n                style={stagger(100)}\n              >\n                <button\n                  type=\"button\"\n                  className=\"flex h-7 items-center rounded-lg border px-3 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\"\n                  onClick={() => {\n                    send(question);\n                    inputRef.current?.focus();\n                  }}\n                >\n                  {question}\n                </button>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </Conversation>\n    );\n  }\n\n  return (\n    <Conversation className={className}>\n      {turns.map((turn, i) => {\n        const latest = i === turns.length - 1;\n\n        return (\n          <Turn key={turn[0]!.id}>\n            {turn.map((message) => (\n              <Message\n                key={message.id}\n                message={message}\n                live={busy && message === last}\n                last={message === last}\n                renderPart={renderPart}\n                onRetry={chat.regenerate}\n              />\n            ))}\n            {latest && busy && isPending(last) && <Thinking />}\n            {latest && chat.error && (\n              <Failure\n                reason={chat.error.message}\n                onRetry={() => chat.regenerate()}\n              />\n            )}\n          </Turn>\n        );\n      })}\n    </Conversation>\n  );\n}\n\nconst StorageKey = \"__ai_search_input\";\nconst field = \"col-start-1 row-start-1 px-1.5 py-1\";\nconst draftListeners = new Set<() => void>();\n\nfunction subscribeDraft(listener: () => void) {\n  draftListeners.add(listener);\n  return () => {\n    draftListeners.delete(listener);\n  };\n}\n\n/** the question form, its draft survives reloads */\nexport function AIChatInput({ className }: { className?: string }) {\n  const { open } = useAIChat();\n  const { chat, busy, send, inputRef } = useChatState();\n  const t = useTranslations({ note: \"AI chat\" });\n  // the server has no drafts\n  const input = useSyncExternalStore(\n    subscribeDraft,\n    () => localStorage.getItem(StorageKey) ?? \"\",\n    () => \"\",\n  );\n  const text = input.trim();\n\n  function update(value: string) {\n    localStorage.setItem(StorageKey, value);\n    for (const listener of draftListeners) listener();\n  }\n\n  useEffect(() => {\n    if (open && !matchMedia(\"(pointer: coarse)\").matches)\n      inputRef.current?.focus({ preventScroll: true });\n  }, [open, inputRef]);\n\n  return (\n    <form\n      className={cn(\n        \"flex items-end gap-1 rounded-lg border bg-popover p-1.5 text-popover-foreground shadow-lg shadow-black/5 transition-[border-color,box-shadow] duration-200 focus-within:border-ring/40 focus-within:ring-4 focus-within:ring-primary/5\",\n        className,\n      )}\n      onSubmit={(e) => {\n        e.preventDefault();\n        if (busy || !text) return;\n        send(text);\n        update(\"\");\n      }}\n      // pressing the padding focuses the input\n      onMouseDown={(e) => {\n        if (e.target !== e.currentTarget) return;\n        e.preventDefault();\n        inputRef.current?.focus();\n      }}\n    >\n      <div className=\"grid flex-1 text-sm\">\n        <textarea\n          ref={inputRef}\n          rows={1}\n          value={input}\n          aria-label={t(\"Message\", { note: \"aria-label\" })}\n          placeholder={\n            chat.messages.length > 0\n              ? t(\"Ask a follow-up\")\n              : t(\"Ask a question\")\n          }\n          className={cn(\n            field,\n            \"resize-none bg-transparent placeholder:text-muted-foreground focus-visible:outline-none\",\n          )}\n          onChange={(e) => update(e.target.value)}\n          onKeyDown={(e) => {\n            // keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false\n            if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n            if (e.key === \"Enter\" && !e.shiftKey) {\n              e.preventDefault();\n              e.currentTarget.form?.requestSubmit();\n            }\n          }}\n        />\n        <div\n          aria-hidden\n          className={cn(\n            field,\n            \"invisible max-h-40 overflow-hidden wrap-break-word whitespace-pre-wrap\",\n          )}\n        >\n          {`${input}\\n`}\n        </div>\n      </div>\n      <button\n        type={busy ? \"button\" : \"submit\"}\n        aria-label={\n          busy\n            ? t(\"Stop\", { note: \"aria-label\" })\n            : t(\"Send\", { note: \"aria-label\" })\n        }\n        disabled={!busy && !text}\n        className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-[background-color,color,scale] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:scale-90 disabled:bg-accent disabled:text-muted-foreground motion-reduce:transition-none\"\n        onClick={\n          busy\n            ? () => {\n                void chat.stop();\n                inputRef.current?.focus();\n              }\n            : undefined\n        }\n      >\n        <IconSwap\n          swapped={busy}\n          from={<ArrowUpIcon className=\"size-4\" strokeWidth={2.25} />}\n          to={<SquareIcon className=\"size-3 fill-current\" />}\n        />\n      </button>\n    </form>\n  );\n}\n\n/** a floating button that toggles the chat */\nexport function AIChatTrigger({ className }: { className?: string }) {\n  const { open, setOpen } = useAIChat();\n  const t = useTranslations({ note: \"AI chat\" });\n\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        buttonVariants({ variant: \"secondary\" }),\n        \"fixed end-4 bottom-4 z-20 gap-2 rounded-2xl text-muted-foreground shadow-lg transition-[translate,opacity] motion-reduce:transition-none\",\n        open && \"translate-y-10 opacity-0\",\n        className,\n      )}\n      inert={open}\n      onClick={() => setOpen(!open)}\n    >\n      <MessageCircleIcon className=\"size-4.5\" />\n      {t(\"Ask AI\")}\n    </button>\n  );\n}\n\nexport function AIChatSources({\n  sources,\n}: {\n  sources: { url: string; title?: string | null; label?: string | null }[];\n}) {\n  const t = useTranslations({ note: \"AI chat\" });\n  if (sources.length === 0) return null;\n\n  return (\n    <ol\n      aria-label={t(\"Sources\", { note: \"aria-label\" })}\n      className=\"flex flex-wrap gap-1.5\"\n    >\n      {sources.map((source, i) => (\n        <li\n          key={i}\n          className=\"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\"\n          style={stagger()}\n        >\n          <Link\n            href={source.url}\n            className=\"flex h-7 max-w-56 items-center gap-1.5 rounded-lg border bg-card ps-1.5 pe-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\"\n          >\n            <span className=\"flex h-4.5 min-w-4.5 shrink-0 items-center justify-center rounded-md bg-secondary px-1 font-mono text-[10.5px] tabular-nums\">\n              {source.label ?? i + 1}\n            </span>\n            <span className=\"truncate\">{source.title ?? source.url}</span>\n          </Link>\n        </li>\n      ))}\n    </ol>\n  );\n}\n\n/** a call of the docs search tool, expandable to the found pages */\nexport function AIChatSearch({\n  part,\n  live,\n}: {\n  part: {\n    state: string;\n    input?: { query?: string };\n    output?: { doc?: { url: string; title: string } | null }[];\n  };\n  live: boolean;\n}) {\n  const t = useTranslations({ note: \"AI chat\" });\n  const done = part.state === \"output-available\";\n  const running = live && !part.state.startsWith(\"output-\");\n  const links: ReactNode[] = [];\n  let label: ReactNode = live ? t(\"Searching\") : t(\"Search stopped\");\n  if (part.state === \"output-error\")\n    label = <span className=\"text-destructive\">{t(\"Search failed\")}</span>;\n\n  if (done) {\n    label = t(\"Searched\");\n    for (const { doc } of part.output ?? []) {\n      if (!doc) continue;\n      links.push(\n        <Link\n          key={doc.url}\n          href={doc.url}\n          className=\"truncate transition-colors hover:text-accent-foreground\"\n        >\n          {doc.title}\n        </Link>,\n      );\n    }\n  }\n\n  return (\n    <Collapsible\n      disabled={links.length === 0}\n      className=\"motion-safe:animate-in motion-safe:fade-in\"\n    >\n      <CollapsibleTrigger className=\"group/trigger -ms-1 flex max-w-full items-center gap-2 rounded-md py-0.5 ps-1 pe-1.5 text-start text-sm text-muted-foreground transition-colors not-data-disabled:hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\">\n        <span className=\"flex w-4 shrink-0 justify-center [&_svg]:size-3.5\">\n          {running ? <Spinner /> : <SearchIcon />}\n        </span>\n        <span\n          className={cn(\"shrink-0\", running && \"motion-safe:animate-pulse\")}\n        >\n          {label}\n        </span>\n        {part.input?.query && (\n          <span className=\"truncate text-muted-foreground/75\">\n            “{part.input.query}”\n          </span>\n        )}\n        {done && (\n          <span className=\"shrink-0 text-xs text-muted-foreground/75 tabular-nums\">\n            {links.length === 1\n              ? t(\"1 result\")\n              : t(\"{count} results\", {\n                  variables: { count: String(links.length) },\n                })}\n          </span>\n        )}\n        <ChevronRightIcon className=\"size-3.5 shrink-0 opacity-50 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-panel-open/trigger:rotate-90 group-data-disabled/trigger:hidden motion-reduce:transition-none rtl:rotate-180\" />\n      </CollapsibleTrigger>\n      <CollapsibleContent className=\"h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0 motion-reduce:transition-none\">\n        <div className=\"ms-[7.5px] mt-1.5 flex flex-col gap-1 border-s ps-4 pb-1 text-xs text-muted-foreground\">\n          {links}\n        </div>\n      </CollapsibleContent>\n    </Collapsible>\n  );\n}\n\n/** memoized, as settled messages keep their identity while an answer streams */\nconst Message = memo(function Message({\n  message,\n  live,\n  last,\n  renderPart,\n  onRetry,\n}: {\n  message: UIMessage;\n  live: boolean;\n  last: boolean;\n  renderPart: AIChatOptions[\"renderPart\"];\n  onRetry: () => unknown;\n}) {\n  const t = useTranslations({ note: \"AI chat\" });\n  const text = textOf(message);\n\n  if (message.role === \"user\") {\n    return (\n      <div className=\"flex justify-end motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\">\n        <p className=\"max-w-[85%] rounded-xl bg-secondary px-3 py-1.5 text-sm wrap-break-word whitespace-pre-wrap text-secondary-foreground\">\n          {text}\n        </p>\n      </div>\n    );\n  }\n\n  const items: ReactNode[] = [];\n  for (const [i, part] of message.parts.entries()) {\n    if (part.type !== \"text\")\n      items.push(<Fragment key={i}>{renderPart?.(part, live)}</Fragment>);\n    else if (part.text)\n      items.push(\n        <Markdown\n          key={i}\n          text={part.text}\n          live={live && part.state === \"streaming\"}\n        />,\n      );\n  }\n\n  return (\n    <div className=\"group/message flex flex-col gap-3 empty:hidden\">\n      {items}\n      {!live && text && (\n        <div\n          className={cn(\n            \"-ms-1.25 -mt-1 flex items-center motion-safe:animate-in motion-safe:fade-in\",\n            !last &&\n              \"opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 motion-reduce:transition-none pointer-coarse:opacity-100\",\n          )}\n        >\n          <CopyAction text={text} className={small} />\n          {last && (\n            <Action\n              label={t(\"Retry\")}\n              className={small}\n              onClick={() => void onRetry()}\n            >\n              <RefreshCwIcon />\n            </Action>\n          )}\n        </div>\n      )}\n    </div>\n  );\n});\n\nconst small = \"size-6 [&_svg]:size-3.5\";\n\n/** nothing shows yet, or the model reads what a tool returned */\nfunction isPending(message: UIMessage | undefined) {\n  const part = message?.role === \"assistant\" ? message.parts.at(-1) : undefined;\n  if (part?.type === \"text\") return part.text.length === 0;\n  if (part && \"toolCallId\" in part) return part.state.startsWith(\"output-\");\n  return true;\n}\n\n/** shown a beat late, so quick answers never flash it */\nfunction Thinking() {\n  const t = useTranslations({ note: \"AI chat\" });\n\n  return (\n    <div\n      role=\"status\"\n      className=\"flex items-center gap-2 text-sm text-muted-foreground motion-safe:animate-in motion-safe:fade-in\"\n      style={{ animationDelay: \"150ms\", animationFillMode: \"backwards\" }}\n    >\n      <Spinner />\n      <span className=\"motion-safe:animate-pulse\">{t(\"Thinking\")}</span>\n    </div>\n  );\n}\n\nfunction Failure({ reason, onRetry }: { reason: string; onRetry: () => void }) {\n  const t = useTranslations({ note: \"AI chat\" });\n\n  return (\n    <div\n      role=\"alert\"\n      className=\"flex items-center gap-3 rounded-lg bg-destructive/5 py-2 ps-3 pe-2 text-sm ring-1 ring-destructive/15 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1\"\n    >\n      <span\n        aria-hidden\n        className=\"size-1.5 shrink-0 rounded-full bg-destructive\"\n      />\n      <div className=\"min-w-0 flex-1\">\n        <p>{t(\"Something went wrong.\")}</p>\n        <p className=\"line-clamp-2 text-xs wrap-break-word text-muted-foreground\">\n          {reason}\n        </p>\n      </div>\n      <button\n        type=\"button\"\n        className={cn(\n          buttonVariants({ variant: \"outline\", size: \"sm\" }),\n          \"shrink-0\",\n        )}\n        onClick={onRetry}\n      >\n        {t(\"Try again\")}\n      </button>\n    </div>\n  );\n}\n\nfunction Action({\n  label,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"aria-label\"> & { label: string }) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        aria-label={label}\n        className={cn(\n          buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n          \"text-muted-foreground\",\n          className,\n        )}\n        {...props}\n      />\n      <TooltipContent side=\"bottom\" sideOffset={6}>\n        {label}\n      </TooltipContent>\n    </Tooltip>\n  );\n}\n\nfunction CopyAction({ text, className }: { text: string; className?: string }) {\n  const t = useTranslations({ note: \"AI chat\" });\n  const [checked, onClick] = useCopyButton(() =>\n    navigator.clipboard.writeText(text),\n  );\n\n  return (\n    <Action\n      label={checked ? t(\"Copied\") : t(\"Copy\")}\n      className={className}\n      onClick={onClick}\n    >\n      <IconSwap swapped={checked} from={<CopyIcon />} to={<CheckIcon />} />\n    </Action>\n  );\n}\n\nfunction IconSwap({\n  swapped,\n  from,\n  to,\n}: {\n  swapped: boolean;\n  from: ReactNode;\n  to: ReactNode;\n}) {\n  const layer =\n    \"col-start-1 row-start-1 flex transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none\";\n  const hidden = \"scale-50 opacity-0 blur-[2px]\";\n\n  return (\n    <span className=\"inline-grid place-items-center\">\n      <span aria-hidden={swapped} className={cn(layer, swapped && hidden)}>\n        {from}\n      </span>\n      <span aria-hidden={!swapped} className={cn(layer, !swapped && hidden)}>\n        {to}\n      </span>\n    </span>\n  );\n}\n\nconst orbit = [1.6, 1.35, 1.15, 1, 0.85, 0.72, 0.6].map((r, i) => {\n  const angle = (-i * 32 * Math.PI) / 180;\n  return {\n    cx: 8 + 5.5 * Math.sin(angle),\n    cy: 8 - 5.5 * Math.cos(angle),\n    r,\n    opacity: 1 - i * 0.12,\n  };\n});\n\nfunction Spinner() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      aria-hidden\n      className=\"size-4 shrink-0 text-primary motion-safe:animate-spin motion-safe:[animation-duration:1.2s]\"\n    >\n      {orbit.map((dot, i) => (\n        <circle key={i} {...dot} fill=\"currentColor\" />\n      ))}\n    </svg>\n  );\n}\n\nfunction stagger(base = 0): CSSProperties {\n  return {\n    animationDelay: `calc(${base}ms + (sibling-index() - 1) * 50ms)`,\n    animationFillMode: \"backwards\",\n  };\n}\n\nfunction textOf(message: UIMessage) {\n  let text = \"\";\n  for (const part of message.parts) {\n    if (part.type === \"text\") text += part.text;\n  }\n  return text;\n}\n",
      "type": "registry:component",
      "target": "components/ai/chat/index.tsx"
    },
    {
      "path": "registry/base/ai/chat/conversation.tsx",
      "content": "// Adapted from Fumadocs UI (https://github.com/fuma-nama/fumadocs)\n// Copyright (c) 2023 Fuma, MIT License\n\"use client\";\nimport { useTranslations } from \"@fuma-translate/react\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport {\n  createContext,\n  type ReactNode,\n  use,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"cn\";\n\ninterface Scroll {\n  follow: boolean;\n  /** turns mounted after it are newly asked */\n  ready: boolean;\n  /** where the conversation last scrolled itself, to tell its scrolls from the reader's */\n  set?: number;\n  lastTop: number;\n  frame?: number;\n}\n\nconst ConversationContext = createContext<{ pin: () => void } | null>(null);\n\n/**\n * Follows the answer until the reader scrolls up, asking again follows the new one.\n */\nexport function Conversation({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  const t = useTranslations({ note: \"AI chat\" });\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const scroll = useRef<Scroll>({ follow: true, ready: false, lastTop: 0 });\n  const [atEnd, setAtEnd] = useState(true);\n  const [context] = useState(() => ({\n    pin() {\n      const scroller = scrollRef.current;\n      if (!scroller || !scroll.current.ready) return;\n      scroll.current.follow = true;\n      glide(scroller, scroll.current);\n    },\n  }));\n\n  useLayoutEffect(() => {\n    const scroller = scrollRef.current;\n    const list = listRef.current;\n    if (!scroller || !list) return;\n    const state = scroll.current;\n    state.ready = true;\n    jump(scroller, state, endOf(scroller));\n\n    const observer = new ResizeObserver(() => {\n      if (state.follow) glide(scroller, state);\n      setAtEnd(isAtEnd(scroller, state));\n    });\n    observer.observe(list);\n    observer.observe(scroller);\n\n    const onScroll = () => {\n      const top = scroller.scrollTop;\n      if (state.set === undefined || Math.abs(top - state.set) > 1) {\n        stop(state);\n        if (top < state.lastTop - 1) state.follow = false;\n        else if (isAtEnd(scroller, state)) state.follow = true;\n      }\n      state.lastTop = top;\n      setAtEnd(isAtEnd(scroller, state));\n    };\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n\n    return () => {\n      observer.disconnect();\n      stop(state);\n      scroller.removeEventListener(\"scroll\", onScroll);\n    };\n  }, []);\n\n  return (\n    <div className={cn(\"relative min-h-0\", className)}>\n      <div\n        ref={scrollRef}\n        className=\"size-full overflow-y-auto overscroll-contain\"\n        style={{\n          maskImage:\n            \"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent)\",\n        }}\n      >\n        <div\n          ref={listRef}\n          role=\"log\"\n          className=\"flex min-h-full flex-col gap-8 px-3 pt-2 pb-6\"\n        >\n          <ConversationContext value={context}>{children}</ConversationContext>\n        </div>\n      </div>\n      <button\n        type=\"button\"\n        aria-label={t(\"Scroll to latest\", { note: \"aria-label\" })}\n        tabIndex={atEnd ? -1 : 0}\n        data-hidden={atEnd}\n        className=\"absolute bottom-3 left-1/2 flex size-8 -translate-x-1/2 items-center justify-center rounded-full border bg-popover text-muted-foreground shadow-lg transition-[opacity,translate,color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:text-popover-foreground data-[hidden=true]:pointer-events-none data-[hidden=true]:translate-y-2 data-[hidden=true]:opacity-0 motion-reduce:transition-none\"\n        onClick={context.pin}\n      >\n        <ArrowDownIcon className=\"size-4\" />\n      </button>\n    </div>\n  );\n}\n\nexport function Turn({ children }: { children: ReactNode }) {\n  const { pin } = use(ConversationContext)!;\n  useLayoutEffect(() => pin(), [pin]);\n\n  return <div className=\"flex flex-col gap-4\">{children}</div>;\n}\n\nfunction endOf(scroller: HTMLElement) {\n  return scroller.scrollHeight - scroller.clientHeight;\n}\n\n/** gliding counts as being at the end */\nfunction isAtEnd(scroller: HTMLElement, state: Scroll) {\n  return state.frame !== undefined || endOf(scroller) - scroller.scrollTop < 8;\n}\n\nfunction jump(scroller: HTMLElement, state: Scroll, top: number) {\n  scroller.scrollTop = top;\n  state.set = scroller.scrollTop;\n}\n\nfunction stop(state: Scroll) {\n  if (state.frame !== undefined) cancelAnimationFrame(state.frame);\n  state.frame = undefined;\n}\n\nlet reducedMotion: MediaQueryList | undefined;\n\n/** eases to the end, which may move while gliding */\nfunction glide(scroller: HTMLElement, state: Scroll) {\n  stop(state);\n  const from = scroller.scrollTop;\n  const distance = Math.abs(endOf(scroller) - from);\n  reducedMotion ??= matchMedia(\"(prefers-reduced-motion: reduce)\");\n  if (distance < 2 || reducedMotion.matches) {\n    jump(scroller, state, endOf(scroller));\n    return;\n  }\n\n  const duration = Math.min(640, Math.max(320, distance / 2));\n  // a frame's timestamp can predate this call, the clock starts on the first frame\n  let start: number | undefined;\n  const step = (now: number) => {\n    start ??= now;\n    const progress = Math.min(1, (now - start) / duration);\n    jump(\n      scroller,\n      state,\n      from + (endOf(scroller) - from) * (1 - (1 - progress) ** 5),\n    );\n    state.frame = progress < 1 ? requestAnimationFrame(step) : undefined;\n  };\n  state.frame = requestAnimationFrame(step);\n}\n",
      "type": "registry:component",
      "target": "components/ai/chat/conversation.tsx"
    },
    {
      "path": "registry/base/ai/chat/markdown.tsx",
      "content": "// Adapted from Fumadocs UI (https://github.com/fuma-nama/fumadocs)\n// Copyright (c) 2023 Fuma, MIT License\n\"use client\";\nimport {\n  CodeBlock,\n  Pre as CodePre,\n} from \"@/components/docs/components/codeblock\";\nimport { DynamicCodeBlock } from \"@/components/docs/components/dynamic-codeblock\";\nimport defaultMdxComponents from \"@/components/docs/mdx\";\nimport type { Element, ElementContent, Root } from \"hast\";\nimport { type Components, toJsx } from \"fumadocs-core/utils/hast-to-jsx\";\nimport { type ComponentProps, memo, useState } from \"react\";\nimport { remark } from \"remark\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkRehype from \"remark-rehype\";\nimport remend from \"remend\";\n\nconst processor = remark().use(remarkGfm).use(remarkRehype);\nconst streamProcessor = remark()\n  .use(remarkGfm)\n  .use(remarkRehype)\n  .use(rehypeWords);\n\nconst components: Partial<Components> = {\n  ...defaultMdxComponents,\n  pre: Pre,\n  img: \"img\",\n};\n// code is highlighted once its block settles\nconst liveComponents: Partial<Components> = {\n  ...components,\n  pre: LivePre,\n};\n\n/**\n * An answer in Markdown. While `live`, only its last block re-renders, with unclosed syntax completed.\n */\nexport function Markdown({ text, live }: { text: string; live: boolean }) {\n  const blocks = splitBlocks(text);\n\n  return (\n    <div className=\"docs-typeset text-sm\">\n      {blocks.map((block, i) => (\n        <Block\n          key={i}\n          text={\n            live && i === blocks.length - 1\n              ? remend(block, { linkMode: \"text-only\" })\n              : block\n          }\n          live={live && i === blocks.length - 1}\n        />\n      ))}\n    </div>\n  );\n}\n\nconst Block = memo(function Block({\n  text,\n  live,\n}: {\n  text: string;\n  live: boolean;\n}) {\n  // only words that stream in fade, a chat from history renders plain text\n  const [streamed] = useState(live);\n  const p = streamed ? streamProcessor : processor;\n\n  return toJsx(p.runSync(p.parse(text)), {\n    components: live ? liveComponents : components,\n  });\n});\n\nconst listItem = /^(?:[-*+]|\\d{1,9}[.)])\\s/;\n\n/** top-level blocks split at blank lines, a block continues through code fences, indented lines and list items */\nexport function splitBlocks(text: string): string[] {\n  const blocks: string[] = [];\n  let block = \"\";\n  let fence: string | undefined;\n  let blank = false;\n  let inList = false;\n\n  for (const line of text.split(\"\\n\")) {\n    if (fence) {\n      block += `\\n${line}`;\n      const close = /^ {0,3}(`{3,}|~{3,})\\s*$/.exec(line)?.[1];\n      if (close && close[0] === fence[0] && close.length >= fence.length)\n        fence = undefined;\n      continue;\n    }\n\n    if (line.trim().length === 0) {\n      blank = true;\n      continue;\n    }\n\n    const indented = /^\\s/.test(line);\n    if (block.length === 0) {\n      block = line;\n    } else if (blank && !indented && !(inList && listItem.test(line))) {\n      blocks.push(block);\n      block = line;\n    } else {\n      block += blank ? `\\n\\n${line}` : `\\n${line}`;\n    }\n\n    if (!indented) inList = listItem.test(line);\n    blank = false;\n    fence = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];\n  }\n\n  if (block.length > 0) blocks.push(block);\n  return blocks;\n}\n\nfunction Pre({ children }: ComponentProps<\"pre\">) {\n  const code = codeOf(children);\n  if (!code) return null;\n  const lang = /language-(\\S+)/.exec(code.className ?? \"\")?.[1] ?? \"text\";\n\n  return (\n    <DynamicCodeBlock\n      lang={lang === \"mdx\" ? \"md\" : lang}\n      code={code.children.trimEnd()}\n    />\n  );\n}\n\nfunction LivePre({ children }: ComponentProps<\"pre\">) {\n  const code = codeOf(children);\n  if (!code) return null;\n\n  return (\n    <CodeBlock className=\"my-0\">\n      <CodePre>\n        <code>{code.children.trimEnd()}</code>\n      </CodePre>\n    </CodeBlock>\n  );\n}\n\nfunction codeOf(children: unknown) {\n  const props = (children as { props?: ComponentProps<\"code\"> } | undefined)\n    ?.props;\n  if (typeof props?.children === \"string\")\n    return props as { children: string; className?: string };\n}\n\nfunction rehypeWords() {\n  const split = (parent: Root | Element) => {\n    for (let i = 0; i < parent.children.length; i++) {\n      const node = parent.children[i]!;\n      if (node.type === \"element\" && node.tagName !== \"pre\") split(node);\n      if (node.type !== \"text\") continue;\n\n      const words: ElementContent[] = [];\n      for (const word of node.value.split(/(?=\\s)/)) {\n        if (word.length === 0) continue;\n        words.push({\n          type: \"element\",\n          tagName: \"span\",\n          properties: {\n            className: [\"motion-safe:animate-in\", \"motion-safe:fade-in\"],\n          },\n          children: [{ type: \"text\", value: word }],\n        });\n      }\n\n      parent.children.splice(i, 1, ...words);\n      i += words.length - 1;\n    }\n  };\n\n  return (tree: Root) => split(tree);\n}\n",
      "type": "registry:component",
      "target": "components/ai/chat/markdown.tsx"
    },
    {
      "path": "registry/base/ai/chat/i18n.ts",
      "content": "// Adapted from Fumadocs UI (https://github.com/fuma-nama/fumadocs)\n// Copyright (c) 2023 Fuma, MIT License\nimport type { TranslationExtension } from \"fumadocs-core/i18n\";\nimport { keys as translationKeys } from \"./.translations\";\nimport type { Translations } from \"./.translations\";\n\nexport type { Translations };\nexport function aiChatTranslations(): TranslationExtension<keyof Translations> {\n  return { keys: translationKeys as never };\n}\n",
      "type": "registry:component",
      "target": "components/ai/chat/i18n.ts"
    },
    {
      "path": "registry/base/ai/chat/.translations/index.ts",
      "content": "// Adapted from Fumadocs UI (https://github.com/fuma-nama/fumadocs)\n// Copyright (c) 2023 Fuma, MIT License\n\n// The keys of the AI chat's UI strings, as in `@fumadocs/ai-chat`: the English\n// text followed by each note, in parentheses.\nexport const keys = [\n  \"1 result(AI chat)\",\n  \"Answers come from the docs, AI can make mistakes.(AI chat)\",\n  \"Ask AI(AI chat)\",\n  \"Ask a follow-up(AI chat)\",\n  \"Ask a question(AI chat)\",\n  \"Close(AI chat)\",\n  \"Copied(AI chat)\",\n  \"Copy(AI chat)\",\n  \"How do I get started?(AI chat)\",\n  \"Message(AI chat)(aria-label)\",\n  \"New chat(AI chat)\",\n  \"Retry(AI chat)\",\n  \"Scroll to latest(AI chat)(aria-label)\",\n  \"Search failed(AI chat)\",\n  \"Search stopped(AI chat)\",\n  \"Searched(AI chat)\",\n  \"Searching(AI chat)\",\n  \"Send(AI chat)(aria-label)\",\n  \"Something went wrong.(AI chat)\",\n  \"Sources(AI chat)(aria-label)\",\n  \"Stop(AI chat)(aria-label)\",\n  \"Suggestions(AI chat)(aria-label)\",\n  \"Summarize this page(AI chat)\",\n  \"Thinking(AI chat)\",\n  \"Try again(AI chat)\",\n  \"What can I customize?(AI chat)\",\n  \"What do you want to know?(AI chat)\",\n  \"{count} results(AI chat)\",\n] as const;\n\nexport type Translations = Record<(typeof keys)[number], string>;\n",
      "type": "registry:component",
      "target": "components/ai/chat/.translations/index.ts"
    }
  ],
  "type": "registry:component"
}