AI & LLM uploads

Chat attachments, multimodal input and RAG document ingestion.

Chat attachments

"use client"

import * as React from "react"
import type { StoredObject } from "@uploadcn/core"
import { PaperclipIcon } from "lucide-react"

import { ChatAttachments } from "@/components/chat-attachments"

interface Message {
  id: number
  text: string
  files: string[]
}

export default function ChatAttachmentExample() {
  const [messages, setMessages] = React.useState<Message[]>([])

  return (
    <div className="flex flex-col gap-4">
      {messages.length ? (
        <ol className="flex flex-col gap-2" aria-label="Messages">
          {messages.map((message) => (
            <li
              key={message.id}
              className="animate-in self-end rounded-2xl bg-secondary px-3 py-2 text-sm text-secondary-foreground fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none"
            >
              {message.text ? <p>{message.text}</p> : null}
              {message.files.length ? (
                <p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
                  <PaperclipIcon className="size-3" aria-label="Attachments" />
                  {message.files.join(", ")}
                </p>
              ) : null}
            </li>
          ))}
        </ol>
      ) : (
        <p className="text-center text-sm text-muted-foreground">
          Paste a screenshot, drop a PDF, or click the paperclip.
        </p>
      )}
      <ChatAttachments<StoredObject>
        onSend={({ text, attachments }) =>
          setMessages((previous) => [
            ...previous,
            {
              id: previous.length,
              text,
              files: attachments.map((item) => item.name),
            },
          ])
        }
      />
    </div>
  )
}

A compact composer for chat interfaces:

  • Attach by clicking, dragging onto the composer, or pasting screenshots.
  • Files upload while the user types; sending waits for them.
  • Images are downscaled to 1568px WebP, about what vision models work with, so attachments upload faster without changing answers.

Pass the stored objects to your model instead of base64 payloads:

onSend={async ({ text, attachments }) => {
  await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ text, files: attachments.map((item) => item.result) }),
  })
}}

On the server, give the model a signed URL (or fetch the object and pass its bytes) for each file.

Document ingestion (RAG)

"use client"

import { simulateBackendStep } from "@/examples/_demo"
import {
  DocumentIngest,
  INGEST_STAGES,
  type IngestProcess,
} from "@/components/document-ingest"

/**
 * After the bytes are stored, follow server-side ingestion. Here each stage
 * calls a demo endpoint; in production you'd poll a job or listen to SSE.
 */
const ingest: IngestProcess<unknown> = async (item, { setStage, signal }) => {
  for (const stage of INGEST_STAGES.slice(1)) {
    setStage(stage)
    // Your ingestion pipeline does the work; here a stand-in waits.
    await simulateBackendStep(item.name, stage, signal)
  }
}

export default function AiDocumentUploadExample() {
  return <DocumentIngest ingest={ingest} />
}

DocumentIngest follows each document through upload → parse → chunk → embed → index. The stages are driven by your backend through process:

const ingest: IngestProcess<StoredObject> = async (item, { setStage, signal }) => {
  const { jobId } = await api.ingest(item.result!.key, { signal })
  for await (const event of api.watch(jobId, { signal })) {
    setStage(event.stage) // "parse" | "chunk" | "embed" | "index"
  }
}

Throw to fail the item; it can be retried from the list.

On this page