import { AttachmentSample } from "@/components/docs/samples/attachment";
Enable users to attach files to their messages, enhancing conversations with images, documents, and other content.
Overview#
The attachment system in assistant-ui provides a flexible framework for handling file uploads in your AI chat interface. It consists of:
- Attachment Adapters: Backend logic for processing attachment files
- UI Components: Pre-built components for attachment display and interaction
- Runtime Integration: Seamless integration with all assistant-ui runtimes
Getting Started#
Install UI Components#
First, add the attachment UI components to your project:
<InstallCommand shadcn={["attachment"]} />
This adds /components/assistant-ui/attachment.tsx to your project.
Set up Runtime (No Configuration Required)#
For useChatRuntime, attachments work automatically without additional configuration:
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
const runtime = useChatRuntime();
Add UI Components#
Integrate attachment components into your chat interface:
// In your Composer component
import {
ComposerAttachments,
ComposerAddAttachment,
} from "@/components/assistant-ui/attachment";
const Composer = () => {
return (
<ComposerPrimitive.Root>
<ComposerAttachments />
<ComposerAddAttachment />
<ComposerPrimitive.Input placeholder="Type a message..." />
</ComposerPrimitive.Root>
);
};
// In your UserMessage component
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
const UserMessage = () => {
return (
<MessagePrimitive.Root>
<UserMessageAttachments />
<MessagePrimitive.Parts />
</MessagePrimitive.Root>
);
};
Built-in Attachment Adapters#
AI SDK Runtime (Default)#
When using useChatRuntime, the built-in adapter accepts all file types and converts them to base64 data URLs. This works well for images and small files.
To restrict accepted file types, pass a custom adapter:
const runtime = useChatRuntime({
adapters: {
attachments: new SimpleImageAttachmentAdapter(), // only images
},
});
SimpleImageAttachmentAdapter#
Handles image files and converts them to data URLs for display in the chat UI.
const imageAdapter = new SimpleImageAttachmentAdapter();
// Accepts: image/* (JPEG, PNG, GIF, etc.)
SimpleTextAttachmentAdapter#
Processes text files and wraps content in formatted tags:
const textAdapter = new SimpleTextAttachmentAdapter();
// Accepts: text/plain, text/html, text/markdown, etc.
CompositeAttachmentAdapter#
Combines multiple adapters to support various file types:
const compositeAdapter = new CompositeAttachmentAdapter([
new SimpleImageAttachmentAdapter(),
new SimpleTextAttachmentAdapter(),
]);
Creating Custom Attachment Adapters#
Build your own adapters for specialized file handling. Below are complete examples for common use cases.
Vision-Capable Image Adapter#
Send images to vision-capable LLMs like GPT-4V, Claude 3, or Gemini Pro Vision:
import {
AttachmentAdapter,
PendingAttachment,
CompleteAttachment,
} from "@assistant-ui/react";
class VisionImageAdapter implements AttachmentAdapter {
accept = "image/jpeg,image/png,image/webp,image/gif";
async add({ file }: { file: File }): Promise<PendingAttachment> {
// Validate file size (e.g., 20MB limit for most LLMs)
const maxSize = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
throw new Error("Image size exceeds 20MB limit");
}
// Return pending attachment while processing
return {
id: crypto.randomUUID(),
type: "image",
name: file.name,
file,
status: { type: "requires-action", reason: "composer-send" },
};
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
// Convert image to base64 data URL
const base64 = await this.fileToBase64DataURL(attachment.file);
// Return in assistant-ui format with image content
return {
id: attachment.id,
type: "image",
name: attachment.name,
content: [
{
type: "image",
image: base64, // data:image/jpeg;base64,... format
},
],
status: { type: "complete" },
};
}
async remove(attachment: PendingAttachment): Promise<void> {
// Cleanup if needed (e.g., revoke object URLs if you created any)
}
private async fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// FileReader result is already a data URL
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
}
PDF Document Adapter#
Handle PDF files by extracting text or converting to base64 for processing:
import {
AttachmentAdapter,
PendingAttachment,
CompleteAttachment,
} from "@assistant-ui/react";
class PDFAttachmentAdapter implements AttachmentAdapter {
accept = "application/pdf";
async add({ file }: { file: File }): Promise<PendingAttachment> {
// Validate file size
const maxSize = 10 * 1024 * 1024; // 10MB limit
if (file.size > maxSize) {
throw new Error("PDF size exceeds 10MB limit");
}
return {
id: crypto.randomUUID(),
type: "document",
name: file.name,
file,
status: { type: "requires-action", reason: "composer-send" },
};
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
// Option 1: Extract text from PDF (requires pdf parsing library)
// const text = await this.extractTextFromPDF(attachment.file);
// Option 2: Convert to base64 for API processing
const base64Data = await this.fileToBase64(attachment.file);
return {
id: attachment.id,
type: "document",
name: attachment.name,
content: [
{
type: "text",
text: `[PDF Document: ${attachment.name}]\nBase64 data: ${base64Data.substring(0, 50)}...`,
},
],
status: { type: "complete" },
};
}
async remove(attachment: PendingAttachment): Promise<void> {
// Cleanup if needed
}
private async fileToBase64(file: File): Promise<string> {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
// Optional: Extract text from PDF using a library like pdf.js
private async extractTextFromPDF(file: File): Promise<string> {
// Implementation would use pdf.js or similar
// This is a placeholder
return "Extracted PDF text content";
}
}
Using Custom Adapters#
With LocalRuntime#
When using LocalRuntime, you need to handle images in your ChatModelAdapter (the adapter that connects to your AI backend):
import { useLocalRuntime, ChatModelAdapter } from "@assistant-ui/react";
// This adapter connects LocalRuntime to your AI backend
const MyModelAdapter: ChatModelAdapter = {
async run({ messages, abortSignal }) {
// Convert messages to format expected by your vision-capable API
const formattedMessages = messages.map((msg) => {
if (
msg.role === "user" &&
msg.content.some((part) => part.type === "image")
) {
// Format for GPT-4V or similar vision models
return {
role: "user",
content: msg.content.map((part) => {
if (part.type === "text") {
return { type: "text", text: part.text };
}
if (part.type === "image") {
return {
type: "image_url",
image_url: { url: part.image },
};
}
return part;
}),
};
}
// Regular text messages
return {
role: msg.role,
content: msg.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n"),
};
});
// Send to your vision-capable API
const response = await fetch("/api/vision-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: formattedMessages }),
signal: abortSignal,
});
const data = await response.json();
return {
content: [{ type: "text", text: data.message }],
};
},
};
// Create runtime with vision image adapter
const runtime = useLocalRuntime(MyModelAdapter, {
adapters: {
attachments: new VisionImageAdapter(),
},
});
Advanced Features#
Progress Updates#
Provide real-time upload progress using async generators:
class UploadAttachmentAdapter implements AttachmentAdapter {
accept = "*/*";
async *add({ file }: { file: File }) {
const id = generateId();
// Initial pending state
yield {
id,
type: "file",
name: file.name,
file,
status: { type: "running", reason: "uploading", progress: 0 },
} as PendingAttachment;
// Simulate upload progress
for (let progress = 10; progress <= 90; progress += 10) {
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
id,
type: "file",
name: file.name,
file,
status: { type: "running", reason: "uploading", progress },
} as PendingAttachment;
}
// Return final pending state
return {
id,
type: "file",
name: file.name,
file,
status: { type: "running", reason: "uploading", progress: 100 },
} as PendingAttachment;
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
// Upload the file and return complete attachment
const url = await this.uploadFile(attachment.file);
return {
id: attachment.id,
type: attachment.type,
name: attachment.name,
content: [
{
type: "file",
data: url, // or base64 data
mimeType: attachment.file.type,
},
],
status: { type: "complete" },
};
}
async remove(attachment: PendingAttachment): Promise<void> {
// Cleanup logic
}
private async uploadFile(file: File): Promise<string> {
// Your upload logic here
return "https://example.com/file-url";
}
}
Validation and Error Handling#
Implement robust validation in your adapters:
class ValidatedImageAdapter implements AttachmentAdapter {
accept = "image/*";
maxSizeBytes = 5 * 1024 * 1024; // 5MB
async add({ file }: { file: File }): Promise<PendingAttachment> {
// Validate file size
if (file.size > this.maxSizeBytes) {
return {
id: generateId(),
type: "image",
name: file.name,
file,
status: {
type: "incomplete",
reason: "error",
},
};
}
// Validate image dimensions
try {
const dimensions = await this.getImageDimensions(file);
if (dimensions.width > 4096 || dimensions.height > 4096) {
throw new Error("Image dimensions exceed 4096x4096");
}
} catch (error) {
return {
id: generateId(),
type: "image",
name: file.name,
file,
status: {
type: "incomplete",
reason: "error",
},
};
}
// Return valid attachment
return {
id: generateId(),
type: "image",
name: file.name,
file,
status: { type: "requires-action", reason: "composer-send" },
};
}
private async getImageDimensions(file: File) {
// Implementation to check image dimensions
}
}
External Source Attachments#
Add attachments from external sources (URLs, API data, CMS references) without needing a File object or an AttachmentAdapter:
const aui = useAui();
// Add an attachment from an external source
await aui.composer().addAttachment({
name: "report.pdf",
contentType: "application/pdf",
content: [{ type: "text", text: "Extracted document content..." }],
});
// Optionally provide id and type
await aui.composer().addAttachment({
id: "cms-doc-123",
type: "document",
name: "Product Spec",
content: [{ type: "text", text: "Product specification content..." }],
});
External attachments are added as complete attachments directly — they skip the AttachmentAdapter entirely and can be removed without one.
Multiple File Selection#
Enable multi-file selection with custom limits:
const aui = useAui();
const handleMultipleFiles = async (files: FileList) => {
const maxFiles = 5;
const filesToAdd = Array.from(files).slice(0, maxFiles);
for (const file of filesToAdd) {
await aui.composer().addAttachment(file);
}
};
Backend Integration#
With Vercel AI SDK#
Attachments are sent to the backend as file content parts.
Runtime Support#
Attachments work with all assistant-ui runtimes:
- AI SDK Runtime:
useChatRuntime - External Store:
useExternalStoreRuntime - LangGraph:
useLangGraphRuntime - Custom Runtimes: Any runtime implementing the attachment interface
Large File Uploads#
The built-in adapters convert files to base64 data URLs in memory. For large files (long audio, video, etc.), this can cause performance issues. Instead, upload to a server and pass the URL:
class ServerUploadAdapter implements AttachmentAdapter {
accept = "*";
private urls = new Map<string, string>();
async *add({ file }: { file: File }) {
const id = crypto.randomUUID();
yield {
id, type: "file" as const, name: file.name, file,
contentType: file.type,
status: { type: "running" as const, reason: "uploading" as const, progress: 0 },
};
const form = new FormData();
form.append("file", file);
const { url } = await fetch("/api/upload", { method: "POST", body: form }).then(r => r.json());
this.urls.set(id, url);
yield {
id, type: "file" as const, name: file.name, file,
contentType: file.type,
status: { type: "requires-action" as const, reason: "composer-send" as const },
};
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
const url = this.urls.get(attachment.id)!;
this.urls.delete(attachment.id);
return {
...attachment, status: { type: "complete" },
content: [{ type: "file", data: url, mimeType: attachment.contentType ?? "", filename: attachment.name }],
};
}
async remove() {}
}
Best Practices#
- File Size Limits: Always validate file sizes to prevent memory issues
- Type Validation: Verify file types match your
acceptpattern - Error Handling: Provide clear error messages for failed uploads
- Progress Feedback: Show upload progress for better UX
- Security: Validate and sanitize file content before processing
- Accessibility: Ensure attachment UI is keyboard navigable
Resources#
- Attachment UI Components - UI implementation details
- API Reference - Detailed type definitions