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