diff --git a/src/routes/filesystem.routes.ts b/src/routes/filesystem.routes.ts index ac2196ad..de81adb9 100644 --- a/src/routes/filesystem.routes.ts +++ b/src/routes/filesystem.routes.ts @@ -1,4 +1,9 @@ import { Router, Request } from 'express'; +import multer from 'multer'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; import { CUIError, FileSystemListQuery, @@ -10,6 +15,22 @@ import { RequestWithRequestId } from '@/types/express.js'; import { FileSystemService } from '@/services/file-system-service.js'; import { createLogger } from '@/services/logger.js'; +// Configure multer for image uploads +const imageUpload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (req, file, cb) => { + // Accept image files + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed')); + } + } +}); + export function createFileSystemRoutes( fileSystemService: FileSystemService ): Router { @@ -104,5 +125,66 @@ export function createFileSystemRoutes( } }); + // Upload temporary image + router.post('/upload-temp-image', imageUpload.single('image'), async (req: RequestWithRequestId, res, next) => { + const requestId = req.requestId; + logger.debug('Upload temp image request', { requestId }); + + try { + if (!req.file) { + throw new CUIError('NO_FILE', 'No image file provided', 400); + } + + // Get file extension from mimetype + const getExtension = (mimeType: string): string => { + const extensions: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/svg+xml': 'svg' + }; + return extensions[mimeType] || 'png'; + }; + + // Create temp images directory in .cui folder + const tempImagesDir = path.join(os.homedir(), '.cui', 'tempimages'); + if (!fs.existsSync(tempImagesDir)) { + fs.mkdirSync(tempImagesDir, { recursive: true }); + } + + // Generate unique filename + const timestamp = Date.now(); + const randomString = crypto.randomBytes(8).toString('hex'); + const extension = getExtension(req.file.mimetype); + const filename = `image_${timestamp}_${randomString}.${extension}`; + const filePath = path.join(tempImagesDir, filename); + + // Write file to disk + fs.writeFileSync(filePath, req.file.buffer); + + logger.debug('Temp image saved successfully', { + requestId, + filePath, + size: req.file.size, + mimeType: req.file.mimetype + }); + + res.json({ + success: true, + filePath: filePath, + filename: filename + }); + } catch (error) { + logger.debug('Upload temp image failed', { + requestId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + return router; } \ No newline at end of file diff --git a/src/web/chat/components/Composer/Composer.tsx b/src/web/chat/components/Composer/Composer.tsx index 7e302b10..3595c4a5 100644 --- a/src/web/chat/components/Composer/Composer.tsx +++ b/src/web/chat/components/Composer/Composer.tsx @@ -668,6 +668,67 @@ export const Composer = forwardRef(function Composer resetAutocomplete(); }; + const handlePaste = async (e: React.ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + // Check if clipboard contains image data + const imageItems = Array.from(clipboardData.items).filter(item => item.type.startsWith('image/')); + + if (imageItems.length === 0) { + // No images, let default paste behavior handle text + return; + } + + // Prevent default paste behavior for images + e.preventDefault(); + + try { + // Process the first image found + const imageItem = imageItems[0]; + const imageFile = imageItem.getAsFile(); + + if (!imageFile) { + console.warn('Could not get image file from clipboard'); + return; + } + + // Upload the image to temp storage + const uploadResult = await api.uploadTempImage(imageFile); + + if (uploadResult.success && uploadResult.filePath) { + // Insert the file path at the current cursor position + const textarea = textareaRef.current; + if (textarea) { + const cursorPos = textarea.selectionStart; + const textBefore = value.substring(0, cursorPos); + const textAfter = value.substring(cursorPos); + + // Add space before if needed + const needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(' ') && !textBefore.endsWith('\n'); + const imageReference = `@${uploadResult.filePath}`; + const finalText = (needsSpaceBefore ? ' ' : '') + imageReference; + + const newText = textBefore + finalText + textAfter; + setValue(newText); + + // Set cursor position after inserted text + setTimeout(() => { + if (textareaRef.current) { + const newCursorPos = cursorPos + finalText.length; + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + textareaRef.current.focus(); + adjustTextareaHeight(); + } + }, 0); + } + } + } catch (error) { + console.error('Failed to upload pasted image:', error); + // Could show a toast notification here + } + }; + const handleSubmit = (permissionMode: string) => { const trimmedValue = value.trim(); if (!trimmedValue || isLoading) return; @@ -870,6 +931,7 @@ export const Composer = forwardRef(function Composer value={value} onChange={handleTextChange} onKeyDown={handleKeyDown} + onPaste={handlePaste} rows={1} disabled={(isLoading || disabled) && !(permissionRequest && showPermissionUI)} /> diff --git a/src/web/chat/services/api.ts b/src/web/chat/services/api.ts index b161cba6..664a1913 100644 --- a/src/web/chat/services/api.ts +++ b/src/web/chat/services/api.ts @@ -201,6 +201,49 @@ class ApiService { }); } + async uploadTempImage(imageFile: File): Promise<{ success: boolean; filePath: string; filename: string }> { + const formData = new FormData(); + formData.append('image', imageFile); + + const fullUrl = `${this.baseUrl}/api/filesystem/upload-temp-image`; + + // Log request + console.log(`[API] POST ${fullUrl}`, { fileName: imageFile.name, fileSize: imageFile.size }); + + // Get auth token for Bearer authorization + const authToken = getAuthToken(); + const headers = new Headers(); + + // Add Bearer token if available + if (authToken) { + headers.set('Authorization', `Bearer ${authToken}`); + } + + try { + const response = await fetch(fullUrl, { + method: 'POST', + headers, + body: formData, + }); + + const data = await response.json(); + + // Log response + console.log(`[API Response] ${fullUrl}:`, data); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized'); + } + throw new Error((data as ApiError).error || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + throw error; + } + } + async getGeminiHealth(): Promise { return this.apiCall('/api/gemini/health'); }