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 <simosmik@gmail.com>
This commit is contained in:
lvalics
2025-07-12 22:30:55 +03:00
committed by GitHub
parent ad0bcba117
commit 7feeebc2ae
5 changed files with 371 additions and 12 deletions

View File

@@ -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 {