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 ( + + + Image preview +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {current.alt} + + {images.length > 1 && ( + <> + + +
+ {currentIndex + 1} / {images.length} +
+ + )} +
+
+
+ ); +} 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.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[]; }