"use client"; import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, useAssistantApi, useAssistantState, } from "@assistant-ui/react"; import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react"; import Image from "next/image"; import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/shallow"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useDocumentUploadDialog } from "./document-upload-popup"; const useFileSrc = (file: File | undefined) => { const [src, setSrc] = useState(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 || attachment.type !== "image") return {}; // First priority: use File object if available (for new uploads) if (attachment.file) return { file: attachment.file }; // Second priority: use stored imageDataUrl (for persisted messages) // This is stored in our custom ChatAttachment interface const customAttachment = attachment as { imageDataUrl?: string }; if (customAttachment.imageDataUrl) { return { src: customAttachment.imageDataUrl }; } // Third priority: try to extract from content array (standard assistant-ui format) if (Array.isArray(attachment.content)) { const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image; if (contentSrc) return { src: contentSrc }; } return {}; }) ); return useFileSrc(file) ?? src; }; type AttachmentPreviewProps = { src: string; }; const AttachmentPreview: FC = ({ src }) => { const [isLoaded, setIsLoaded] = useState(false); return ( Image Preview setIsLoaded(true)} priority={false} /> ); }; const AttachmentPreviewDialog: FC = ({ children }) => { const src = useAttachmentSrc(); if (!src) return children; return ( {children} Image Attachment Preview
); }; const AttachmentThumb: FC = () => { const isImage = useAssistantState(({ attachment }) => attachment?.type === "image"); // Check if actively processing (running AND progress < 100) // When progress is 100, processing is done but waiting for send() const isProcessing = useAssistantState(({ attachment }) => { const status = attachment?.status; if (status?.type !== "running") return false; // If progress is defined and equals 100, processing is complete const progress = (status as { type: "running"; progress?: number }).progress; return progress === undefined || progress < 100; }); const src = useAttachmentSrc(); // Show loading spinner only when actively processing (not when done and waiting for send) if (isProcessing) { return (
); } return ( ); }; const AttachmentUI: FC = () => { const api = useAssistantApi(); const isComposer = api.attachment.source === "composer"; const isImage = useAssistantState(({ attachment }) => attachment?.type === "image"); // Check if actively processing (running AND progress < 100) // When progress is 100, processing is done but waiting for send() const isProcessing = useAssistantState(({ attachment }) => { const status = attachment?.status; if (status?.type !== "running") return false; const progress = (status as { type: "running"; progress?: number }).progress; return progress === undefined || progress < 100; }); const typeLabel = useAssistantState(({ attachment }) => { const type = attachment?.type; switch (type) { case "image": return "Image"; case "document": return "Document"; case "file": return "File"; default: return "File"; // Default fallback for unknown types } }); return ( #attachment-tile]:size-24" )} > {isComposer && !isProcessing && } {isProcessing ? ( Processing... ) : ( )} ); }; const AttachmentRemove: FC = () => { return ( ); }; /** * Image attachment with preview thumbnail (click to expand) */ const MessageImageAttachment: FC = () => { const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image"); const src = useAttachmentSrc(); if (!src) return null; return (
{attachmentName} {/* Hover overlay with filename */}
{attachmentName}
); }; /** * Document/file attachment as chip (similar to mentioned documents) */ const MessageDocumentAttachment: FC = () => { const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment"); return ( {attachmentName} ); }; /** * Attachment component for user messages * Shows image preview for images, chip for documents */ const MessageAttachmentChip: FC = () => { const isImage = useAssistantState(({ attachment }) => attachment?.type === "image"); if (isImage) { return ; } return ; }; export const UserMessageAttachments: FC = () => { return ; }; export const ComposerAttachments: FC = () => { return (
); }; export const ComposerAddAttachment: FC = () => { const chatAttachmentInputRef = useRef(null); const { openDialog } = useDocumentUploadDialog(); const handleFileUpload = () => { openDialog(); }; const handleChatAttachment = () => { chatAttachmentInputRef.current?.click(); }; // Prevent event bubbling when file input is clicked const handleFileInputClick = (e: React.MouseEvent) => { e.stopPropagation(); }; return ( <> Add attachment Upload Documents ); };