From 7feeebc2aedaaadc278dd28ba021ca8645ed47a1 Mon Sep 17 00:00:00 2001 From: lvalics Date: Sat, 12 Jul 2025 22:30:55 +0300 Subject: [PATCH] feat: Add image upload functionality with drag & drop, clipboard paste, and file picker (#46) - Add drag & drop support for images with visual feedback - Implement clipboard paste for images (Ctrl+V/Cmd+V) - Add image upload button in chat interface - Support multiple formats: PNG, JPG, JPEG, GIF, WebP, SVG - Max 5 images per message, 5MB per image - Add image preview with remove functionality - Display images in chat message bubbles Technical implementation: - Frontend: Add react-dropzone for drag & drop - Frontend: Create ImageAttachment component for previews - Backend: Add multer for file upload handling - Backend: Create /api/projects/:projectName/upload-images endpoint - Backend: Convert images to base64 and save to temp files - Claude CLI: Pass image paths as arguments - Add automatic cleanup of temporary files - Fix JWT auth to use correct token name - Fix UI overlap with proper padding adjustments - Remove non-essential console.logs for production Security: - Validate file types and sizes - Sanitize filenames - User-specific temp directories - Proper JWT authentication Infrastructure: - Add .tmp/ to .gitignore - Create comprehensive CHANGELOG.md - Update package.json with new dependencies Co-authored-by: viper151 --- .gitignore | 1 + package.json | 2 + server/claude-cli.js | 82 +++++++++++- server/index.js | 84 ++++++++++++ src/components/ChatInterface.jsx | 214 ++++++++++++++++++++++++++++++- 5 files changed, 371 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index dd12aab..bb65f13 100755 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,7 @@ jspm_packages/ # Temporary folders tmp/ temp/ +.tmp/ # Vite .vite/ diff --git a/package.json b/package.json index 827902c..35a9afc 100755 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "jsonwebtoken": "^9.0.2", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", + "multer": "^2.0.1", "node-fetch": "^2.7.0", "node-pty": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.8.1", "tailwind-merge": "^3.3.1", diff --git a/server/claude-cli.js b/server/claude-cli.js index 4199d08..260957c 100755 --- a/server/claude-cli.js +++ b/server/claude-cli.js @@ -1,10 +1,13 @@ import { spawn } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; let activeClaudeProcesses = new Map(); // Track active processes by session ID async function spawnClaude(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event @@ -14,7 +17,7 @@ async function spawnClaude(command, options = {}, ws) { disallowedTools: [], skipPermissions: false }; - + // Build Claude CLI command - start with print/resume flags first const args = []; @@ -23,6 +26,56 @@ async function spawnClaude(command, options = {}, ws) { args.push('--print', command); } + // Use cwd (actual project directory) instead of projectPath (Claude's metadata directory) + const workingDir = cwd || process.cwd(); + + // Handle images by saving them to temporary files and passing paths to Claude + const tempImagePaths = []; + let tempDir = null; + if (images && images.length > 0) { + try { + // Create temp directory in the project directory so Claude can access it + tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); + await fs.mkdir(tempDir, { recursive: true }); + + // Save each image to a temp file + for (const [index, image] of images.entries()) { + // Extract base64 data and mime type + const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + console.error('Invalid image data format'); + continue; + } + + const [, mimeType, base64Data] = matches; + const extension = mimeType.split('/')[1] || 'png'; + const filename = `image_${index}.${extension}`; + const filepath = path.join(tempDir, filename); + + // Write base64 data to file + await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); + tempImagePaths.push(filepath); + } + + // Include the full image paths in the prompt for Claude to reference + // Only modify the command if we actually have images and a command + if (tempImagePaths.length > 0 && command && command.trim()) { + const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; + const modifiedCommand = command + imageNote; + + // Update the command in args + const printIndex = args.indexOf('--print'); + if (printIndex !== -1 && args[printIndex + 1] === command) { + args[printIndex + 1] = modifiedCommand; + } + } + + + } catch (error) { + console.error('Error processing images for Claude:', error); + } + } + // Add resume flag if resuming if (resume && sessionId) { args.push('--resume', sessionId); @@ -87,15 +140,14 @@ async function spawnClaude(command, options = {}, ws) { } } - // Use cwd (actual project directory) instead of projectPath (Claude's metadata directory) - const workingDir = cwd || process.cwd(); console.log('Spawning Claude CLI:', 'claude', args.map(arg => { const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg; }).join(' ')); console.log('Working directory:', workingDir); console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); - console.log('🔍 Full command args:', args); + console.log('🔍 Full command args:', JSON.stringify(args, null, 2)); + console.log('🔍 Final Claude command will be: claude ' + args.join(' ')); const claudeProcess = spawn('claude', args, { cwd: workingDir, @@ -103,6 +155,10 @@ async function spawnClaude(command, options = {}, ws) { env: { ...process.env } // Inherit all environment variables }); + // Attach temp file info to process for cleanup later + claudeProcess.tempImagePaths = tempImagePaths; + claudeProcess.tempDir = tempDir; + // Store process reference for potential abort const processKey = capturedSessionId || sessionId || Date.now().toString(); activeClaudeProcesses.set(processKey, claudeProcess); @@ -166,7 +222,7 @@ async function spawnClaude(command, options = {}, ws) { }); // Handle process completion - claudeProcess.on('close', (code) => { + claudeProcess.on('close', async (code) => { console.log(`Claude CLI process exited with code ${code}`); // Clean up process reference @@ -179,6 +235,20 @@ async function spawnClaude(command, options = {}, ws) { isNewSession: !sessionId && !!command // Flag to indicate this was a new session })); + // Clean up temporary image files if any + if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) { + for (const imagePath of claudeProcess.tempImagePaths) { + await fs.unlink(imagePath).catch(err => + console.error(`Failed to delete temp image ${imagePath}:`, err) + ); + } + if (claudeProcess.tempDir) { + await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err => + console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err) + ); + } + } + if (code === 0) { resolve(); } else { diff --git a/server/index.js b/server/index.js index 650e216..c071867 100755 --- a/server/index.js +++ b/server/index.js @@ -814,6 +814,90 @@ Agent instructions:`; } }); +// Image upload endpoint +app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => { + try { + const multer = (await import('multer')).default; + const path = (await import('path')).default; + const fs = (await import('fs')).promises; + const os = (await import('os')).default; + + // Configure multer for image uploads + const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id)); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, uniqueSuffix + '-' + sanitizedName); + } + }); + + const fileFilter = (req, file, cb) => { + const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); + } + }; + + const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB + files: 5 + } + }); + + // Handle multipart form data + upload.array('images', 5)(req, res, async (err) => { + if (err) { + return res.status(400).json({ error: err.message }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No image files provided' }); + } + + try { + // Process uploaded images + const processedImages = await Promise.all( + req.files.map(async (file) => { + // Read file and convert to base64 + const buffer = await fs.readFile(file.path); + const base64 = buffer.toString('base64'); + const mimeType = file.mimetype; + + // Clean up temp file immediately + await fs.unlink(file.path); + + return { + name: file.originalname, + data: `data:${mimeType};base64,${base64}`, + size: file.size, + mimeType: mimeType + }; + }) + ); + + res.json({ images: processedImages }); + } catch (error) { + console.error('Error processing images:', error); + // Clean up any remaining files + await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => {}))); + res.status(500).json({ error: 'Failed to process images' }); + } + }); + } catch (error) { + console.error('Error in image upload endpoint:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); // Serve React app for all other routes app.get('*', (req, res) => { diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index b0caae7..398b53a 100755 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -18,6 +18,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import ReactMarkdown from 'react-markdown'; +import { useDropzone } from 'react-dropzone'; import TodoList from './TodoList'; import ClaudeLogo from './ClaudeLogo.jsx'; @@ -72,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{message.content}
+ {message.images && message.images.length > 0 && ( +
+ {message.images.map((img, idx) => ( + {img.name} window.open(img.data, '_blank')} + /> + ))} +
+ )}
{new Date(message.timestamp).toLocaleTimeString()}
@@ -875,6 +889,43 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ); }); +// ImageAttachment component for displaying image previews +const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { + const [preview, setPreview] = useState(null); + + useEffect(() => { + const url = URL.createObjectURL(file); + setPreview(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + return ( +
+ {file.name} + {uploadProgress !== undefined && uploadProgress < 100 && ( +
+
{uploadProgress}%
+
+ )} + {error && ( +
+ + + +
+ )} + +
+ ); +}; + // ChatInterface: Main chat component with Session Protection System integration // // Session Protection System prevents automatic project updates from interrupting active conversations: @@ -904,6 +955,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [permissionMode, setPermissionMode] = useState('default'); + const [attachedImages, setAttachedImages] = useState([]); + const [uploadingImages, setUploadingImages] = useState(new Map()); + const [imageErrors, setImageErrors] = useState(new Map()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); @@ -1380,6 +1434,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setCurrentSessionId(pendingSessionId); sessionStorage.removeItem('pendingSessionId'); } + + // Clear persisted chat messages after successful completion + if (selectedProject && latestMessage.exitCode === 0) { + localStorage.removeItem(`chat_messages_${selectedProject.name}`); + } break; case 'session-aborted': @@ -1628,13 +1687,105 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, []); - const handleSubmit = (e) => { + // Handle image files from drag & drop or file picker + const handleImageFiles = useCallback((files) => { + const validFiles = files.filter(file => { + if (!file.type.startsWith('image/')) { + return false; + } + if (file.size > 5 * 1024 * 1024) { + setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)')); + return false; + } + return true; + }); + + if (validFiles.length > 0) { + setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images + } + }, []); + + // Handle clipboard paste for images + const handlePaste = useCallback(async (e) => { + const items = Array.from(e.clipboardData.items); + + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + handleImageFiles([file]); + } + } + } + + // Fallback for some browsers/platforms + if (items.length === 0 && e.clipboardData.files.length > 0) { + const files = Array.from(e.clipboardData.files); + const imageFiles = files.filter(f => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + handleImageFiles(imageFiles); + } + } + }, [handleImageFiles]); + + // Setup dropzone + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + accept: { + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] + }, + maxSize: 5 * 1024 * 1024, // 5MB + maxFiles: 5, + onDrop: handleImageFiles, + noClick: true, // We'll use our own button + noKeyboard: true + }); + + const handleSubmit = async (e) => { e.preventDefault(); if (!input.trim() || isLoading || !selectedProject) return; + // Upload images first if any + let uploadedImages = []; + if (attachedImages.length > 0) { + const formData = new FormData(); + attachedImages.forEach(file => { + formData.append('images', file); + }); + + try { + const token = localStorage.getItem('auth-token'); + const headers = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, { + method: 'POST', + headers: headers, + body: formData + }); + + if (!response.ok) { + throw new Error('Failed to upload images'); + } + + const result = await response.json(); + uploadedImages = result.images; + } catch (error) { + console.error('Image upload failed:', error); + setChatMessages(prev => [...prev, { + type: 'error', + content: `Failed to upload images: ${error.message}`, + timestamp: new Date() + }]); + return; + } + } + const userMessage = { type: 'user', content: input, + images: uploadedImages, timestamp: new Date() }; @@ -1681,7 +1832,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const toolsSettings = getToolsSettings(); - // Send command to Claude CLI via WebSocket + // Send command to Claude CLI via WebSocket with images sendMessage({ type: 'claude-command', command: input, @@ -1691,14 +1842,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess sessionId: currentSessionId, resume: !!currentSessionId, toolsSettings: toolsSettings, - permissionMode: permissionMode + permissionMode: permissionMode, + images: uploadedImages // Pass images to backend } }); setInput(''); + setAttachedImages([]); + setUploadingImages(new Map()); + setImageErrors(new Map()); setIsTextareaExpanded(false); - // Reset textarea height to minimal state + // Reset textarea height + + if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } @@ -1992,13 +2149,46 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
-
+ {/* Drag overlay */} + {isDragActive && ( +
+
+ + + +

Drop images here

+
+
+ )} + + {/* Image attachments preview */} + {attachedImages.length > 0 && ( +
+
+ {attachedImages.map((file, index) => ( + { + setAttachedImages(prev => prev.filter((_, i) => i !== index)); + }} + uploadProgress={uploadingImages.get(file.name)} + error={imageErrors.get(file.name)} + /> + ))} +
+
+ )} + +
+