diff --git a/CLAUDE.md b/CLAUDE.md
index 70c5c02..ba8e6cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 114d269..abea496 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -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
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx
index a1b54da..80c5c1d 100644
--- a/src/components/chat/ChatView.tsx
+++ b/src/components/chat/ChatView.tsx
@@ -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 = `${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,
});
diff --git a/src/components/chat/FileAttachmentDisplay.tsx b/src/components/chat/FileAttachmentDisplay.tsx
new file mode 100644
index 0000000..86c8131
--- /dev/null
+++ b/src/components/chat/FileAttachmentDisplay.tsx
@@ -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 (
+
+ {imageFiles.length > 0 && (
+
+ {imageFiles.map((file, i) => (
+ handlePreview(i)}
+ />
+ ))}
+
+ )}
+
+ {otherFiles.length > 0 && (
+
+ {otherFiles.map((file) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/chat/FileCard.tsx b/src/components/chat/FileCard.tsx
new file mode 100644
index 0000000..c3ba87d
--- /dev/null
+++ b/src/components/chat/FileCard.tsx
@@ -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 (
+
+
+
+
{name}
+
{formatFileSize(size)}
+
+
+ );
+}
diff --git a/src/components/chat/ImageLightbox.tsx b/src/components/chat/ImageLightbox.tsx
new file mode 100644
index 0000000..50153ec
--- /dev/null
+++ b/src/components/chat/ImageLightbox.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/chat/ImageThumbnail.tsx b/src/components/chat/ImageThumbnail.tsx
new file mode 100644
index 0000000..b587199
--- /dev/null
+++ b/src/components/chat/ImageThumbnail.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+interface ImageThumbnailProps {
+ src: string;
+ alt: string;
+ onClick: () => void;
+}
+
+export function ImageThumbnail({ src, alt, onClick }: ImageThumbnailProps) {
+ return (
+
+ );
+}
diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx
index ff97015..b81abc2 100644
--- a/src/components/chat/MessageInput.tsx
+++ b/src/components/chat/MessageInput.tsx
@@ -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> = {
},
};
+/**
+ * Convert a data URL to a FileAttachment object.
+ */
+async function dataUrlToFileAttachment(
+ dataUrl: string,
+ filename: string,
+ mediaType: string,
+): Promise {
+ // data:image/png;base64, — 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 (
+
+ );
+}
+
+/**
+ * Attachment button that opens the file dialog. Must be rendered inside PromptInput.
+ */
+function AttachFileButton() {
+ const attachments = usePromptInputAttachments();
+
+ return (
+ attachments.openFileDialog()}
+ tooltip="Attach files"
+ >
+
+
+ );
+}
+
+/**
+ * Capsule display for attached files, rendered inside PromptInput context.
+ */
+function FileAttachmentsCapsules() {
+ const attachments = usePromptInputAttachments();
+
+ if (attachments.files.length === 0) return null;
+
+ return (
+
+ {attachments.files.map((file) => {
+ const isImage = file.mediaType?.startsWith('image/');
+ return (
+
+ {isImage && file.url && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {file.filename || 'file'}
+
+
+
+ );
+ })}
+
+ );
+}
+
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) => {
+ const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => {
e.preventDefault();
const content = inputValue.trim();
closePopover();
+ // Convert PromptInput FileUIParts (with data URLs) to FileAttachment[]
+ const convertFiles = async (): Promise => {
+ 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 */}
{/* Command badge */}
{badge && (
@@ -596,6 +757,8 @@ export function MessageInput({
)}
+ {/* File attachment capsules */}
+
+ {/* Attach file button */}
+
+
{/* Mode selector */}
diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx
index ebdb9c7..c2121fd 100644
--- a/src/components/chat/MessageItem.tsx
+++ b/src/components/chat/MessageItem.tsx
@@ -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(/^\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 (
+ {/* File attachments for user messages */}
+ {isUser && files.length > 0 && (
+
+ )}
+
{/* Tool calls for assistant messages */}
{!isUser && pairedTools.length > 0 && (
@@ -204,11 +229,11 @@ export function MessageItem({ message }: MessageItemProps) {
)}
{/* Text content */}
- {text && (
+ {displayText && (
isUser ? (
-
{text}
+
{displayText}
) : (
-
{text}
+
{displayText}
)
)}
@@ -217,7 +242,7 @@ export function MessageItem({ message }: MessageItemProps) {
{!isUser && {timestamp}}
{!isUser && tokenUsage && }
- {text && }
+ {displayText && }
);
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts
index a081f42..2a2e008 100644
--- a/src/lib/claude-client.ts
+++ b/src/lib/claude-client.ts
@@ -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
{
const {
prompt,
@@ -141,6 +167,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({
@@ -339,8 +366,61 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream 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,
});
diff --git a/src/types/index.ts b/src/types/index.ts
index 28f1f38..eff9823 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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;
abortController?: AbortController;
permissionMode?: string;
+ files?: FileAttachment[];
}