{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multimodal-chatbot",
  "title": "Multimodal Chatbot",
  "description": "An AI Gateway chat workspace for ad-hoc image and PDF questions with inline file parts.",
  "dependencies": [
    "@ai-sdk/react",
    "@base-ui/react",
    "@phosphor-icons/react",
    "ai",
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "badge",
    "breadcrumb",
    "button",
    "textarea",
    "https://elements.ai-sdk.dev/api/registry/attachments.json",
    "https://elements.ai-sdk.dev/api/registry/conversation.json",
    "https://elements.ai-sdk.dev/api/registry/message.json"
  ],
  "files": [
    {
      "path": "registry/multimodal-chatbot/app/demos/multimodal-chatbot/page.tsx",
      "content": "import { MultimodalChatbotScreen } from \"@/components/multimodal-chatbot/multimodal-chatbot-screen\";\n\nexport default function MultimodalChatbotPage() {\n  return <MultimodalChatbotScreen />;\n}\n",
      "type": "registry:page",
      "target": "app/demos/multimodal-chatbot/page.tsx"
    },
    {
      "path": "registry/multimodal-chatbot/app/api/demos/multimodal-chatbot/route.ts",
      "content": "import { handleMultimodalChatbotRequest } from \"@/lib/multimodal-chatbot/runtime\";\n\nexport const runtime = \"nodejs\";\n\nexport function POST(request: Request) {\n  return handleMultimodalChatbotRequest(request);\n}\n",
      "type": "registry:page",
      "target": "app/api/demos/multimodal-chatbot/route.ts"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/multimodal-chatbot-screen.tsx",
      "content": "import { Badge } from \"@/components/ui/badge\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { getMultimodalChatbotRuntimeState } from \"@/lib/multimodal-chatbot/runtime\";\nimport { MultimodalChatbotWorkspace } from \"@/components/multimodal-chatbot/multimodal-chatbot-workspace\";\n\nexport function MultimodalChatbotScreen() {\n  const runtimeState = getMultimodalChatbotRuntimeState();\n\n  return (\n    <main className=\"min-h-svh bg-background text-foreground\">\n      <div className=\"mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 md:px-6\">\n        <header className=\"grid gap-4 border border-foreground/10 bg-background px-4 py-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-end\">\n          <div className=\"space-y-2\">\n            <Breadcrumb>\n              <BreadcrumbList className=\"text-[11px] text-muted-foreground uppercase tracking-[0.2em]\">\n                <BreadcrumbItem>\n                  <BreadcrumbLink\n                    aria-label=\"Back to demos\"\n                    className=\"-ml-1 inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground\"\n                    href=\"/\"\n                  >\n                    <ArrowLeft aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                    <span>Demo</span>\n                  </BreadcrumbLink>\n                </BreadcrumbItem>\n                <BreadcrumbSeparator className=\"text-muted-foreground\">\n                  /\n                </BreadcrumbSeparator>\n                <BreadcrumbItem>\n                  <BreadcrumbPage className=\"font-normal text-muted-foreground\">\n                    Multi-Modal Chatbot\n                  </BreadcrumbPage>\n                </BreadcrumbItem>\n              </BreadcrumbList>\n            </Breadcrumb>\n            <h1 className=\"max-w-3xl font-medium text-2xl tracking-tight\">\n              Ad-hoc file chat over user-provided images and PDFs\n            </h1>\n            <p className=\"max-w-3xl text-muted-foreground text-sm/relaxed\">\n              This slice keeps the official AI SDK multimodal guide recognizable\n              while turning it into a workspace for immediate image and PDF\n              questions.\n            </p>\n          </div>\n\n          <div className=\"flex flex-wrap items-center gap-2\">\n            <Badge variant=\"outline\">{runtimeState.statusLabel}</Badge>\n            <Badge variant=\"outline\">{runtimeState.chatModel}</Badge>\n          </div>\n        </header>\n\n        <div className=\"lg:h-svh\">\n          <MultimodalChatbotWorkspace\n            acceptedMediaTypes={runtimeState.acceptedMediaTypes}\n            chatModel={runtimeState.chatModel}\n            isChatAvailable={runtimeState.isChatAvailable}\n            nodeVersion={runtimeState.nodeVersion}\n            setupMessage={runtimeState.setupMessage}\n          />\n        </div>\n      </div>\n    </main>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/multimodal-chatbot-screen.tsx"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/multimodal-chatbot-workspace.tsx",
      "content": "\"use client\";\n\nimport {\n  ArrowClockwiseIcon,\n  ImagesIcon,\n  PaperclipIcon,\n  StopIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport {\n  Attachment,\n  AttachmentInfo,\n  AttachmentPreview,\n  AttachmentRemove,\n  Attachments,\n} from \"@/components/ai-elements/attachments\";\nimport {\n  Conversation,\n  ConversationContent,\n  ConversationEmptyState,\n  ConversationScrollButton,\n} from \"@/components/ai-elements/conversation\";\nimport {\n  Message,\n  MessageContent,\n  MessageResponse,\n} from \"@/components/ai-elements/message\";\nimport {\n  PromptInput,\n  PromptInputBody,\n  PromptInputFooter,\n  PromptInputSubmit,\n  PromptInputTextarea,\n} from \"@/components/ai-elements/prompt-input\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { ChatStatus, UIMessage } from \"ai\";\nimport type { RefObject } from \"react\";\n\nimport {\n  getMultimodalFileParts,\n  getMultimodalMessageText,\n  type PendingAttachment,\n} from \"./multimodal-chatbot-session\";\nimport { useMultimodalChatbotWorkspace } from \"./use-multimodal-chatbot-workspace\";\n\nexport interface MultimodalChatbotWorkspaceProps {\n  acceptedMediaTypes: string[];\n  chatModel: string;\n  isChatAvailable: boolean;\n  nodeVersion: string;\n  setupMessage: string | null;\n}\n\ninterface MultimodalConversationProps {\n  hasMessages: boolean;\n  messages: UIMessage[];\n}\n\nfunction MultimodalConversation({\n  hasMessages,\n  messages,\n}: MultimodalConversationProps) {\n  return (\n    <Conversation className=\"min-h-0\">\n      <ConversationContent className=\"mx-auto flex w-full max-w-3xl flex-1 gap-6 px-4 py-6\">\n        {hasMessages ? (\n          messages.map((message) => (\n            <MultimodalMessage key={message.id} message={message} />\n          ))\n        ) : (\n          <ConversationEmptyState\n            description=\"Attach an image or PDF, ask a question, and inspect how the same chat surface handles ad-hoc multimodal context without preindexing.\"\n            icon={<ImagesIcon className=\"size-5\" />}\n            title=\"Multi-modal workspace is ready\"\n          />\n        )}\n      </ConversationContent>\n      <ConversationScrollButton />\n    </Conversation>\n  );\n}\n\nfunction MultimodalMessage({ message }: { message: UIMessage }) {\n  const text = getMultimodalMessageText(message);\n  const fileParts = getMultimodalFileParts(message);\n\n  return (\n    <Message from={message.role}>\n      <MessageContent\n        className={cn(\n          \"space-y-4\",\n          message.role === \"assistant\" ? \"max-w-3xl\" : \"max-w-2xl\"\n        )}\n      >\n        {text ? <MessageResponse>{text}</MessageResponse> : null}\n\n        {fileParts.length > 0 ? (\n          <Attachments variant=\"list\">\n            {fileParts.map((part) => {\n              const attachmentId = `${message.id}-${part.filename ?? \"attachment\"}-${part.url}`;\n\n              return (\n                <Attachment\n                  data={{\n                    ...part,\n                    id: attachmentId,\n                  }}\n                  key={attachmentId}\n                >\n                  <AttachmentPreview />\n                  <AttachmentInfo showMediaType />\n                </Attachment>\n              );\n            })}\n          </Attachments>\n        ) : null}\n\n        {!text && fileParts.length === 0 ? (\n          <p className=\"text-muted-foreground text-sm\">\n            Waiting for visible output.\n          </p>\n        ) : null}\n      </MessageContent>\n    </Message>\n  );\n}\n\ninterface MultimodalComposerProps {\n  chatModel: string;\n  fileInputRef: RefObject<HTMLInputElement | null>;\n  hasMessages: boolean;\n  isBusy: boolean;\n  isChatAvailable: boolean;\n  onAppendFiles: (fileList: FileList | null) => void;\n  onRegenerateLastTurn: () => void;\n  onRemovePendingAttachment: (attachmentId: string) => void;\n  onSend: (text: string) => Promise<void>;\n  onSendSamplePrompt: (text: string) => void;\n  onStopChat: () => void;\n  pendingAttachments: PendingAttachment[];\n  samplePrompts: readonly string[];\n  status: ChatStatus;\n}\n\nfunction MultimodalComposer({\n  chatModel,\n  fileInputRef,\n  hasMessages,\n  isBusy,\n  isChatAvailable,\n  pendingAttachments,\n  samplePrompts,\n  status,\n  onAppendFiles,\n  onRegenerateLastTurn,\n  onRemovePendingAttachment,\n  onSend,\n  onSendSamplePrompt,\n  onStopChat,\n}: MultimodalComposerProps) {\n  return (\n    <div className=\"border-foreground/10 border-t px-4 py-4\">\n      <div className=\"mx-auto w-full max-w-3xl\">\n        <PromptInput\n          onSubmit={({ text }) => {\n            onSend(text);\n          }}\n        >\n          <PromptInputBody>\n            <PendingAttachmentList\n              attachments={pendingAttachments}\n              onRemoveAttachment={onRemovePendingAttachment}\n            />\n            <PromptInputTextarea\n              disabled={!isChatAvailable || isBusy}\n              placeholder=\"Ask about an uploaded image or PDF. The message will carry the files inline to the model.\"\n            />\n          </PromptInputBody>\n          <PromptInputFooter className=\"flex items-center justify-between gap-3 border-foreground/10 border-t px-3 py-3\">\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <Badge variant=\"outline\">Multimodal</Badge>\n              <Badge variant=\"outline\">Image + PDF</Badge>\n              <Badge variant=\"outline\">{chatModel}</Badge>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <input\n                accept=\"image/*,application/pdf\"\n                className=\"sr-only\"\n                multiple\n                onChange={(event) => onAppendFiles(event.target.files)}\n                ref={fileInputRef}\n                type=\"file\"\n              />\n              <Button\n                disabled={!isChatAvailable || isBusy}\n                onClick={() => fileInputRef.current?.click()}\n                size=\"sm\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <PaperclipIcon className=\"size-3.5\" />\n                Attach\n              </Button>\n              {isBusy ? (\n                <Button\n                  onClick={onStopChat}\n                  size=\"sm\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <StopIcon className=\"size-3.5\" />\n                  Stop\n                </Button>\n              ) : null}\n              {hasMessages ? (\n                <Button\n                  onClick={onRegenerateLastTurn}\n                  size=\"sm\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <ArrowClockwiseIcon className=\"size-3.5\" />\n                  Retry\n                </Button>\n              ) : null}\n              <PromptInputSubmit disabled={!isChatAvailable} status={status} />\n            </div>\n          </PromptInputFooter>\n        </PromptInput>\n\n        {hasMessages ? null : (\n          <MultimodalSamplePrompts\n            onSendSamplePrompt={onSendSamplePrompt}\n            prompts={samplePrompts}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction PendingAttachmentList({\n  attachments,\n  onRemoveAttachment,\n}: {\n  attachments: PendingAttachment[];\n  onRemoveAttachment: (attachmentId: string) => void;\n}) {\n  if (attachments.length === 0) {\n    return null;\n  }\n\n  return (\n    <Attachments className=\"mb-3\" variant=\"list\">\n      {attachments.map((attachment) => (\n        <Attachment\n          data={{\n            filename: attachment.file.name,\n            id: attachment.id,\n            mediaType: attachment.file.type,\n            type: \"file\",\n            url: attachment.previewUrl,\n          }}\n          key={attachment.id}\n          onRemove={() => onRemoveAttachment(attachment.id)}\n        >\n          <AttachmentPreview />\n          <AttachmentInfo showMediaType />\n          <AttachmentRemove>\n            <XIcon className=\"size-4\" />\n          </AttachmentRemove>\n        </Attachment>\n      ))}\n    </Attachments>\n  );\n}\n\nfunction MultimodalSamplePrompts({\n  prompts,\n  onSendSamplePrompt,\n}: {\n  prompts: readonly string[];\n  onSendSamplePrompt: (text: string) => void;\n}) {\n  return (\n    <div className=\"mt-3 flex flex-wrap gap-2\">\n      {prompts.map((prompt) => (\n        <Button\n          key={prompt}\n          onClick={() => onSendSamplePrompt(prompt)}\n          size=\"sm\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <ImagesIcon className=\"size-3.5\" />\n          {prompt}\n        </Button>\n      ))}\n    </div>\n  );\n}\n\nfunction MultimodalSidebar({\n  acceptedMediaTypes,\n  nodeVersion,\n}: {\n  acceptedMediaTypes: string[];\n  nodeVersion: string;\n}) {\n  return (\n    <aside className=\"border border-foreground/10 bg-background p-4 lg:min-h-0 lg:overflow-y-auto\">\n      <div className=\"space-y-4\">\n        <div>\n          <p className=\"text-[11px] text-muted-foreground uppercase tracking-[0.2em]\">\n            Runtime\n          </p>\n          <p className=\"mt-1 font-medium text-sm\">{nodeVersion}</p>\n        </div>\n        <div>\n          <p className=\"text-[11px] text-muted-foreground uppercase tracking-[0.2em]\">\n            Input contract\n          </p>\n          <p className=\"mt-1 text-sm\">\n            This workspace sends image and PDF attachments inline as message\n            parts, matching the official AI SDK multimodal guide.\n          </p>\n        </div>\n        <div>\n          <p className=\"text-[11px] text-muted-foreground uppercase tracking-[0.2em]\">\n            Accepted media\n          </p>\n          <div className=\"mt-2 flex flex-wrap gap-2\">\n            {acceptedMediaTypes.map((mediaType) => (\n              <Badge key={mediaType} variant=\"outline\">\n                {mediaType}\n              </Badge>\n            ))}\n          </div>\n        </div>\n        <div>\n          <p className=\"text-[11px] text-muted-foreground uppercase tracking-[0.2em]\">\n            Demo role\n          </p>\n          <p className=\"mt-1 text-sm\">\n            Use this slice when users bring ad-hoc files into the chat and need\n            model reasoning without an indexing pipeline.\n          </p>\n        </div>\n      </div>\n    </aside>\n  );\n}\n\nexport function MultimodalChatbotWorkspace({\n  acceptedMediaTypes,\n  chatModel,\n  isChatAvailable,\n  nodeVersion,\n  setupMessage,\n}: MultimodalChatbotWorkspaceProps) {\n  const controller = useMultimodalChatbotWorkspace();\n\n  return (\n    <div className=\"grid min-h-[70svh] gap-4 lg:h-full lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_20rem]\">\n      <section className=\"flex min-h-[70svh] flex-col border border-foreground/10 bg-background lg:h-full lg:min-h-0\">\n        {isChatAvailable ? null : (\n          <div className=\"border-foreground/10 border-b px-4 py-3 text-muted-foreground text-xs/relaxed\">\n            {setupMessage}\n          </div>\n        )}\n\n        {controller.chatErrorMessage ? (\n          <div className=\"border-foreground/10 border-b px-4 py-3 text-destructive text-xs/relaxed\">\n            {controller.chatErrorMessage}\n          </div>\n        ) : null}\n\n        {controller.composerError ? (\n          <div className=\"border-foreground/10 border-b px-4 py-3 text-destructive text-xs/relaxed\">\n            {controller.composerError}\n          </div>\n        ) : null}\n\n        <MultimodalConversation\n          hasMessages={controller.hasMessages}\n          messages={controller.messages}\n        />\n        <MultimodalComposer\n          chatModel={chatModel}\n          fileInputRef={controller.fileInputRef}\n          hasMessages={controller.hasMessages}\n          isBusy={controller.isBusy}\n          isChatAvailable={isChatAvailable}\n          onAppendFiles={controller.appendFiles}\n          onRegenerateLastTurn={controller.regenerateLastTurn}\n          onRemovePendingAttachment={controller.removePendingAttachment}\n          onSend={controller.handleSend}\n          onSendSamplePrompt={controller.sendSamplePrompt}\n          onStopChat={controller.stopChat}\n          pendingAttachments={controller.pendingAttachments}\n          samplePrompts={controller.samplePrompts}\n          status={controller.status}\n        />\n      </section>\n\n      <MultimodalSidebar\n        acceptedMediaTypes={acceptedMediaTypes}\n        nodeVersion={nodeVersion}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/multimodal-chatbot-workspace.tsx"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/use-multimodal-chatbot.ts",
      "content": "\"use client\";\n\nimport { Chat, useChat } from \"@ai-sdk/react\";\nimport { DefaultChatTransport, type UIMessage } from \"ai\";\nimport { useState } from \"react\";\n\nexport function useMultimodalChatbot() {\n  const [chat] = useState(\n    () =>\n      new Chat<UIMessage>({\n        transport: new DefaultChatTransport({\n          api: \"/api/demos/multimodal-chatbot\",\n        }),\n      })\n  );\n  const controller = useChat({ chat });\n  const hasMessages = controller.messages.length > 0;\n  const isBusy =\n    controller.status === \"submitted\" || controller.status === \"streaming\";\n\n  return {\n    ...controller,\n    hasMessages,\n    isBusy,\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/use-multimodal-chatbot.ts"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/use-multimodal-chatbot-workspace.ts",
      "content": "\"use client\";\n\nimport type { ChatStatus, UIMessage } from \"ai\";\nimport {\n  type RefObject,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { convertFilesToParts } from \"./convert-files-to-parts\";\nimport {\n  buildPendingAttachmentId,\n  mergePendingAttachments,\n  multimodalSamplePrompts,\n  type PendingAttachment,\n} from \"./multimodal-chatbot-session\";\nimport { useMultimodalChatbot } from \"./use-multimodal-chatbot\";\n\ninterface UseMultimodalChatbotWorkspaceController {\n  appendFiles: (fileList: FileList | null) => void;\n  chatErrorMessage: string | null;\n  composerError: string | null;\n  fileInputRef: RefObject<HTMLInputElement | null>;\n  handleSend: (text: string) => Promise<void>;\n  hasMessages: boolean;\n  isBusy: boolean;\n  messages: UIMessage[];\n  pendingAttachments: PendingAttachment[];\n  regenerateLastTurn: () => void;\n  removePendingAttachment: (attachmentId: string) => void;\n  samplePrompts: readonly string[];\n  sendSamplePrompt: (text: string) => void;\n  status: ChatStatus;\n  stopChat: () => void;\n}\n\nfunction buildPendingAttachment(file: File): PendingAttachment {\n  return {\n    file,\n    id: buildPendingAttachmentId(file),\n    previewUrl: URL.createObjectURL(file),\n  };\n}\n\nfunction revokePendingAttachments(attachments: PendingAttachment[]) {\n  for (const attachment of attachments) {\n    URL.revokeObjectURL(attachment.previewUrl);\n  }\n}\n\nfunction clearFileInput(input: HTMLInputElement | null) {\n  if (input) {\n    input.value = \"\";\n  }\n}\n\nexport function useMultimodalChatbotWorkspace(): UseMultimodalChatbotWorkspaceController {\n  const [pendingAttachments, setPendingAttachments] = useState<\n    PendingAttachment[]\n  >([]);\n  const [composerError, setComposerError] = useState<string | null>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);\n  const {\n    error,\n    hasMessages,\n    isBusy,\n    messages,\n    regenerate,\n    sendMessage,\n    status,\n    stop,\n  } = useMultimodalChatbot();\n\n  useEffect(() => {\n    pendingAttachmentsRef.current = pendingAttachments;\n  }, [pendingAttachments]);\n\n  useEffect(\n    () => () => {\n      revokePendingAttachments(pendingAttachmentsRef.current);\n    },\n    []\n  );\n\n  const clearPendingAttachments = useCallback(() => {\n    setPendingAttachments((current) => {\n      revokePendingAttachments(current);\n      return [];\n    });\n  }, []);\n\n  const appendFiles = useCallback((fileList: FileList | null) => {\n    if (!fileList) {\n      return;\n    }\n\n    const nextAttachments = Array.from(fileList).map(buildPendingAttachment);\n    setPendingAttachments((current) => {\n      const replacedIds = new Set(\n        nextAttachments.map((attachment) => attachment.id)\n      );\n\n      revokePendingAttachments(\n        current.filter((attachment) => replacedIds.has(attachment.id))\n      );\n\n      return mergePendingAttachments(current, nextAttachments);\n    });\n  }, []);\n\n  const removePendingAttachment = useCallback((attachmentId: string) => {\n    setPendingAttachments((current) => {\n      const removedAttachment = current.find(\n        (attachment) => attachment.id === attachmentId\n      );\n\n      if (removedAttachment) {\n        URL.revokeObjectURL(removedAttachment.previewUrl);\n      }\n\n      return current.filter((attachment) => attachment.id !== attachmentId);\n    });\n  }, []);\n\n  const handleSend = useCallback(\n    async (text: string) => {\n      const trimmedText = text.trim();\n\n      if (!trimmedText && pendingAttachments.length === 0) {\n        return;\n      }\n\n      try {\n        setComposerError(null);\n        const fileParts = await convertFilesToParts(\n          pendingAttachments.map((attachment) => attachment.file)\n        );\n        const parts = trimmedText\n          ? [{ type: \"text\" as const, text: trimmedText }, ...fileParts]\n          : fileParts;\n\n        sendMessage({\n          parts,\n          role: \"user\",\n        });\n        clearPendingAttachments();\n        clearFileInput(fileInputRef.current);\n      } catch (attachmentError) {\n        setComposerError(\n          attachmentError instanceof Error\n            ? attachmentError.message\n            : \"Failed to prepare attachments.\"\n        );\n      }\n    },\n    [clearPendingAttachments, pendingAttachments, sendMessage]\n  );\n\n  const sendSamplePrompt = useCallback(\n    (text: string) => {\n      sendMessage({ text });\n    },\n    [sendMessage]\n  );\n\n  return {\n    chatErrorMessage: error?.message ?? null,\n    composerError,\n    fileInputRef,\n    hasMessages,\n    isBusy,\n    messages,\n    pendingAttachments,\n    samplePrompts: multimodalSamplePrompts,\n    status,\n    appendFiles,\n    handleSend,\n    regenerateLastTurn: regenerate,\n    removePendingAttachment,\n    sendSamplePrompt,\n    stopChat: stop,\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/use-multimodal-chatbot-workspace.ts"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/convert-files-to-parts.ts",
      "content": "export function convertFilesToParts(files: File[]) {\n  return Promise.all(\n    files.map(\n      (file) =>\n        new Promise<{\n          filename: string;\n          mediaType: string;\n          type: \"file\";\n          url: string;\n        }>((resolve, reject) => {\n          const reader = new FileReader();\n\n          reader.onload = () => {\n            const result = reader.result;\n\n            if (typeof result !== \"string\") {\n              reject(new Error(`Failed to read ${file.name} as a data URL.`));\n              return;\n            }\n\n            resolve({\n              filename: file.name,\n              mediaType: file.type || \"application/octet-stream\",\n              type: \"file\",\n              url: result,\n            });\n          };\n\n          reader.onerror = () => {\n            reject(new Error(`Failed to read ${file.name} as a data URL.`));\n          };\n\n          reader.readAsDataURL(file);\n        })\n    )\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/convert-files-to-parts.ts"
    },
    {
      "path": "registry/multimodal-chatbot/components/multimodal-chatbot/multimodal-chatbot-session.ts",
      "content": "import type { FileUIPart, UIMessage } from \"ai\";\n\nexport interface PendingAttachment {\n  file: File;\n  id: string;\n  previewUrl: string;\n}\n\nexport const multimodalSamplePrompts = [\n  \"Summarize the uploaded PDF in three bullets.\",\n  \"What stands out in this image?\",\n  \"Compare the text in the PDF with the diagram screenshot.\",\n] as const;\n\nexport function getMultimodalMessageText(message: UIMessage) {\n  return message.parts\n    .filter((part) => part.type === \"text\")\n    .map((part) => part.text)\n    .join(\"\\n\");\n}\n\nexport function getMultimodalFileParts(message: UIMessage) {\n  return message.parts.filter(\n    (part): part is FileUIPart => part.type === \"file\"\n  );\n}\n\nexport function buildPendingAttachmentId(\n  file: Pick<File, \"lastModified\" | \"name\" | \"size\">\n) {\n  return `${file.name}-${file.size}-${file.lastModified}`;\n}\n\nexport function mergePendingAttachments(\n  current: PendingAttachment[],\n  next: PendingAttachment[]\n) {\n  const byId = new Map(\n    current.map((attachment) => [attachment.id, attachment])\n  );\n\n  for (const attachment of next) {\n    byId.set(attachment.id, attachment);\n  }\n\n  return Array.from(byId.values());\n}\n",
      "type": "registry:component",
      "target": "@components/multimodal-chatbot/multimodal-chatbot-session.ts"
    },
    {
      "path": "registry/multimodal-chatbot/components/ai-elements/prompt-input.tsx",
      "content": "\"use client\";\n\nimport { CornerDownLeftIcon, LoaderCircleIcon, SquareIcon } from \"lucide-react\";\nimport type {\n  ComponentProps,\n  FormEvent,\n  HTMLAttributes,\n  ReactNode,\n  TextareaHTMLAttributes,\n} from \"react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\ntype PromptStatus = \"error\" | \"ready\" | \"streaming\" | \"submitted\";\n\ninterface PromptInputContextValue {\n  setText: (text: string) => void;\n  text: string;\n}\n\ninterface PromptInputMessage {\n  text: string;\n}\n\ninterface PromptInputProps\n  extends Omit<HTMLAttributes<HTMLFormElement>, \"onSubmit\"> {\n  children: ReactNode;\n  onSubmit: (\n    message: PromptInputMessage,\n    event: FormEvent<HTMLFormElement>\n  ) => void | Promise<void>;\n}\n\ninterface PromptInputSubmitProps\n  extends Omit<ComponentProps<typeof Button>, \"children\" | \"type\"> {\n  onStop?: () => void;\n  status?: PromptStatus;\n}\n\ntype PromptInputClickEvent = Parameters<\n  Exclude<ComponentProps<typeof Button>[\"onClick\"], undefined>\n>[0];\n\nconst PromptInputContext = createContext<PromptInputContextValue | null>(null);\n\nfunction usePromptInputContext() {\n  const context = useContext(PromptInputContext);\n\n  if (!context) {\n    throw new Error(\n      \"PromptInput components must be used inside <PromptInput>.\"\n    );\n  }\n\n  return context;\n}\n\nexport function PromptInput({\n  children,\n  className,\n  onSubmit,\n  ...props\n}: PromptInputProps) {\n  const [text, setText] = useState(\"\");\n\n  const contextValue = useMemo(\n    () => ({\n      setText,\n      text,\n    }),\n    [text]\n  );\n\n  const handleSubmit = useCallback(\n    async (event: FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n\n      const nextText = text.trim();\n      if (!nextText) {\n        return;\n      }\n\n      const result = onSubmit({ text: nextText }, event);\n\n      try {\n        await result;\n        setText(\"\");\n      } catch {\n        // Keep the text for retry after a failed submit.\n      }\n    },\n    [onSubmit, text]\n  );\n\n  return (\n    <PromptInputContext.Provider value={contextValue}>\n      <form\n        className={cn(\"w-full\", className)}\n        onSubmit={handleSubmit}\n        {...props}\n      >\n        {children}\n      </form>\n    </PromptInputContext.Provider>\n  );\n}\n\nexport function PromptInputBody({\n  className,\n  ...props\n}: HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn(\"grid gap-3\", className)} {...props} />;\n}\n\nexport function PromptInputFooter({\n  className,\n  ...props\n}: HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn(className)} {...props} />;\n}\n\nexport function PromptInputTextarea({\n  className,\n  disabled,\n  onChange,\n  ...props\n}: TextareaHTMLAttributes<HTMLTextAreaElement>) {\n  const context = usePromptInputContext();\n\n  return (\n    <Textarea\n      className={cn(\"min-h-16 resize-none\", className)}\n      disabled={disabled}\n      onChange={(event) => {\n        context.setText(event.currentTarget.value);\n        onChange?.(event);\n      }}\n      value={context.text}\n      {...props}\n    />\n  );\n}\n\nexport function PromptInputSubmit({\n  className,\n  disabled,\n  onClick,\n  onStop,\n  size = \"icon\",\n  status = \"ready\",\n  variant = \"default\",\n  ...props\n}: PromptInputSubmitProps) {\n  const { text } = usePromptInputContext();\n  const isBusy = status === \"submitted\" || status === \"streaming\";\n  const isDisabled = Boolean(disabled) || (!isBusy && text.trim().length === 0);\n\n  let icon = <CornerDownLeftIcon className=\"size-4\" />;\n\n  if (status === \"submitted\") {\n    icon = <LoaderCircleIcon className=\"size-4 animate-spin\" />;\n  } else if (status === \"streaming\") {\n    icon = <SquareIcon className=\"size-4\" />;\n  }\n\n  return (\n    <Button\n      aria-label={isBusy ? \"Stop\" : \"Submit\"}\n      className={cn(className)}\n      disabled={isDisabled}\n      onClick={(event: PromptInputClickEvent) => {\n        if (isBusy && onStop) {\n          event.preventDefault();\n          onStop();\n          return;\n        }\n\n        onClick?.(event);\n      }}\n      size={size}\n      type={isBusy && onStop ? \"button\" : \"submit\"}\n      variant={variant}\n      {...props}\n    >\n      {icon}\n    </Button>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/ai-elements/prompt-input.tsx"
    },
    {
      "path": "registry/multimodal-chatbot/lib/ai-gateway/contract.ts",
      "content": "import { createGateway } from \"ai\";\n\nexport const DEFAULT_GATEWAY_BASE_URL = \"https://ai-gateway.vercel.sh/v3/ai\";\nexport const MINIMUM_NODE_VERSION = \"22.13.0\";\nconst nodeVersionPattern = /^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+].*)?$/;\n\nexport type AiGatewayEnvRecord = Record<string, string | undefined>;\n\nexport interface ParsedNodeVersion {\n  major: number;\n  minor: number;\n  patch: number;\n}\n\nexport interface AiGatewayContractConfig {\n  apiKey: string;\n  baseURL: string;\n  chatModel: string;\n}\n\nexport interface AiGatewayResolvedEnv {\n  apiKey: string | undefined;\n  baseURL: string;\n  chatModel: string;\n}\n\nexport interface AiGatewaySetupConfig {\n  baseURL: string;\n  chatModel: string;\n}\n\nexport interface AiGatewayContractSetupState<\n  TConfig extends AiGatewaySetupConfig = AiGatewaySetupConfig,\n> {\n  config: TConfig;\n  isReady: boolean;\n  issues: string[];\n  nodeVersion: string;\n}\n\nexport interface AiGatewayContractOptions<\n  TConfig extends AiGatewaySetupConfig = AiGatewaySetupConfig,\n> {\n  buildConfig?: (\n    resolvedEnv: AiGatewayResolvedEnv,\n    env: AiGatewayEnvRecord\n  ) => TConfig;\n  defaultBaseURL?: string;\n  defaultChatModel: string;\n  getAdditionalIssues?: (\n    resolvedEnv: AiGatewayResolvedEnv,\n    env: AiGatewayEnvRecord\n  ) => string[];\n  missingApiKeyError: string;\n  missingApiKeyIssue?: string;\n}\n\nconst genericMissingApiKeyIssue =\n  \"AI_GATEWAY_API_KEY is missing. The demo can render, but chat requests will fail until it is configured.\";\n\nexport function parseNodeVersion(version: string): ParsedNodeVersion {\n  const match = nodeVersionPattern.exec(version);\n  const major = Number(match?.[1]);\n  const minor = Number(match?.[2]);\n  const patch = Number(match?.[3]);\n\n  if (![major, minor, patch].every(Number.isInteger)) {\n    throw new Error(`Unable to parse Node.js version: \"${version}\".`);\n  }\n\n  return { major, minor, patch };\n}\n\nexport function getNodeMajor(version: string): number {\n  return parseNodeVersion(version).major;\n}\n\nfunction compareNodeVersions(\n  left: ParsedNodeVersion,\n  right: ParsedNodeVersion\n): number {\n  if (left.major !== right.major) {\n    return left.major - right.major;\n  }\n\n  if (left.minor !== right.minor) {\n    return left.minor - right.minor;\n  }\n\n  return left.patch - right.patch;\n}\n\nexport function assertSupportedNodeRuntime(version = process.version): number {\n  const parsedVersion = parseNodeVersion(version);\n  const minimumVersion = parseNodeVersion(MINIMUM_NODE_VERSION);\n\n  if (compareNodeVersions(parsedVersion, minimumVersion) < 0) {\n    throw new Error(\n      `Node.js ${version} is unsupported. This demo workspace requires Node.js >=${MINIMUM_NODE_VERSION}.`\n    );\n  }\n\n  return parsedVersion.major;\n}\n\nexport function resolveAiGatewayContractEnv(\n  env: AiGatewayEnvRecord,\n  options: Pick<AiGatewayContractOptions, \"defaultBaseURL\" | \"defaultChatModel\">\n): AiGatewayResolvedEnv {\n  return {\n    apiKey: env.AI_GATEWAY_API_KEY,\n    baseURL:\n      env.AI_GATEWAY_BASE_URL ||\n      options.defaultBaseURL ||\n      DEFAULT_GATEWAY_BASE_URL,\n    chatModel: env.AI_GATEWAY_CHAT_MODEL || options.defaultChatModel,\n  };\n}\n\nexport function readAiGatewayContractConfig(\n  env: AiGatewayEnvRecord,\n  options: AiGatewayContractOptions\n): AiGatewayContractConfig {\n  assertSupportedNodeRuntime();\n  const resolvedEnv = resolveAiGatewayContractEnv(env, options);\n\n  if (!resolvedEnv.apiKey) {\n    throw new Error(options.missingApiKeyError);\n  }\n\n  return {\n    apiKey: resolvedEnv.apiKey,\n    baseURL: resolvedEnv.baseURL,\n    chatModel: resolvedEnv.chatModel,\n  };\n}\n\nexport function buildAiGatewayContractSetupState<\n  TConfig extends AiGatewaySetupConfig,\n>(\n  env: AiGatewayEnvRecord,\n  options: AiGatewayContractOptions<TConfig>\n): AiGatewayContractSetupState<TConfig> {\n  const issues: string[] = [];\n  const resolvedEnv = resolveAiGatewayContractEnv(env, options);\n\n  try {\n    assertSupportedNodeRuntime();\n  } catch (error) {\n    issues.push(\n      error instanceof Error ? error.message : \"Unsupported Node.js runtime.\"\n    );\n  }\n\n  if (!resolvedEnv.apiKey) {\n    issues.push(options.missingApiKeyIssue || genericMissingApiKeyIssue);\n  }\n\n  issues.push(...(options.getAdditionalIssues?.(resolvedEnv, env) ?? []));\n\n  return {\n    config:\n      options.buildConfig?.(resolvedEnv, env) ??\n      ({\n        baseURL: resolvedEnv.baseURL,\n        chatModel: resolvedEnv.chatModel,\n      } as TConfig),\n    isReady: issues.length === 0,\n    issues,\n    nodeVersion: process.version,\n  };\n}\n\nexport function createAiGatewayFromContract(\n  env: AiGatewayEnvRecord,\n  options: AiGatewayContractOptions\n): ReturnType<typeof createGateway> {\n  const { apiKey, baseURL } = readAiGatewayContractConfig(env, options);\n\n  return createGateway({\n    apiKey,\n    baseURL,\n  });\n}\n",
      "type": "registry:lib",
      "target": "@lib/ai-gateway/contract.ts"
    },
    {
      "path": "registry/multimodal-chatbot/lib/multimodal-chatbot/env-source.ts",
      "content": "export function getMultimodalChatbotAppEnv() {\n  // biome-ignore lint/style/noProcessEnv: Registry source installs into consumer apps without this repo's env wrapper.\n  return process.env;\n}\n",
      "type": "registry:lib",
      "target": "@lib/multimodal-chatbot/env-source.ts"
    },
    {
      "path": "registry/multimodal-chatbot/lib/multimodal-chatbot/env.ts",
      "content": "import { getMultimodalChatbotAppEnv } from \"./env-source\";\nimport {\n  buildAiGatewayContractSetupState,\n  createAiGatewayFromContract,\n  readAiGatewayContractConfig,\n  type AiGatewayContractConfig,\n  type AiGatewayContractSetupState,\n  type AiGatewayEnvRecord,\n  type AiGatewaySetupConfig,\n} from \"@/lib/ai-gateway/contract\";\n\nexport const DEFAULT_CHAT_MODEL = \"openai/gpt-4.1-mini\";\n\nexport type MultimodalChatbotEnv = AiGatewayEnvRecord;\n\nexport type MultimodalChatbotConfig = AiGatewayContractConfig;\n\nexport type MultimodalChatbotSetupState = AiGatewayContractSetupState<AiGatewaySetupConfig>;\n\nexport type MultimodalChatbotGateway = ReturnType<typeof createAiGatewayFromContract>;\n\nconst multimodalChatbotContract = {\n  defaultChatModel: DEFAULT_CHAT_MODEL,\n  missingApiKeyError: \"Missing AI_GATEWAY_API_KEY. Add it to .env.local before using the multimodal chatbot.\",\n  missingApiKeyIssue: \"AI_GATEWAY_API_KEY is missing. The demo can render, but multimodal chat requests will fail until it is configured.\",\n} as const;\n\nexport function getMultimodalChatbotEnv(): MultimodalChatbotEnv {\n  return getMultimodalChatbotAppEnv();\n}\n\nexport function getMultimodalChatbotConfig(\n  env: MultimodalChatbotEnv = getMultimodalChatbotEnv()\n): MultimodalChatbotConfig {\n  return readAiGatewayContractConfig(env, multimodalChatbotContract);\n}\n\nexport function getMultimodalChatbotSetupState(\n  env: MultimodalChatbotEnv = getMultimodalChatbotEnv()\n): MultimodalChatbotSetupState {\n  return buildAiGatewayContractSetupState(env, multimodalChatbotContract);\n}\n\nexport function createMultimodalChatbotGateway(\n  env: MultimodalChatbotEnv = getMultimodalChatbotEnv()\n): MultimodalChatbotGateway {\n  return createAiGatewayFromContract(env, multimodalChatbotContract);\n}\n",
      "type": "registry:lib",
      "target": "@lib/multimodal-chatbot/env.ts"
    },
    {
      "path": "registry/multimodal-chatbot/lib/multimodal-chatbot/runtime.ts",
      "content": "import {\n  convertToModelMessages,\n  streamText,\n  type UIMessage,\n  validateUIMessages,\n} from \"ai\";\n\nimport {\n  createMultimodalChatbotGateway,\n  getMultimodalChatbotConfig,\n  getMultimodalChatbotEnv,\n  getMultimodalChatbotSetupState,\n  type MultimodalChatbotEnv,\n} from \"@/lib/multimodal-chatbot/env\";\n\nconst invalidMessagesError = 'Expected a JSON body with a \"messages\" array.';\nconst invalidUiMessagesError =\n  'Expected each \"messages\" entry to match the UIMessage format.';\nconst malformedJsonError = \"Expected a valid JSON request body.\";\nconst unsupportedMediaTypeError =\n  \"Only image attachments and PDF attachments are supported.\";\n\ninterface MultimodalChatbotRequestBody {\n  messages?: UIMessage[];\n}\n\nexport interface MultimodalChatbotRuntimeState {\n  acceptedMediaTypes: string[];\n  chatModel: string;\n  isChatAvailable: boolean;\n  nodeVersion: string;\n  setupMessage: string | null;\n  statusLabel: \"Ready\" | \"Setup required\";\n}\n\ninterface MultimodalChatbotRequestDependencies {\n  streamMultimodalChatbot: (\n    messages: UIMessage[],\n    env: MultimodalChatbotEnv\n  ) => Promise<Response>;\n}\n\nconst acceptedMediaTypes = [\"application/pdf\", \"image/*\"] as const;\n\nexport function getMultimodalChatbotRuntimeState(\n  env: MultimodalChatbotEnv = getMultimodalChatbotEnv()\n): MultimodalChatbotRuntimeState {\n  const setup = getMultimodalChatbotSetupState(env);\n\n  return {\n    acceptedMediaTypes: [...acceptedMediaTypes],\n    chatModel: setup.config.chatModel,\n    isChatAvailable: setup.isReady,\n    nodeVersion: setup.nodeVersion,\n    setupMessage: setup.issues.length > 0 ? setup.issues.join(\" \") : null,\n    statusLabel: setup.isReady ? \"Ready\" : \"Setup required\",\n  };\n}\n\nfunction assertAcceptedMediaTypes(messages: UIMessage[]) {\n  for (const message of messages) {\n    for (const part of message.parts) {\n      if (part.type !== \"file\") {\n        continue;\n      }\n\n      const mediaType = part.mediaType ?? \"\";\n      const isAccepted =\n        mediaType.startsWith(\"image/\") || mediaType === \"application/pdf\";\n\n      if (!isAccepted) {\n        throw new Error(unsupportedMediaTypeError);\n      }\n    }\n  }\n}\n\nasync function readMultimodalChatbotMessages(\n  body: unknown\n): Promise<UIMessage[]> {\n  const { messages } = (body ?? {}) as MultimodalChatbotRequestBody;\n\n  if (!Array.isArray(messages)) {\n    throw new Error(invalidMessagesError);\n  }\n\n  try {\n    const validatedMessages = await validateUIMessages({ messages });\n    assertAcceptedMediaTypes(validatedMessages);\n    return validatedMessages;\n  } catch (error) {\n    if (error instanceof Error && error.message === unsupportedMediaTypeError) {\n      throw error;\n    }\n\n    throw new Error(invalidUiMessagesError);\n  }\n}\n\nexport async function streamMultimodalChatbot(\n  messages: UIMessage[],\n  env: MultimodalChatbotEnv\n) {\n  const gateway = createMultimodalChatbotGateway(env);\n  const { chatModel } = getMultimodalChatbotConfig(env);\n\n  const result = streamText({\n    model: gateway(chatModel),\n    messages: await convertToModelMessages(messages),\n  });\n\n  return result.toUIMessageStreamResponse();\n}\n\nexport async function handleMultimodalChatbotRequest(\n  request: Request,\n  env: MultimodalChatbotEnv = getMultimodalChatbotEnv(),\n  dependencies: MultimodalChatbotRequestDependencies = {\n    streamMultimodalChatbot,\n  }\n) {\n  const runtimeState = getMultimodalChatbotRuntimeState(env);\n\n  if (!runtimeState.isChatAvailable) {\n    return Response.json(\n      {\n        error: runtimeState.setupMessage,\n      },\n      { status: 500 }\n    );\n  }\n\n  let messages: UIMessage[];\n\n  try {\n    messages = await readMultimodalChatbotMessages(await request.json());\n  } catch (error) {\n    if (error instanceof SyntaxError) {\n      return Response.json(\n        {\n          error: malformedJsonError,\n        },\n        { status: 400 }\n      );\n    }\n\n    if (\n      error instanceof Error &&\n      [\n        invalidMessagesError,\n        invalidUiMessagesError,\n        unsupportedMediaTypeError,\n      ].includes(error.message)\n    ) {\n      return Response.json(\n        {\n          error: error.message,\n        },\n        { status: 400 }\n      );\n    }\n\n    throw error;\n  }\n\n  return dependencies.streamMultimodalChatbot(messages, env);\n}\n",
      "type": "registry:lib",
      "target": "@lib/multimodal-chatbot/runtime.ts"
    }
  ],
  "envVars": {
    "AI_GATEWAY_API_KEY": "",
    "AI_GATEWAY_BASE_URL": "https://ai-gateway.vercel.sh/v3/ai",
    "AI_GATEWAY_CHAT_MODEL": "openai/gpt-4.1-mini"
  },
  "docs": "Install into a Next.js App Router project initialized with shadcn/ui. This item adds the multimodal demo page, chat route, client workspace, and AI Gateway env vars required for image and PDF chat.",
  "type": "registry:block"
}
