feat: add file upload and preview support (images and documents)

- Support image (JPEG/PNG/GIF/WebP, max 5MB) and document uploads
- Images sent as multimodal content blocks to Claude SDK
- Documents saved to temp dir for Claude's Read tool
- Emerald capsule badges in input for attached files
- Drag-and-drop, paste, and file picker support via PromptInput
- Chat display: image thumbnails with lightbox, file cards
- Lightbox uses Radix Dialog with prev/next navigation
- File metadata embedded in message content as HTML comments
- Compatible with existing slash command badge system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-08 21:35:45 +08:00
1 parent 4ac1f8cea3
commit 164f730da1
11 files changed
+562 -22

No files matched your search

+17 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent, FileAttachment } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
@@ -141,15 +141,22 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
}, [pendingPermission, setPendingApprovalSessionId]);
const sendMessage = useCallback(
async (content: string) => {
async (content: string, files?: FileAttachment[]) => {
if (isStreaming) return;
// Build display content: embed file metadata as HTML comment for MessageItem to parse
let displayContent = content;
if (files && files.length > 0) {
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content,
content: displayContent,
created_at: new Date().toISOString(),
token_usage: null,
};
@@ -171,7 +178,13 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, content, mode, model: currentModel }),
body: JSON.stringify({
session_id: sessionId,
content,
mode,
model: currentModel,
...(files && files.length > 0 ? { files } : {}),
}),
signal: controller.signal,
});
@@ -0,0 +1,71 @@
'use client';
import { useState, useCallback } from 'react';
import type { FileAttachment } from '@/types';
import { isImageFile } from '@/types';
import { ImageThumbnail } from './ImageThumbnail';
import { FileCard } from './FileCard';
import { ImageLightbox } from './ImageLightbox';
interface FileAttachmentDisplayProps {
files: FileAttachment[];
}
export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const imageFiles = files.filter((f) => isImageFile(f.type));
const otherFiles = files.filter((f) => !isImageFile(f.type));
const lightboxImages = imageFiles.map((f) => ({
src: `data:${f.type};base64,${f.data}`,
alt: f.name,
}));
const handlePreview = useCallback((index: number) => {
setLightboxIndex(index);
setLightboxOpen(true);
}, []);
if (files.length === 0) return null;
const imageGridCols =
imageFiles.length === 1
? 'grid-cols-1 max-w-xs'
: imageFiles.length === 2
? 'grid-cols-2 max-w-sm'
: 'grid-cols-3 max-w-md';
return (
<div className="space-y-2 mb-2">
{imageFiles.length > 0 && (
<div className={`grid gap-2 ${imageGridCols}`}>
{imageFiles.map((file, i) => (
<ImageThumbnail
key={file.id}
src={`data:${file.type};base64,${file.data}`}
alt={file.name}
onClick={() => handlePreview(i)}
/>
))}
</div>
)}
{otherFiles.length > 0 && (
<div className="space-y-1.5">
{otherFiles.map((file) => (
<FileCard key={file.id} name={file.name} size={file.size} />
))}
</div>
)}
<ImageLightbox
images={lightboxImages}
initialIndex={lightboxIndex}
open={lightboxOpen}
onOpenChange={setLightboxOpen}
/>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { FileIcon } from 'lucide-react';
import { formatFileSize } from '@/types';
interface FileCardProps {
name: string;
size: number;
}
export function FileCard({ name, size }: FileCardProps) {
return (
<div className="rounded-lg border bg-muted/50 p-3 flex items-center gap-3">
<FileIcon className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{name}</div>
<div className="text-xs text-muted-foreground">{formatFileSize(size)}</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState, useCallback } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
interface LightboxImage {
src: string;
alt: string;
}
interface ImageLightboxProps {
images: LightboxImage[];
initialIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImageLightbox({ images, initialIndex, open, onOpenChange }: ImageLightboxProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const goToPrev = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
}, [images.length]);
const goToNext = useCallback(() => {
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
}, [images.length]);
// Reset index when dialog opens with a new initialIndex
const handleOpenChange = useCallback((newOpen: boolean) => {
if (newOpen) {
setCurrentIndex(initialIndex);
}
onOpenChange(newOpen);
}, [initialIndex, onOpenChange]);
if (images.length === 0) return null;
const current = images[currentIndex];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-[95vw] max-h-[95vh] p-0 border-none bg-black/90 shadow-none sm:max-w-[95vw]"
showCloseButton
>
<DialogTitle className="sr-only">Image preview</DialogTitle>
<div className="relative flex items-center justify-center min-h-[50vh]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt}
className="max-w-[90vw] max-h-[90vh] object-contain"
/>
{images.length > 1 && (
<>
<button
type="button"
onClick={goToPrev}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
<button
type="button"
onClick={goToNext}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{currentIndex + 1} / {images.length}
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
+24
View File
@@ -0,0 +1,24 @@
'use client';
interface ImageThumbnailProps {
src: string;
alt: string;
onClick: () => void;
}
export function ImageThumbnail({ src, alt, onClick }: ImageThumbnailProps) {
return (
<button
type="button"
onClick={onClick}
className="rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className="max-h-32 w-full object-cover rounded-lg"
/>
</button>
);
}
+176 -8
View File
@@ -11,6 +11,8 @@ import {
HelpCircleIcon,
ArrowDown01Icon,
CommandLineIcon,
Attachment01Icon,
Cancel01Icon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { FolderPicker } from './FolderPicker';
@@ -21,11 +23,27 @@ import {
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
usePromptInputAttachments,
} from '@/components/ai-elements/prompt-input';
import type { ChatStatus } from 'ai';
import type { FileAttachment } from '@/types';
import { nanoid } from 'nanoid';
// Accepted file types for upload
const ACCEPTED_FILE_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'text/*',
'.md', '.json', '.csv', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs',
].join(',');
// Max file sizes
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion
interface MessageInputProps {
onSend: (content: string) => void;
onSend: (content: string, files?: FileAttachment[]) => void;
onCommand?: (command: string) => void;
onStop?: () => void;
disabled?: boolean;
@@ -145,6 +163,116 @@ const PROVIDER_MODEL_LABELS: Record<string, Record<string, string>> = {
},
};
/**
* Convert a data URL to a FileAttachment object.
*/
async function dataUrlToFileAttachment(
dataUrl: string,
filename: string,
mediaType: string,
): Promise<FileAttachment> {
// data:image/png;base64,<data> — extract the base64 part
const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
// Estimate raw size from base64 length
const size = Math.ceil((base64.length * 3) / 4);
return {
id: nanoid(),
name: filename,
type: mediaType || 'application/octet-stream',
size,
data: base64,
};
}
/**
* Submit button that's aware of file attachments. Must be rendered inside PromptInput.
*/
function FileAwareSubmitButton({
status,
onStop,
disabled,
inputValue,
hasBadge,
}: {
status: ChatStatus;
onStop?: () => void;
disabled?: boolean;
inputValue: string;
hasBadge: boolean;
}) {
const attachments = usePromptInputAttachments();
const hasFiles = attachments.files.length > 0;
const isStreaming = status === 'streaming' || status === 'submitted';
return (
<PromptInputSubmit
status={status}
onStop={onStop}
disabled={disabled || (!isStreaming && !inputValue.trim() && !hasBadge && !hasFiles)}
/>
);
}
/**
* Attachment button that opens the file dialog. Must be rendered inside PromptInput.
*/
function AttachFileButton() {
const attachments = usePromptInputAttachments();
return (
<PromptInputButton
onClick={() => attachments.openFileDialog()}
tooltip="Attach files"
>
<HugeiconsIcon icon={Attachment01Icon} className="h-3.5 w-3.5" />
</PromptInputButton>
);
}
/**
* Capsule display for attached files, rendered inside PromptInput context.
*/
function FileAttachmentsCapsules() {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) return null;
return (
<div className="flex w-full flex-wrap items-center gap-1.5 px-3 pt-2 pb-0 order-first">
{attachments.files.map((file) => {
const isImage = file.mediaType?.startsWith('image/');
return (
<span
key={file.id}
className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 pl-2 pr-1 py-0.5 text-xs font-medium border border-emerald-500/20"
>
{isImage && file.url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={file.url}
alt={file.filename || 'image'}
className="h-5 w-5 rounded object-cover"
/>
)}
<span className="max-w-[120px] truncate text-[11px]">
{file.filename || 'file'}
</span>
<button
type="button"
onClick={() => attachments.remove(file.id)}
className="ml-0.5 rounded-full p-0.5 hover:bg-emerald-500/20 transition-colors"
>
<HugeiconsIcon icon={Cancel01Icon} className="h-3 w-3" />
</button>
</span>
);
})}
</div>
);
}
export function MessageInput({
onSend,
onCommand,
@@ -351,12 +479,38 @@ export function MessageInput({
}
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
const handleSubmit = useCallback(async (_msg: { text: string; files: unknown[] }, e: FormEvent<HTMLFormElement>) => {
const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const content = inputValue.trim();
closePopover();
// Convert PromptInput FileUIParts (with data URLs) to FileAttachment[]
const convertFiles = async (): Promise<FileAttachment[]> => {
if (!msg.files || msg.files.length === 0) return [];
const attachments: FileAttachment[] = [];
for (const file of msg.files) {
if (!file.url) continue;
try {
const attachment = await dataUrlToFileAttachment(
file.url,
file.filename || 'file',
file.mediaType || 'application/octet-stream',
);
// Enforce per-type size limits
const isImage = attachment.type.startsWith('image/');
const sizeLimit = isImage ? MAX_IMAGE_SIZE : MAX_DOC_SIZE;
if (attachment.size <= sizeLimit) {
attachments.push(attachment);
}
} catch {
// Skip files that fail conversion
}
}
return attachments;
};
// If badge is active, expand the command/skill and send
if (badge) {
let expandedPrompt = '';
@@ -381,16 +535,20 @@ export function MessageInput({
? `${expandedPrompt}\n\nUser context: ${content}`
: expandedPrompt || badge.command;
const files = await convertFiles();
setBadge(null);
setInputValue('');
onSend(finalPrompt);
onSend(finalPrompt, files.length > 0 ? files : undefined);
return;
}
if (!content || disabled) return;
const files = await convertFiles();
const hasFiles = files.length > 0;
if ((!content && !hasFiles) || disabled) return;
// Check if it's a direct slash command typed in the input
if (content.startsWith('/')) {
if (content.startsWith('/') && !hasFiles) {
const cmd = BUILT_IN_COMMANDS.find(c => c.value === content);
if (cmd) {
if (cmd.immediate && onCommand) {
@@ -423,7 +581,7 @@ export function MessageInput({
}
}
onSend(content);
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
setInputValue('');
}, [inputValue, onSend, onCommand, disabled, closePopover, badge]);
@@ -575,6 +733,9 @@ export function MessageInput({
{/* PromptInput replaces the old input area */}
<PromptInput
onSubmit={handleSubmit}
accept={ACCEPTED_FILE_TYPES}
multiple
maxFileSize={MAX_FILE_SIZE}
>
{/* Command badge */}
{badge && (
@@ -596,6 +757,8 @@ export function MessageInput({
</span>
</div>
)}
{/* File attachment capsules */}
<FileAttachmentsCapsules />
<PromptInputTextarea
ref={textareaRef}
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
@@ -618,6 +781,9 @@ export function MessageInput({
</span>
</PromptInputButton>
{/* Attach file button */}
<AttachFileButton />
{/* Mode selector */}
<div className="relative" ref={modeMenuRef}>
<PromptInputButton
@@ -698,10 +864,12 @@ export function MessageInput({
)}
</div>
<PromptInputSubmit
<FileAwareSubmitButton
status={chatStatus}
onStop={onStop}
disabled={disabled || (!isStreaming && !inputValue.trim() && !badge)}
disabled={disabled}
inputValue={inputValue}
hasBadge={!!badge}
/>
</div>
</PromptInputFooter>
+30 -5
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
import type { Message, TokenUsage } from '@/types';
import type { Message, TokenUsage, FileAttachment } from '@/types';
import {
Message as AIMessage,
MessageContent,
@@ -16,6 +16,7 @@ import {
} from '@/components/ai-elements/tool';
import { CopyIcon, CheckIcon } from 'lucide-react';
import type { ToolUIPart } from 'ai';
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
interface MessageItemProps {
message: Message;
@@ -112,6 +113,18 @@ function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
return 'output-available';
}
function parseMessageFiles(content: string): { files: FileAttachment[]; text: string } {
const match = content.match(/^<!--files:(.*?)-->\n?/);
if (!match) return { files: [], text: content };
try {
const files = JSON.parse(match[1]);
const text = content.slice(match[0].length);
return { files, text };
} catch {
return { files: [], text: content };
}
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -164,6 +177,13 @@ export function MessageItem({ message }: MessageItemProps) {
const { text, tools } = parseToolBlocks(message.content);
const pairedTools = pairTools(tools);
// Parse file attachments from user messages
const { files, text: textWithoutFiles } = isUser
? parseMessageFiles(text)
: { files: [], text };
const displayText = isUser ? textWithoutFiles : text;
let tokenUsage: TokenUsage | null = null;
if (message.token_usage) {
try {
@@ -181,6 +201,11 @@ export function MessageItem({ message }: MessageItemProps) {
return (
<AIMessage from={isUser ? 'user' : 'assistant'}>
<MessageContent>
{/* File attachments for user messages */}
{isUser && files.length > 0 && (
<FileAttachmentDisplay files={files} />
)}
{/* Tool calls for assistant messages */}
{!isUser && pairedTools.length > 0 && (
<div className="space-y-2 w-full">
@@ -204,11 +229,11 @@ export function MessageItem({ message }: MessageItemProps) {
)}
{/* Text content */}
{text && (
{displayText && (
isUser ? (
<div className="text-sm whitespace-pre-wrap break-words">{text}</div>
<div className="text-sm whitespace-pre-wrap break-words">{displayText}</div>
) : (
<MessageResponse>{text}</MessageResponse>
<MessageResponse>{displayText}</MessageResponse>
)
)}
</MessageContent>
@@ -217,7 +242,7 @@ export function MessageItem({ message }: MessageItemProps) {
<div className={`flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <span className="text-xs text-muted-foreground/50">{timestamp}</span>}
{!isUser && tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
{text && <CopyButton text={text} />}
{displayText && <CopyButton text={displayText} />}
</div>
</AIMessage>
);