mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 10:29:39 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -90,6 +90,7 @@ jspm_packages/
|
|||||||
# Temporary folders
|
# Temporary folders
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
.vite/
|
.vite/
|
||||||
|
|||||||
@@ -44,10 +44,12 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
|
"multer": "^2.0.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { spawn } from 'child_process';
|
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
|
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
async function spawnClaude(command, options = {}, ws) {
|
async function spawnClaude(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
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 capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
|
|
||||||
@@ -23,6 +26,56 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
args.push('--print', command);
|
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
|
// Add resume flag if resuming
|
||||||
if (resume && sessionId) {
|
if (resume && sessionId) {
|
||||||
args.push('--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 => {
|
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||||
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
||||||
}).join(' '));
|
}).join(' '));
|
||||||
console.log('Working directory:', workingDir);
|
console.log('Working directory:', workingDir);
|
||||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
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, {
|
const claudeProcess = spawn('claude', args, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
@@ -103,6 +155,10 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
env: { ...process.env } // Inherit all environment variables
|
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
|
// Store process reference for potential abort
|
||||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||||
activeClaudeProcesses.set(processKey, claudeProcess);
|
activeClaudeProcesses.set(processKey, claudeProcess);
|
||||||
@@ -166,7 +222,7 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle process completion
|
// Handle process completion
|
||||||
claudeProcess.on('close', (code) => {
|
claudeProcess.on('close', async (code) => {
|
||||||
console.log(`Claude CLI process exited with code ${code}`);
|
console.log(`Claude CLI process exited with code ${code}`);
|
||||||
|
|
||||||
// Clean up process reference
|
// 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
|
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) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
// Serve React app for all other routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||||
|
|
||||||
@@ -72,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
<div className="text-sm whitespace-pre-wrap break-words">
|
<div className="text-sm whitespace-pre-wrap break-words">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
|
{message.images && message.images.length > 0 && (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{message.images.map((img, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={img.data}
|
||||||
|
alt={img.name}
|
||||||
|
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => window.open(img.data, '_blank')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-xs text-blue-100 mt-1 text-right">
|
<div className="text-xs text-blue-100 mt-1 text-right">
|
||||||
{new Date(message.timestamp).toLocaleTimeString()}
|
{new Date(message.timestamp).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div className="relative group">
|
||||||
|
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
||||||
|
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<div className="text-white text-xs">{uploadProgress}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ChatInterface: Main chat component with Session Protection System integration
|
// ChatInterface: Main chat component with Session Protection System integration
|
||||||
//
|
//
|
||||||
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
// 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 [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||||
const [permissionMode, setPermissionMode] = useState('default');
|
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 messagesEndRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
@@ -1380,6 +1434,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
setCurrentSessionId(pendingSessionId);
|
setCurrentSessionId(pendingSessionId);
|
||||||
sessionStorage.removeItem('pendingSessionId');
|
sessionStorage.removeItem('pendingSessionId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear persisted chat messages after successful completion
|
||||||
|
if (selectedProject && latestMessage.exitCode === 0) {
|
||||||
|
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session-aborted':
|
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();
|
e.preventDefault();
|
||||||
if (!input.trim() || isLoading || !selectedProject) return;
|
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 = {
|
const userMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: input,
|
content: input,
|
||||||
|
images: uploadedImages,
|
||||||
timestamp: new Date()
|
timestamp: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1681,7 +1832,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
|
|
||||||
// Send command to Claude CLI via WebSocket
|
// Send command to Claude CLI via WebSocket with images
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'claude-command',
|
type: 'claude-command',
|
||||||
command: input,
|
command: input,
|
||||||
@@ -1691,14 +1842,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
resume: !!currentSessionId,
|
resume: !!currentSessionId,
|
||||||
toolsSettings: toolsSettings,
|
toolsSettings: toolsSettings,
|
||||||
permissionMode: permissionMode
|
permissionMode: permissionMode,
|
||||||
|
images: uploadedImages // Pass images to backend
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setAttachedImages([]);
|
||||||
|
setUploadingImages(new Map());
|
||||||
|
setImageErrors(new Map());
|
||||||
setIsTextareaExpanded(false);
|
setIsTextareaExpanded(false);
|
||||||
|
|
||||||
// Reset textarea height to minimal state
|
// Reset textarea height
|
||||||
|
|
||||||
|
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
@@ -1992,13 +2149,46 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
||||||
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
{/* Drag overlay */}
|
||||||
|
{isDragActive && (
|
||||||
|
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
||||||
|
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium">Drop images here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image attachments preview */}
|
||||||
|
{attachedImages.length > 0 && (
|
||||||
|
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachedImages.map((file, index) => (
|
||||||
|
<ImageAttachment
|
||||||
|
key={index}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => {
|
||||||
|
setAttachedImages(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
uploadProgress={uploadingImages.get(file.name)}
|
||||||
|
error={imageErrors.get(file.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onClick={handleTextareaClick}
|
onClick={handleTextareaClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@@ -2015,7 +2205,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="chat-input-placeholder w-full px-4 sm:px-6 py-3 sm:py-4 pr-28 sm:pr-40 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
{/* Clear button - shown when there's text */}
|
{/* Clear button - shown when there's text */}
|
||||||
@@ -2060,6 +2250,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Image upload button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
className="absolute left-2 bottom-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Attach images"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Mic button - HIDDEN */}
|
{/* Mic button - HIDDEN */}
|
||||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||||
<MicButton
|
<MicButton
|
||||||
|
|||||||
Reference in New Issue
Block a user