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

+15
View File
@@ -13,6 +13,21 @@ CodePilot — Claude Code 的原生桌面 GUI 客户端,基于 Electron + Next
3. 构建命令:`npm run electron:pack:mac`(macOS)/ `npm run electron:pack:win`(Windows)
4. 上传产物到 GitHub Release 并编写 release notes
## Development Rules
**提交前必须详尽测试:**
- 每次提交代码前,必须在开发环境中充分测试所有改动的功能,确认无回归
- 涉及前端 UI 的改动需要实际启动应用验证(`npm run dev` 或 `npm run electron:dev`)
- 涉及构建/打包的改动需要完整执行一次打包流程验证产物可用
- 涉及多平台的改动需要考虑各平台的差异性
**新增功能前必须详尽调研:**
- 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践
- 涉及 Electron API 需确认目标版本支持情况
- 涉及第三方库需确认与现有依赖的兼容性
- 涉及 Claude Code SDK 需确认 SDK 实际支持的功能和调用方式
- 对不确定的技术点先做 POC 验证,不要直接在主代码中试错
## Build Notes
- macOS 构建产出 DMG(arm64 + x64),Windows 产出 NSIS 安装包或 zip
+15 -3
View File
@@ -1,15 +1,15 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock } from '@/types';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const body: SendMessageRequest = await request.json();
const { session_id, content, model, mode } = body;
const body: SendMessageRequest & { files?: FileAttachment[] } = await request.json();
const { session_id, content, model, mode, files } = body;
if (!session_id || !content) {
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
@@ -63,6 +63,17 @@ export async function POST(request: NextRequest) {
abortController.abort();
});
// Convert file attachments to the format expected by streamClaude
const fileAttachments: FileAttachment[] | undefined = files && files.length > 0
? files.map((f, i) => ({
id: f.id || `file-${Date.now()}-${i}`,
name: f.name,
type: f.type,
size: f.size,
data: f.data,
}))
: undefined;
// Stream Claude response, using SDK session ID for resume if available
const stream = streamClaude({
prompt: content,
@@ -73,6 +84,7 @@ export async function POST(request: NextRequest) {
workingDirectory: session.working_directory || undefined,
abortController,
permissionMode,
files: fileAttachments,
});
// Tee the stream: one for client, one for collecting the response
+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>
);
+82 -2
View File
@@ -15,11 +15,14 @@ import type {
NotificationHookInput,
PostToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment } from '@/types';
import { isImageFile } from '@/types';
import { registerPendingPermission } from './permission-registry';
import { getSetting, getActiveProvider } from './db';
import { findClaudeBinary, findGitBash, getExpandedPath } from './platform';
import os from 'os';
import fs from 'fs';
import path from 'path';
let cachedClaudePath: string | null | undefined;
@@ -131,6 +134,29 @@ function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null {
* Stream Claude responses using the Agent SDK.
* Returns a ReadableStream of SSE-formatted strings.
*/
/**
* Save non-image file attachments to a temporary upload directory
* and return the file paths. The files are placed in .codepilot-uploads/
* under the working directory so Claude's Read tool can access them.
*/
function saveUploadedFiles(files: FileAttachment[], workDir: string): string[] {
const uploadDir = path.join(workDir, '.codepilot-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savedPaths: string[] = [];
for (const file of files) {
// Sanitize filename to prevent directory traversal
const safeName = path.basename(file.name).replace(/[^a-zA-Z0-9._-]/g, '_');
const timestamp = Date.now();
const filePath = path.join(uploadDir, `${timestamp}-${safeName}`);
const buffer = Buffer.from(file.data, 'base64');
fs.writeFileSync(filePath, buffer);
savedPaths.push(filePath);
}
return savedPaths;
}
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
const {
prompt,
@@ -141,6 +167,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
mcpServers,
abortController,
permissionMode,
files,
} = options;
return new ReadableStream<string>({
@@ -339,8 +366,61 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
};
// Build the prompt: if there are file attachments, construct multimodal content
let finalPrompt: string | { content: unknown[] } = prompt;
if (files && files.length > 0) {
// Allowed image types for Claude multimodal: JPEG, PNG, GIF, WebP; max 5MB each
const allowedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const maxImageSize = 5 * 1024 * 1024;
const imageFiles = files.filter(f =>
isImageFile(f.type) && allowedImageTypes.includes(f.type) && f.size <= maxImageSize
);
const nonImageFiles = files.filter(f => !isImageFile(f.type));
// Save non-image files to disk so Claude's Read tool can access them
let fileReferences = '';
if (nonImageFiles.length > 0) {
const workDir = workingDirectory || process.cwd();
const savedPaths = saveUploadedFiles(nonImageFiles, workDir);
fileReferences = savedPaths
.map((p, i) => `[User attached file: ${p} (${nonImageFiles[i].name})]`)
.join('\n') + '\n\n';
}
// For image files, construct multimodal content blocks
if (imageFiles.length > 0) {
const contentBlocks: unknown[] = [];
// Add image content blocks
for (const img of imageFiles) {
// Supported: image/jpeg, image/png, image/gif, image/webp
const mediaType = img.type as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: img.data,
},
});
}
// Add text block with optional file references prepended
contentBlocks.push({
type: 'text',
text: fileReferences + prompt,
});
finalPrompt = { content: contentBlocks };
} else {
// No images, just prepend file references to the text prompt
finalPrompt = fileReferences + prompt;
}
}
const conversation = query({
prompt,
prompt: finalPrompt as string,
options: queryOptions,
});
+25
View File
@@ -392,6 +392,30 @@ export const SETTING_KEYS = {
MAX_THINKING_TOKENS: 'max_thinking_tokens',
} as const;
// ==========================================
// File Attachment Types
// ==========================================
export interface FileAttachment {
id: string;
name: string;
type: string; // MIME type
size: number;
data: string; // base64 encoded content
}
// Check if a MIME type is an image
export function isImageFile(type: string): boolean {
return type.startsWith('image/');
}
// Format bytes into human-readable size
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ==========================================
// Claude Client Types
// ==========================================
@@ -406,4 +430,5 @@ export interface ClaudeStreamOptions {
mcpServers?: Record<string, MCPServerConfig>;
abortController?: AbortController;
permissionMode?: string;
files?: FileAttachment[];
}