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.