Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/routes/filesystem.routes.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, string> = {
'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;
}
62 changes: 62 additions & 0 deletions src/web/chat/components/Composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,67 @@ export const Composer = forwardRef<ComposerRef, ComposerProps>(function Composer
resetAutocomplete();
};

const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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;
Expand Down Expand Up @@ -870,6 +931,7 @@ export const Composer = forwardRef<ComposerRef, ComposerProps>(function Composer
value={value}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
rows={1}
disabled={(isLoading || disabled) && !(permissionRequest && showPermissionUI)}
/>
Expand Down
43 changes: 43 additions & 0 deletions src/web/chat/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeminiHealthResponse> {
return this.apiCall<GeminiHealthResponse>('/api/gemini/health');
}
Expand Down