logoassistant-ui

Attachments

Enable users to attach files to their messages, enhancing conversations with images, documents, and other content.

Sample Attachment

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:

npx shadcn@latest add @assistant-ui/attachment

Main Component

npm install @assistant-ui/react zustand
yarn add @assistant-ui/react zustand
pnpm add @assistant-ui/react zustand
bun add @assistant-ui/react zustand
xpm add @assistant-ui/react zustand
"use client";import { PropsWithChildren, useEffect, useState, type FC } from "react";import Image from "next/image";import { XIcon, PlusIcon, FileText } from "lucide-react";import {  AttachmentPrimitive,  ComposerPrimitive,  MessagePrimitive,  useAssistantState,  useAssistantApi,} from "@assistant-ui/react";import { useShallow } from "zustand/shallow";import {  Tooltip,  TooltipContent,  TooltipTrigger,} from "@/components/ui/tooltip";import {  Dialog,  DialogTitle,  DialogContent,  DialogTrigger,} from "@/components/ui/dialog";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const useFileSrc = (file: File | undefined) => {  const [src, setSrc] = useState<string | undefined>(undefined);  useEffect(() => {    if (!file) {      setSrc(undefined);      return;    }    const objectUrl = URL.createObjectURL(file);    setSrc(objectUrl);    return () => {      URL.revokeObjectURL(objectUrl);    };  }, [file]);  return src;};const useAttachmentSrc = () => {  const { file, src } = useAssistantState(    useShallow(({ attachment }): { file?: File; src?: string } => {      if (attachment.type !== "image") return {};      if (attachment.file) return { file: attachment.file };      const src = attachment.content?.filter((c) => c.type === "image")[0]        ?.image;      if (!src) return {};      return { src };    }),  );  return useFileSrc(file) ?? src;};type AttachmentPreviewProps = {  src: string;};const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {  const [isLoaded, setIsLoaded] = useState(false);  return (    <Image      src={src}      alt="Image Preview"      width={1}      height={1}      className={        isLoaded          ? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"          : "aui-attachment-preview-image-loading hidden"      }      onLoadingComplete={() => setIsLoaded(true)}      priority={false}    />  );};const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {  const src = useAttachmentSrc();  if (!src) return children;  return (    <Dialog>      <DialogTrigger        className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"        asChild      >        {children}      </DialogTrigger>      <DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">        <DialogTitle className="aui-sr-only sr-only">          Image Attachment Preview        </DialogTitle>        <div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">          <AttachmentPreview src={src} />        </div>      </DialogContent>    </Dialog>  );};const AttachmentThumb: FC = () => {  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  const src = useAttachmentSrc();  return (    <Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">      <AvatarImage        src={src}        alt="Attachment preview"        className="aui-attachment-tile-image object-cover"      />      <AvatarFallback delayMs={isImage ? 200 : 0}>        <FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />      </AvatarFallback>    </Avatar>  );};const AttachmentUI: FC = () => {  const api = useAssistantApi();  const isComposer = api.attachment.source === "composer";  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  const typeLabel = useAssistantState(({ attachment }) => {    const type = attachment.type;    switch (type) {      case "image":        return "Image";      case "document":        return "Document";      case "file":        return "File";      default:        const _exhaustiveCheck: never = type;        throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);    }  });  return (    <Tooltip>      <AttachmentPrimitive.Root        className={cn(          "aui-attachment-root relative",          isImage &&            "aui-attachment-root-composer only:[&>#attachment-tile]:size-24",        )}      >        <AttachmentPreviewDialog>          <TooltipTrigger asChild>            <div              className={cn(                "aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",                isComposer &&                  "aui-attachment-tile-composer border-foreground/20",              )}              role="button"              id="attachment-tile"              aria-label={`${typeLabel} attachment`}            >              <AttachmentThumb />            </div>          </TooltipTrigger>        </AttachmentPreviewDialog>        {isComposer && <AttachmentRemove />}      </AttachmentPrimitive.Root>      <TooltipContent side="top">        <AttachmentPrimitive.Name />      </TooltipContent>    </Tooltip>  );};const AttachmentRemove: FC = () => {  return (    <AttachmentPrimitive.Remove asChild>      <TooltipIconButton        tooltip="Remove file"        className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"        side="top"      >        <XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />      </TooltipIconButton>    </AttachmentPrimitive.Remove>  );};export const UserMessageAttachments: FC = () => {  return (    <div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">      <MessagePrimitive.Attachments components={{ Attachment: AttachmentUI }} />    </div>  );};export const ComposerAttachments: FC = () => {  return (    <div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">      <ComposerPrimitive.Attachments        components={{ Attachment: AttachmentUI }}      />    </div>  );};export const ComposerAddAttachment: FC = () => {  return (    <ComposerPrimitive.AddAttachment asChild>      <TooltipIconButton        tooltip="Add Attachment"        side="bottom"        variant="ghost"        size="icon"        className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"        aria-label="Add Attachment"      >        <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />      </TooltipIconButton>    </ComposerPrimitive.AddAttachment>  );};

assistant-ui dependencies

"use client";import { ComponentPropsWithRef, forwardRef } from "react";import { Slottable } from "@radix-ui/react-slot";import {  Tooltip,  TooltipContent,  TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {  tooltip: string;  side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef<  HTMLButtonElement,  TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {  return (    <Tooltip>      <TooltipTrigger asChild>        <Button          variant="ghost"          size="icon"          {...rest}          className={cn("aui-button-icon size-6 p-1", className)}          ref={ref}        >          <Slottable>{children}</Slottable>          <span className="aui-sr-only sr-only">{tooltip}</span>        </Button>      </TooltipTrigger>      <TooltipContent side={side}>{tooltip}</TooltipContent>    </Tooltip>  );});TooltipIconButton.displayName = "TooltipIconButton";

shadcn/ui dependencies

npm install @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
yarn add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
pnpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
bun add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
xpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
"use client";import * as React from "react";import * as DialogPrimitive from "@radix-ui/react-dialog";import { XIcon } from "lucide-react";import { cn } from "@/lib/utils";function Dialog({  ...props}: React.ComponentProps<typeof DialogPrimitive.Root>) {  return <DialogPrimitive.Root data-slot="dialog" {...props} />;}function DialogTrigger({  ...props}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal({  ...props}: React.ComponentProps<typeof DialogPrimitive.Portal>) {  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose({  ...props}: React.ComponentProps<typeof DialogPrimitive.Close>) {  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {  return (    <DialogPrimitive.Overlay      data-slot="dialog-overlay"      className={cn(        "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",        className,      )}      {...props}    />  );}function DialogContent({  className,  children,  ...props}: React.ComponentProps<typeof DialogPrimitive.Content>) {  return (    <DialogPortal data-slot="dialog-portal">      <DialogOverlay />      <DialogPrimitive.Content        data-slot="dialog-content"        className={cn(          "data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",          className,        )}        {...props}      >        {children}        <DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0">          <XIcon />          <span className="sr-only">Close</span>        </DialogPrimitive.Close>      </DialogPrimitive.Content>    </DialogPortal>  );}function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-header"      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}      {...props}    />  );}function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-footer"      className={cn(        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",        className,      )}      {...props}    />  );}function DialogTitle({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Title>) {  return (    <DialogPrimitive.Title      data-slot="dialog-title"      className={cn("font-semibold text-lg leading-none", className)}      {...props}    />  );}function DialogDescription({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Description>) {  return (    <DialogPrimitive.Description      data-slot="dialog-description"      className={cn("text-muted-foreground text-sm", className)}      {...props}    />  );}export {  Dialog,  DialogClose,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogOverlay,  DialogPortal,  DialogTitle,  DialogTrigger,};
"use client";import * as React from "react";import * as TooltipPrimitive from "@radix-ui/react-tooltip";import { cn } from "@/lib/utils";function TooltipProvider({  delayDuration = 0,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {  return (    <TooltipPrimitive.Provider      data-slot="tooltip-provider"      delayDuration={delayDuration}      {...props}    />  );}function Tooltip({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Root>) {  return (    <TooltipProvider>      <TooltipPrimitive.Root data-slot="tooltip" {...props} />    </TooltipProvider>  );}function TooltipTrigger({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({  className,  sideOffset = 0,  children,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Content>) {  return (    <TooltipPrimitive.Portal>      <TooltipPrimitive.Content        data-slot="tooltip-content"        sideOffset={sideOffset}        className={cn(          "fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",          className,        )}        {...props}      >        {children}        <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />      </TooltipPrimitive.Content>    </TooltipPrimitive.Portal>  );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
"use client";import * as React from "react";import * as AvatarPrimitive from "@radix-ui/react-avatar";import { cn } from "@/lib/utils";function Avatar({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Root>) {  return (    <AvatarPrimitive.Root      data-slot="avatar"      className={cn(        "relative flex size-8 shrink-0 overflow-hidden rounded-full",        className,      )}      {...props}    />  );}function AvatarImage({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Image>) {  return (    <AvatarPrimitive.Image      data-slot="avatar-image"      className={cn("aspect-square size-full", className)}      {...props}    />  );}function AvatarFallback({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {  return (    <AvatarPrimitive.Fallback      data-slot="avatar-fallback"      className={cn(        "flex size-full items-center justify-center rounded-full bg-muted",        className,      )}      {...props}    />  );}export { Avatar, AvatarImage, AvatarFallback };
import * as React from "react";import { Slot } from "@radix-ui/react-slot";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva(  "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm no-underline outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",  {    variants: {      variant: {        default:          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",        destructive:          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",        outline:          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",        secondary:          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",        ghost:          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",        link: "text-primary underline-offset-4 hover:underline",      },      size: {        default: "h-9 px-4 py-2 has-[>svg]:px-3",        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",        icon: "size-9",      },    },    defaultVariants: {      variant: "default",      size: "default",    },  },);function Button({  className,  variant,  size,  asChild = false,  ...props}: React.ComponentProps<"button"> &  VariantProps<typeof buttonVariants> & {    asChild?: boolean;  }) {  const Comp = asChild ? Slot : "button";  return (    <Comp      data-slot="button"      className={cn(buttonVariants({ variant, size, className }))}      {...props}    />  );}export { Button, buttonVariants };

This adds /components/assistant-ui/attachment.tsx to your project.

Next steps: Feel free to adjust these auto-generated components (styling, layout, behavior) to match your application's design system.

Set up Runtime (No Configuration Required)

For useChatRuntime, attachments work automatically without additional configuration:

/app/MyRuntimeProvider.tsx
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";

const runtime = useChatRuntime({
  api: "/api/chat",
});

Note: The AI SDK runtime handles attachments automatically. For other runtimes like useLocalRuntime, you may still need to configure attachment adapters as shown in the Creating Custom Attachment Adapters section below.

Add UI Components

Integrate attachment components into your chat interface:

/components/assistant-ui/thread.tsx
// 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

SimpleImageAttachmentAdapter

Handles image files and converts them to data URLs for display in the chat UI. By default, images are shown inline but not sent to the LLM - use the VisionImageAdapter example above to send images to vision-capable models.

const imageAdapter = new SimpleImageAttachmentAdapter();
// Accepts: image/* (JPEG, PNG, GIF, etc.)
// Output: { type: "image", url: "data:image/..." }

SimpleTextAttachmentAdapter

Processes text files and wraps content in formatted tags:

const textAdapter = new SimpleTextAttachmentAdapter();
// Accepts: text/plain, text/html, text/markdown, etc.
// Output: Content wrapped in <attachment>...</attachment> tags

CompositeAttachmentAdapter

Combines multiple adapters to support various file types:

const compositeAdapter = new CompositeAttachmentAdapter([
  new SimpleImageAttachmentAdapter(),
  new SimpleTextAttachmentAdapter(),
  // Add more adapters as needed
]);

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: "running" },
    };
  }

  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: "running" },
    };
  }

  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", 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", progress },
      } as PendingAttachment;
    }

    // Return final pending state
    return {
      id,
      type: "file",
      name: file.name,
      file,
      status: { type: "running", 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",
          error: new Error("File size exceeds 5MB limit"),
        },
      };
    }

    // 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",
          error,
        },
      };
    }

    // Return valid attachment
    return {
      id: generateId(),
      type: "image",
      name: file.name,
      file,
      status: { type: "running" },
    };
  }

  private async getImageDimensions(file: File) {
    // Implementation to check image dimensions
  }
}

Multiple File Selection

Enable multi-file selection with custom limits:

const api = useAssistantApi();

const handleMultipleFiles = async (files: FileList) => {
  const maxFiles = 5;
  const filesToAdd = Array.from(files).slice(0, maxFiles);

  for (const file of filesToAdd) {
    await api.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, useAssistantRuntime
  • External Store: useExternalStoreRuntime
  • LangGraph: useLangGraphRuntime
  • Custom Runtimes: Any runtime implementing the attachment interface

The attachment system is designed to be extensible. You can create adapters for any file type, integrate with cloud storage services, or implement custom processing logic to fit your specific needs.

Best Practices

  1. File Size Limits: Always validate file sizes to prevent memory issues
  2. Type Validation: Verify file types match your accept pattern
  3. Error Handling: Provide clear error messages for failed uploads
  4. Progress Feedback: Show upload progress for better UX
  5. Security: Validate and sanitize file content before processing
  6. Accessibility: Ensure attachment UI is keyboard navigable

Resources