feat: support attached images for all providers

This commit is contained in:
Haileyesus
2026-07-03 15:42:29 +03:00
parent 3ade1a1105
commit a253a2bda4
33 changed files with 1467 additions and 321 deletions

View File

@@ -161,6 +161,7 @@ export default tseslint.config(
"server/shared/utils.{js,ts}",
"server/shared/frontmatter.ts",
"server/shared/claude-cli-path.ts",
"server/shared/image-attachments.ts",
], // classify shared utility files so modules can depend on them explicitly
mode: "file",
},

19
package-lock.json generated
View File

@@ -80,6 +80,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/mime-types": "^3.0.1",
"@types/multer": "^2.2.0",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
@@ -5994,12 +5996,29 @@
"@types/unist": "*"
}
},
"node_modules/@types/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",

View File

@@ -203,6 +203,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/mime-types": "^3.0.1",
"@types/multer": "^2.2.0",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

@@ -19,6 +19,7 @@ import path from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { buildClaudeUserContent, normalizeImageDescriptors } from './shared/image-attachments.js';
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
@@ -236,16 +237,13 @@ function mapCliOptionsToSDK(options = {}) {
* Adds a session to the active sessions map
* @param {string} sessionId - Session identifier
* @param {Object} queryInstance - SDK query instance
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
* @param {string} tempDir - Temp directory for cleanup
* @param {Object} writer - WebSocket writer for reconnect support
*/
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
function addSession(sessionId, queryInstance, writer = null) {
activeSessions.set(sessionId, {
instance: queryInstance,
startTime: Date.now(),
status: 'active',
tempImagePaths,
tempDir,
writer
});
}
@@ -364,90 +362,35 @@ function extractTokenBudget(sdkMessage) {
}
/**
* Handles image processing for SDK queries
* Saves base64 images to temporary files and returns modified prompt with file paths
* @param {string} command - Original user prompt
* @param {Array} images - Array of image objects with base64 data
* @param {string} cwd - Working directory for temp file creation
* @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
* Builds the SDK `prompt` payload for one turn.
*
* Plain text turns pass the string through unchanged. Turns with image
* attachments use the SDK's streaming-input mode: a single SDKUserMessage
* whose content carries the prompt text plus one base64 `image` block per
* attachment (read from the global `~/.cloudcli/assets` folder).
*
* @param {string} command - User prompt
* @param {Array} images - Image descriptors ({ path, name?, mimeType? })
* @param {string} cwd - Project working directory image paths resolve against
* @returns {Promise<string|AsyncIterable>} SDK prompt payload
*/
async function handleImages(command, images, cwd) {
const tempImagePaths = [];
let tempDir = null;
if (!images || images.length === 0) {
return { modifiedCommand: command, tempImagePaths, tempDir };
async function buildPromptPayload(command, images, cwd) {
if (normalizeImageDescriptors(images).length === 0) {
return command;
}
try {
// Create temp directory in the project directory
const workingDir = cwd || process.cwd();
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
let modifiedCommand = 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')}`;
modifiedCommand = command + imageNote;
}
// Images processed
return { modifiedCommand, tempImagePaths, tempDir };
} catch (error) {
console.error('Error processing images for SDK:', error);
return { modifiedCommand: command, tempImagePaths, tempDir };
}
}
/**
* Cleans up temporary image files
* @param {Array<string>} tempImagePaths - Array of temp file paths to delete
* @param {string} tempDir - Temp directory to remove
*/
async function cleanupTempFiles(tempImagePaths, tempDir) {
if (!tempImagePaths || tempImagePaths.length === 0) {
return;
}
try {
// Delete individual temp files
for (const imagePath of tempImagePaths) {
await fs.unlink(imagePath).catch(err =>
console.error(`Failed to delete temp image ${imagePath}:`, err)
);
}
// Delete temp directory
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
console.error(`Failed to delete temp directory ${tempDir}:`, err)
);
}
// Temp files cleaned
} catch (error) {
console.error('Error during temp file cleanup:', error);
}
const content = await buildClaudeUserContent(command, images, cwd);
return (async function* () {
yield {
type: 'user',
message: {
role: 'user',
content
},
parent_tool_use_id: null,
timestamp: new Date().toISOString()
};
})();
}
/**
@@ -518,8 +461,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
const { sessionId, sessionSummary } = options;
let capturedSessionId = sessionId;
let sessionCreatedSent = false;
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
@@ -553,10 +494,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
sdkOptions.mcpServers = mcpServers;
}
const imageResult = await handleImages(command, options.images, options.cwd);
const finalCommand = imageResult.modifiedCommand;
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
// Turns with image attachments switch to streaming input so the images
// ride along as real content blocks. Built per query attempt because an
// async generator cannot be replayed once consumed.
const createPrompt = () => buildPromptPayload(command, options.images, options.cwd);
sdkOptions.hooks = {
Notification: [{
@@ -663,7 +604,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
let queryInstance;
try {
queryInstance = query({
prompt: finalCommand,
prompt: await createPrompt(),
options: sdkOptions
});
} catch (hookError) {
@@ -672,7 +613,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks;
queryInstance = query({
prompt: finalCommand,
prompt: await createPrompt(),
options: sdkOptions
});
}
@@ -686,7 +627,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Track the query instance for abort capability
if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
addSession(capturedSessionId, queryInstance, ws);
}
// Process streaming messages
@@ -696,7 +637,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.session_id && !capturedSessionId) {
capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
addSession(capturedSessionId, queryInstance, ws);
// Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -738,9 +679,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
removeSession(capturedSessionId);
}
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
@@ -764,9 +702,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
removeSession(capturedSessionId);
}
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
@@ -819,9 +754,6 @@ async function abortClaudeSDKSession(sessionId) {
// Update session status
session.status = 'aborted';
// Clean up temporary image files
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
// Clean up session
removeSession(sessionId);

View File

@@ -1,10 +1,13 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { appendImagesInputTag } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -28,7 +31,7 @@ function isWorkspaceTrustPrompt(text = '') {
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary, images } = options;
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
@@ -55,8 +58,12 @@ async function spawnCursor(command, options = {}, ws) {
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
baseArgs.push('-p', command);
// Provide a prompt (works for both new and resumed sessions). Image
// attachments ride along as an <images_input> path list appended to the
// prompt; the session history reader strips the tag back out for display.
// cursor-agent is a .cmd shim on Windows, so the whole argument must be
// newline-free or cmd.exe silently truncates it at the first newline.
baseArgs.push('-p', flattenPromptForWindowsShell(appendImagesInputTag(command, images)));
// Model overrides are applied to both new and resumed sessions so a
// session-scoped change request can take effect on the next turn.
@@ -71,7 +78,6 @@ async function spawnCursor(command, options = {}, ws) {
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
@@ -126,10 +132,6 @@ async function spawnCursor(command, options = {}, ws) {
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
}
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],

View File

@@ -7,10 +7,11 @@ import crossSpawn from 'cross-spawn';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { appendImagesInputTag } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -143,9 +144,17 @@ async function spawnGemini(command, options = {}, ws) {
// Build Gemini CLI command - start with print/resume flags first
const args = [];
// Image attachments ride along as an <images_input> path list appended to
// the prompt; session history readers strip the tag back out for display.
// gemini is a .cmd shim on Windows, so the whole argument must be
// newline-free or cmd.exe silently truncates it at the first newline.
const finalCommand = command && command.trim()
? flattenPromptForWindowsShell(appendImagesInputTag(command, images))
: command;
// Add prompt flag with command if we have a command
if (command && command.trim()) {
args.push('--prompt', command);
if (finalCommand && finalCommand.trim()) {
args.push('--prompt', finalCommand);
}
// If we have a sessionId, we want to resume
@@ -161,53 +170,6 @@ async function spawnGemini(command, options = {}, ws) {
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
const workingDir = cleanPath;
// Handle images by saving them to temporary files and passing paths to Gemini
const tempImagePaths = [];
let tempDir = null;
if (images && images.length > 0) {
try {
// Create temp directory in the project directory so Gemini 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) {
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 Gemini to reference
// Gemini CLI can read images from file paths in the prompt
if (tempImagePaths.length > 0 && command && command.trim()) {
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
const promptIndex = args.indexOf('--prompt');
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
args[promptIndex + 1] = modifiedCommand;
} else if (promptIndex !== -1) {
// If we're using context, update the full prompt
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
}
}
} catch (error) {
console.error('Error processing images for Gemini:', error);
}
}
// Add basic flags for Gemini
if (options.debug) {
args.push('--debug');
@@ -322,10 +284,6 @@ async function spawnGemini(command, options = {}, ws) {
});
};
// Attach temp file info to process for cleanup later
geminiProcess.tempImagePaths = tempImagePaths;
geminiProcess.tempDir = tempDir;
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeGeminiProcesses.set(processKey, geminiProcess);
@@ -496,16 +454,6 @@ async function spawnGemini(command, options = {}, ws) {
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
for (const imagePath of geminiProcess.tempImagePaths) {
await fs.unlink(imagePath).catch(err => { });
}
if (geminiProcess.tempDir) {
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
}
}
if (code === 0) {
notifyTerminalState({ code });
resolve();

View File

@@ -64,6 +64,7 @@ import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import voiceRoutes from './voice-proxy.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import { assetsRoutes } from './modules/assets/index.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
@@ -185,6 +186,9 @@ app.use('/api/auth', authRoutes);
// Projects API Routes (protected)
app.use('/api/projects', authenticateToken, projectModuleRoutes);
// Chat image asset upload/serving (global ~/.cloudcli/assets store, protected)
app.use('/api/assets', authenticateToken, assetsRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
@@ -1072,92 +1076,8 @@ const uploadFilesHandler = async (req, res) => {
app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler);
// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name)
// but the current implementation doesn't need to touch the project directory,
// so we just leave the param rename for consistency with the rest of the API.
app.post('/api/projects/:projectId/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' });
}
});
// Chat image uploads moved to POST /api/assets/images (server/modules/assets),
// which stores them in the global ~/.cloudcli/assets folder.
// Get token usage for a specific session. `projectId` is the DB primary key;
// the Claude branch below resolves it to an absolute path via the DB.

View File

@@ -0,0 +1,93 @@
import fsSync, { promises as fs } from 'node:fs';
import express from 'express';
import mime from 'mime-types';
import multer from 'multer';
import {
buildStoredImageRecords,
ensureImageAssetsDir,
isAllowedImageMimeType,
resolveImageAssetFile,
} from '@/modules/assets/services/image-assets.service.js';
const router = express.Router();
// Multer writes uploads straight into the global assets folder; the service
// owns the folder location and the response record shape.
const storage = multer.diskStorage({
destination: (req, file, cb) => {
ensureImageAssetsDir()
.then((assetsDir) => cb(null, assetsDir))
.catch((error) => cb(error as Error, ''));
},
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 upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (isAllowedImageMimeType(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
}
},
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5,
},
});
/**
* Stores chat image attachments in the global `~/.cloudcli/assets` folder and
* returns their absolute paths for use in provider prompts and chat history.
*/
router.post('/images', (req, res) => {
upload.array('images', 5)(req, res, (err: unknown) => {
if (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return res.status(400).json({ error: message });
}
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'No image files provided' });
}
res.json({ images: buildStoredImageRecords(files) });
});
});
/**
* Serves one stored image asset by filename. Only files directly inside the
* global assets folder are reachable; traversal attempts resolve to null.
*/
router.get('/images/:filename', async (req, res) => {
const resolved = resolveImageAssetFile(req.params.filename);
if (!resolved) {
return res.status(400).json({ error: 'Invalid asset filename' });
}
try {
await fs.access(resolved);
} catch {
return res.status(404).json({ error: 'Asset not found' });
}
res.setHeader('Content-Type', mime.lookup(resolved) || 'application/octet-stream');
const fileStream = fsSync.createReadStream(resolved);
fileStream.pipe(res);
fileStream.on('error', (error) => {
console.error('Error streaming image asset:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Error reading asset' });
}
});
});
export default router;

View File

@@ -0,0 +1,3 @@
// Express router mounted at /api/assets by server/index.js (upload + serving
// of chat image attachments stored in the global ~/.cloudcli/assets folder).
export { default as assetsRoutes } from './assets.routes.js';

View File

@@ -0,0 +1,82 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { getGlobalImageAssetsDir, toPosixPath } from '@/shared/image-attachments.js';
/**
* Image mime types accepted for chat attachment uploads. SVG is allowed for
* storage/preview even though some providers (Claude API) skip it at send time.
*/
const ALLOWED_IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
// Used only by this service and the assets routes via the barrel file.
type StoredImageAsset = {
/** Original upload filename, for display. */
name: string;
/** Absolute posix-normalized path inside the global assets folder. */
path: string;
size: number;
mimeType: string;
};
// Shape of one multer-stored file; kept local because only this module reads it.
type UploadedImageFile = {
originalname: string;
filename: string;
size: number;
mimetype: string;
};
/** Returns whether one uploaded mime type may be stored as a chat image asset. */
export function isAllowedImageMimeType(mimeType: string): boolean {
return ALLOWED_IMAGE_MIME_TYPES.has(mimeType);
}
/** Creates the global `~/.cloudcli/assets` folder if needed and returns it. */
export async function ensureImageAssetsDir(): Promise<string> {
const assetsDir = getGlobalImageAssetsDir();
await fs.mkdir(assetsDir, { recursive: true });
return assetsDir;
}
/**
* Maps multer-stored upload files to the attachment records returned to the
* chat composer. The absolute path is what providers receive and what session
* history carries back to the UI.
*/
export function buildStoredImageRecords(files: UploadedImageFile[]): StoredImageAsset[] {
const assetsDir = getGlobalImageAssetsDir();
return files.map((file) => ({
name: file.originalname,
path: toPosixPath(path.join(assetsDir, file.filename)),
size: file.size,
mimeType: file.mimetype,
}));
}
/**
* Resolves one asset filename to its absolute path inside the global assets
* folder, or null when the name is empty, contains path separators/traversal,
* or would escape the folder. This is the only lookup the serving route uses,
* so nothing outside `~/.cloudcli/assets` can ever be read through it.
*/
export function resolveImageAssetFile(filename: string): string | null {
const trimmed = typeof filename === 'string' ? filename.trim() : '';
if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
return null;
}
const assetsDir = path.resolve(getGlobalImageAssetsDir());
const resolved = path.resolve(assetsDir, trimmed);
if (!resolved.startsWith(assetsDir + path.sep)) {
return null;
}
return resolved;
}

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildStoredImageRecords,
isAllowedImageMimeType,
resolveImageAssetFile,
} from '@/modules/assets/services/image-assets.service.js';
const ASSETS_DIR = path.join(os.homedir(), '.cloudcli', 'assets');
test('isAllowedImageMimeType accepts image formats and rejects the rest', () => {
assert.equal(isAllowedImageMimeType('image/png'), true);
assert.equal(isAllowedImageMimeType('image/svg+xml'), true);
assert.equal(isAllowedImageMimeType('application/pdf'), false);
assert.equal(isAllowedImageMimeType('text/html'), false);
});
test('buildStoredImageRecords returns absolute posix paths in the assets dir', () => {
const records = buildStoredImageRecords([
{ originalname: 'shot.png', filename: '123-456-shot.png', size: 42, mimetype: 'image/png' },
]);
assert.equal(records.length, 1);
assert.equal(records[0].name, 'shot.png');
assert.equal(records[0].size, 42);
assert.equal(records[0].mimeType, 'image/png');
assert.equal(records[0].path, `${ASSETS_DIR.replace(/\\/g, '/')}/123-456-shot.png`);
});
test('resolveImageAssetFile resolves plain filenames inside the assets dir', () => {
const resolved = resolveImageAssetFile('123-shot.png');
assert.equal(resolved, path.join(path.resolve(ASSETS_DIR), '123-shot.png'));
});
test('resolveImageAssetFile rejects traversal and separator attempts', () => {
assert.equal(resolveImageAssetFile(''), null);
assert.equal(resolveImageAssetFile(' '), null);
assert.equal(resolveImageAssetFile('../auth.db'), null);
assert.equal(resolveImageAssetFile('..'), null);
assert.equal(resolveImageAssetFile('sub/dir.png'), null);
assert.equal(resolveImageAssetFile('sub\\dir.png'), null);
assert.equal(resolveImageAssetFile('a..b/../c.png'), null);
});

View File

@@ -313,6 +313,18 @@ export class ClaudeSessionsProvider implements IProviderSessions {
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
if (Array.isArray(raw.message.content)) {
// Image attachments sent through the SDK are persisted as base64
// `image` blocks next to the prompt text. Collect them so the UI can
// render them on the user bubble.
const imageAttachments: Array<{ data: string }> = [];
for (const part of raw.message.content) {
if (part?.type === 'image' && part.source?.type === 'base64' && typeof part.source.data === 'string') {
const mediaType = typeof part.source.media_type === 'string' ? part.source.media_type : 'image/png';
imageAttachments.push({ data: `data:${mediaType};base64,${part.source.data}` });
}
}
let imagesAttached = false;
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
const part = raw.message.content[partIndex];
if (part.type === 'tool_result') {
@@ -339,7 +351,9 @@ export class ClaudeSessionsProvider implements IProviderSessions {
kind: 'text',
role: 'user',
content: text,
images: !imagesAttached && imageAttachments.length > 0 ? imageAttachments : undefined,
}));
imagesAttached = true;
}
}
}
@@ -359,9 +373,25 @@ export class ClaudeSessionsProvider implements IProviderSessions {
kind: 'text',
role: 'user',
content: textParts,
images: imageAttachments.length > 0 ? imageAttachments : undefined,
}));
imagesAttached = true;
}
}
// Image-only turns still deserve a user bubble even without text.
if (!imagesAttached && imageAttachments.length > 0) {
messages.push(createNormalizedMessage({
id: `${baseId}_images`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: '',
images: imageAttachments,
}));
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;

View File

@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import { toImageAttachments } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
@@ -31,6 +32,42 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole
return typeof payload.message === 'string' && payload.message.trim().length > 0;
}
/**
* Reads the image attachments Codex records on `user_message` events.
* Turns sent with `local_image` input items land in `local_images` as file
* paths (verified against real rollout JSONL); the `images` array can carry
* base64 data URLs, which are passed through as inline `data` attachments so
* the UI can preview them without a file lookup.
*
* Exported for tests.
*/
export function extractCodexUserImages(
payload: AnyRecord | null | undefined,
): Array<{ path?: string; data?: string }> | undefined {
if (!payload) {
return undefined;
}
const candidates = [
...(Array.isArray(payload.local_images) ? payload.local_images : []),
...(Array.isArray(payload.images) ? payload.images : []),
];
const attachments: Array<{ path?: string; data?: string }> = [];
for (const entry of candidates) {
if (typeof entry !== 'string' || !entry.trim()) {
continue;
}
if (entry.startsWith('data:')) {
attachments.push({ data: entry });
} else {
attachments.push(...toImageAttachments([entry]));
}
}
return attachments.length > 0 ? attachments : undefined;
}
function extractCodexTextContent(content: unknown): string {
if (!Array.isArray(content)) {
return typeof content === 'string' ? content : '';
@@ -104,6 +141,7 @@ async function getCodexSessionMessages(
role: 'user',
content: entry.payload.message,
},
images: extractCodexUserImages(entry.payload as AnyRecord),
});
}
@@ -296,7 +334,8 @@ export class CodexSessionsProvider implements IProviderSessions {
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
if (!content.trim() && !rawImages) {
return [];
}
return [createNormalizedMessage({
@@ -307,6 +346,7 @@ export class CodexSessionsProvider implements IProviderSessions {
kind: 'text',
role: 'user',
content,
images: rawImages,
})];
}

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
@@ -24,7 +25,7 @@ type CursorJsonBlob = CursorDbBlob & {
parsed: AnyRecord;
};
type CursorMessageBlob = {
export type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
@@ -76,6 +77,27 @@ function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string
return value.replace(/^\s*<timestamp>[\s\S]*?<\/timestamp>\s*/, '').trim();
}
/**
* Unwraps one user-authored text payload and splits off the `<images_input>`
* attachment block appended by the chat composer. Assistant text passes
* through untouched.
*/
function extractUserTextAndImages(
value: string,
role: 'user' | 'assistant',
): { text: string; images?: Array<{ path: string; name?: string }> } {
const unwrapped = unwrapUserQueryText(value, role);
if (role !== 'user') {
return { text: unwrapped };
}
const { text, attachments } = parseImagesInputTag(unwrapped);
return {
text,
images: attachments.length > 0 ? attachments : undefined,
};
}
function normalizeToolId(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
@@ -405,8 +427,11 @@ export class CursorSessionsProvider implements IProviderSessions {
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*
* Public so tests can drive history normalization with synthetic blobs
* without needing a real Cursor store.db.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
@@ -446,7 +471,16 @@ export class CursorSessionsProvider implements IProviderSessions {
text = unwrapUserQueryText(content.message.content, role);
}
}
if (text?.trim()) {
const { text: cleanText, images } = role === 'user'
? (() => {
const parsed = parseImagesInputTag(text);
return {
text: parsed.text,
images: parsed.attachments.length > 0 ? parsed.attachments : undefined,
};
})()
: { text, images: undefined };
if (cleanText?.trim() || images) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
@@ -454,7 +488,8 @@ export class CursorSessionsProvider implements IProviderSessions {
provider: PROVIDER,
kind: 'text',
role,
content: text,
content: cleanText,
images,
sequence: blob.sequence,
rowid: blob.rowid,
}));
@@ -506,8 +541,8 @@ export class CursorSessionsProvider implements IProviderSessions {
}
if (part?.type === 'text' && part?.text) {
const normalizedPartText = unwrapUserQueryText(part.text, role);
if (!normalizedPartText) {
const { text: normalizedPartText, images } = extractUserTextAndImages(part.text, role);
if (!normalizedPartText && !images) {
continue;
}
messages.push(createNormalizedMessage({
@@ -518,6 +553,7 @@ export class CursorSessionsProvider implements IProviderSessions {
kind: 'text',
role,
content: normalizedPartText,
images,
sequence: blob.sequence,
rowid: blob.rowid,
}));
@@ -557,8 +593,8 @@ export class CursorSessionsProvider implements IProviderSessions {
&& content.content.trim()
&& !isInternalCursorText(content.content)
) {
const normalizedText = unwrapUserQueryText(content.content, role);
if (!normalizedText) {
const { text: normalizedText, images } = extractUserTextAndImages(content.content, role);
if (!normalizedText && !images) {
continue;
}
messages.push(createNormalizedMessage({
@@ -569,6 +605,7 @@ export class CursorSessionsProvider implements IProviderSessions {
kind: 'text',
role,
content: normalizedText,
images,
sequence: blob.sequence,
rowid: blob.rowid,
}));

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
@@ -128,7 +129,8 @@ async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<
}
}
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
// Exported for tests: parses one Gemini CLI JSONL transcript from disk.
export async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
const messages: AnyRecord[] = [];
let tokenUsage: AnyRecord | undefined;
@@ -159,12 +161,18 @@ async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<G
const role = mapGeminiRole(entry.type);
if (role) {
const textContent = extractGeminiTextContent(entry.content);
if (textContent.trim()) {
const rawTextContent = extractGeminiTextContent(entry.content);
// User prompts sent with attachments carry an <images_input> path
// list; strip it for display and surface the paths as images.
const { text: textContent, attachments } = role === 'user'
? parseImagesInputTag(rawTextContent)
: { text: rawTextContent, attachments: [] };
if (textContent.trim() || attachments.length > 0) {
messages.push({
type: 'message',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
message: { role, content: textContent },
images: attachments.length > 0 ? attachments : undefined,
timestamp: entry.timestamp || null,
});
}
@@ -413,8 +421,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
}
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
const content = raw.message?.content ?? raw.content ?? '';
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
if (!role || (!content && !rawImages)) {
continue;
}
@@ -475,7 +484,12 @@ export class GeminiSessionsProvider implements IProviderSessions {
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
} else if (typeof content === 'string' && (content.trim() || rawImages)) {
// Legacy (non-JSONL) session files reach this branch with the raw
// prompt text, so strip any <images_input> block here as well.
const { text: cleanContent, attachments } = normalizedRole === 'user'
? parseImagesInputTag(content)
: { text: content, attachments: [] };
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
@@ -483,7 +497,8 @@ export class GeminiSessionsProvider implements IProviderSessions {
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
content: cleanContent,
images: rawImages ?? (attachments.length > 0 ? attachments : undefined),
}));
} else {
const textContent = extractGeminiTextContent(content);

View File

@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
import Database from 'better-sqlite3';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
@@ -418,8 +419,13 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
}
if (partType === 'text') {
const content = extractText(partData);
if (content.trim()) {
const rawContent = extractText(partData);
// User prompts sent with attachments carry an <images_input> path
// list; strip it for display and surface the paths as images.
const { text: content, attachments } = messageRole === 'user'
? parseImagesInputTag(rawContent)
: { text: rawContent, attachments: [] };
if (content.trim() || attachments.length > 0) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
@@ -428,6 +434,7 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant',
content,
images: attachments.length > 0 ? attachments : undefined,
}));
}
continue;

View File

@@ -46,7 +46,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'cursor',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
@@ -56,7 +56,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'codex',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
@@ -66,7 +66,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
@@ -76,7 +76,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'opencode',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,

View File

@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
import { appendImagesInputTag } from '@/shared/image-attachments.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
@@ -321,6 +322,41 @@ test('OpenCode session synchronizer adopts the pending app session before watche
}
});
test('OpenCode sessions provider strips <images_input> from user turns and exposes attachments', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-images-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
// Rewrite the user text part with the tagged prompt the runtime sends.
const taggedPrompt = appendImagesInputTag('Look at this screenshot.', [
{ path: 'C:/Users/x/.cloudcli/assets/shot.png' },
]);
const db = new Database(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db'));
try {
db.prepare('UPDATE part SET data = ? WHERE id = ?').run(
JSON.stringify({ type: 'text', text: taggedPrompt }),
'part-user-text',
);
} finally {
db.close();
}
const provider = new OpenCodeSessionsProvider();
const history = await provider.fetchHistory('open-session-1');
const userMessage = history.messages.find((message) => message.kind === 'text' && message.role === 'user');
assert.equal(userMessage?.content, 'Look at this screenshot.');
assert.deepEqual(userMessage?.images, [{ path: 'C:/Users/x/.cloudcli/assets/shot.png' }]);
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({

View File

@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js';
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
import { getGeminiJsonlSessionMessages } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import { appendImagesInputTag } from '@/shared/image-attachments.js';
const SESSION_ID = 'session-1';
// ---------------------------------------------------------------- Claude
test('claude history: base64 image blocks surface as user message images', () => {
const provider = new ClaudeSessionsProvider();
const entry = {
uuid: 'u1',
timestamp: '2026-07-03T10:00:00.000Z',
message: {
role: 'user',
content: [
{ type: 'text', text: 'What is in this screenshot?' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'REVG' } },
],
},
};
const messages = provider.normalizeMessage(entry, SESSION_ID);
assert.equal(messages.length, 1);
assert.equal(messages[0].kind, 'text');
assert.equal(messages[0].role, 'user');
assert.equal(messages[0].content, 'What is in this screenshot?');
assert.deepEqual(messages[0].images, [
{ data: 'data:image/png;base64,QUJD' },
{ data: 'data:image/jpeg;base64,REVG' },
]);
});
test('claude history: image-only user turns still produce a bubble', () => {
const provider = new ClaudeSessionsProvider();
const entry = {
uuid: 'u2',
timestamp: '2026-07-03T10:00:00.000Z',
message: {
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
],
},
};
const messages = provider.normalizeMessage(entry, SESSION_ID);
assert.equal(messages.length, 1);
assert.equal(messages[0].role, 'user');
assert.equal(messages[0].content, '');
assert.deepEqual(messages[0].images, [{ data: 'data:image/png;base64,QUJD' }]);
});
test('claude history: plain text user turns carry no images field', () => {
const provider = new ClaudeSessionsProvider();
const entry = {
uuid: 'u3',
timestamp: '2026-07-03T10:00:00.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
};
const messages = provider.normalizeMessage(entry, SESSION_ID);
assert.equal(messages.length, 1);
assert.equal(messages[0].images, undefined);
});
// ---------------------------------------------------------------- Codex
test('codex history: user_message payload images become path attachments', () => {
// Real rollout shape: local_image input items land in `local_images`,
// while `images` stays an empty array.
assert.deepEqual(
extractCodexUserImages({
type: 'user_message',
message: 'can u see attached image?',
images: [],
local_images: ['C:\\proj\\.cloudcli\\assets\\a.png'],
}),
[{ path: 'C:/proj/.cloudcli/assets/a.png' }],
);
assert.deepEqual(
extractCodexUserImages({ type: 'user_message', message: 'hi', images: ['/proj/b.jpg'] }),
[{ path: '/proj/b.jpg' }],
);
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi' }), undefined);
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi', images: [], local_images: [] }), undefined);
});
test('codex history: base64 data URLs pass through as inline data attachments', () => {
const dataUrl = 'data:image/png;base64,QUJD';
assert.deepEqual(
extractCodexUserImages({
type: 'user_message',
message: 'look',
images: [dataUrl],
local_images: ['C:\\proj\\a.png'],
}),
[{ path: 'C:/proj/a.png' }, { data: dataUrl }],
);
});
test('codex history: normalized user entries keep their images', () => {
const provider = new CodexSessionsProvider();
const messages = provider.normalizeMessage(
{
timestamp: '2026-07-03T10:00:00.000Z',
message: { role: 'user', content: 'Look at this' },
images: [{ path: '.cloudcli/assets/a.png' }],
},
SESSION_ID,
);
assert.equal(messages.length, 1);
assert.equal(messages[0].role, 'user');
assert.equal(messages[0].content, 'Look at this');
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]);
});
// ---------------------------------------------------------------- Gemini
test('gemini history: <images_input> tag is stripped and paths attached', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
const jsonlPath = path.join(tempDir, 'session.jsonl');
try {
const taggedPrompt = appendImagesInputTag('Compare these designs', [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli/assets/2-b.png' },
]);
const lines = [
JSON.stringify({ type: 'user', id: 'm1', content: taggedPrompt, timestamp: '2026-07-03T10:00:00.000Z' }),
JSON.stringify({ type: 'gemini', id: 'm2', content: 'They differ in spacing.', timestamp: '2026-07-03T10:00:05.000Z' }),
];
await writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8');
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
assert.equal(messages.length, 2);
assert.equal(messages[0].message.role, 'user');
assert.equal(messages[0].message.content, 'Compare these designs');
assert.deepEqual(messages[0].images, [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli/assets/2-b.png' },
]);
// Assistant text is left untouched.
assert.equal(messages[1].message.role, 'assistant');
assert.equal(messages[1].images, undefined);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('gemini history: prompts without a tag are unchanged', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
const jsonlPath = path.join(tempDir, 'session.jsonl');
try {
await writeFile(
jsonlPath,
`${JSON.stringify({ type: 'user', id: 'm1', content: 'plain prompt', timestamp: '2026-07-03T10:00:00.000Z' })}\n`,
'utf8',
);
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
assert.equal(messages.length, 1);
assert.equal(messages[0].message.content, 'plain prompt');
assert.equal(messages[0].images, undefined);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------- Cursor
test('cursor history: <images_input> inside user_query is stripped and attached', () => {
const provider = new CursorSessionsProvider();
const taggedPrompt = appendImagesInputTag('Fix the layout bug', [{ path: '.cloudcli/assets/shot.png' }]);
const blobs = [
{
id: 'blob1',
sequence: 1,
rowid: 1,
content: {
role: 'user',
content: `<timestamp>2026-07-03</timestamp>\n<user_query>${taggedPrompt}</user_query>`,
},
},
{
id: 'blob2',
sequence: 2,
rowid: 2,
content: {
role: 'assistant',
content: [{ type: 'text', text: 'Done — the flex container was wrong.' }],
},
},
];
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
assert.equal(messages.length, 2);
assert.equal(messages[0].role, 'user');
assert.equal(messages[0].content, 'Fix the layout bug');
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/shot.png' }]);
assert.equal(messages[1].role, 'assistant');
assert.equal(messages[1].images, undefined);
});
test('cursor history: user text without a tag keeps existing behavior', () => {
const provider = new CursorSessionsProvider();
const blobs = [
{
id: 'blob1',
sequence: 1,
rowid: 1,
content: {
role: 'user',
content: '<timestamp>2026-07-03</timestamp>\n<user_query>plain question</user_query>',
},
},
];
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
assert.equal(messages.length, 1);
assert.equal(messages[0].content, 'plain question');
assert.equal(messages[0].images, undefined);
});

View File

@@ -14,6 +14,8 @@
*/
import { Codex } from '@openai/codex-sdk';
import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
@@ -228,6 +230,7 @@ export async function queryCodex(command, options = {}, ws) {
projectPath,
model,
effort,
images,
permissionMode = 'default'
} = options;
@@ -288,7 +291,12 @@ export async function queryCodex(command, options = {}, ws) {
registerSession(capturedSessionId);
}
const streamedTurn = await thread.runStreamed(command, {
// Execute with streaming. Turns with image attachments send structured
// input items so Codex reads the images from their local asset paths.
const turnInput = normalizeImageDescriptors(images).length > 0
? buildCodexInputItems(command, images, workingDirectory)
: command;
const streamedTurn = await thread.runStreamed(turnInput, {
signal: abortController.signal
});

View File

@@ -4,11 +4,12 @@ import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn';
import Database from 'better-sqlite3';
import { appendImagesInputTag } from './shared/image-attachments.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -92,7 +93,7 @@ function readOpenCodeTokenUsage(sessionId) {
async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options;
const { sessionId, projectPath, cwd, model, effort, sessionSummary, images } = options;
const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null;
@@ -224,7 +225,11 @@ async function spawnOpenCode(command, options = {}, ws) {
args.push('--variant', resolvedEffort);
}
if (command && command.trim()) {
args.push(command.trim());
// Image attachments ride along as an <images_input> path list appended
// to the prompt; the session history reader strips the tag back out.
// opencode is a .cmd shim on Windows, so the whole argument must be
// newline-free or cmd.exe silently truncates it at the first newline.
args.push(flattenPromptForWindowsShell(appendImagesInputTag(command.trim(), images)));
}
opencodeProcess = spawnFunction('opencode', args, {

View File

@@ -0,0 +1,286 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Shared image-attachment plumbing for every provider runtime.
*
* Uploaded chat images are persisted once in the global `~/.cloudcli/assets`
* folder and referenced by absolute path everywhere else:
* - Claude: paths are read back into base64 `image` content blocks.
* - Codex: paths become `local_image` input items.
* - Gemini/Cursor: paths are appended to the prompt inside an
* `<images_input>` tag, which is stripped again when history is read.
*
* The chat UI loads them through the dedicated `/api/assets/images/:filename`
* route, which serves only from this folder.
*/
/** Global storage folder for uploaded chat image attachments. */
export function getGlobalImageAssetsDir(): string {
return path.join(os.homedir(), '.cloudcli', 'assets');
}
export type ImageAttachmentDescriptor = {
/** Project-relative (preferred) or absolute path to the stored image. */
path: string;
name?: string;
mimeType?: string;
};
/** Media types the Claude Messages API accepts for base64 image blocks. */
const CLAUDE_IMAGE_MEDIA_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
]);
const EXTENSION_TO_MEDIA_TYPE: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
};
/**
* Accepts the loosely-typed `options.images` payload from chat.send and
* returns only well-formed descriptors. Plain path strings are supported so
* callers can also pass bare path arrays.
*/
export function normalizeImageDescriptors(images: unknown): ImageAttachmentDescriptor[] {
if (!Array.isArray(images)) {
return [];
}
const descriptors: ImageAttachmentDescriptor[] = [];
for (const entry of images) {
if (typeof entry === 'string' && entry.trim()) {
descriptors.push({ path: entry.trim() });
continue;
}
if (entry && typeof entry === 'object') {
const record = entry as Record<string, unknown>;
const entryPath = typeof record.path === 'string' ? record.path.trim() : '';
if (!entryPath) {
continue;
}
descriptors.push({
path: entryPath,
name: typeof record.name === 'string' ? record.name : undefined,
mimeType: typeof record.mimeType === 'string' ? record.mimeType : undefined,
});
}
}
return descriptors;
}
/** Normalizes Windows separators so stored references stay portable. */
export function toPosixPath(value: string): string {
return value.replace(/\\/g, '/');
}
/** Resolves a project-relative image path against the run's working directory. */
export function resolveImageAbsolutePath(cwd: string | undefined, imagePath: string): string {
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(cwd || process.cwd(), imagePath);
}
/**
* Resolves the media type for one image, preferring the uploaded mime type and
* falling back to the file extension.
*/
export function resolveImageMediaType(descriptor: ImageAttachmentDescriptor): string | null {
if (descriptor.mimeType) {
return descriptor.mimeType;
}
const extension = path.extname(descriptor.path).toLowerCase();
return EXTENSION_TO_MEDIA_TYPE[extension] || null;
}
const IMAGES_INPUT_TAG_PATTERN = /\s*<images_input>([\s\S]*?)<\/images_input>\s*/g;
// One image reference recovered from an <images_input> block: the stored
// asset path plus the user's original filename when it was recorded.
export type ParsedImageAttachment = {
path: string;
name?: string;
};
// Result of stripping an <images_input> block out of persisted prompt text.
// `imagePaths` mirrors `attachments` for callers that only need paths.
export type ParsedImagesInput = {
text: string;
imagePaths: string[];
attachments: ParsedImageAttachment[];
};
/**
* Appends the `<images_input>` reference block used by the Gemini, Cursor,
* and OpenCode CLIs. The block carries one numbered line per attachment with
* the stored file path (quote-free on purpose — Windows .cmd shims mangle
* quoted text) and the user's original filename, plus an explicit instruction
* to read the files and keep the block out of the reply. The same block is
* stripped back out of persisted history by {@link parseImagesInputTag}.
*/
export function appendImagesInputTag(prompt: string, images: unknown): string {
const descriptors = normalizeImageDescriptors(images);
if (descriptors.length === 0) {
return prompt;
}
const entryLines = descriptors.map((descriptor, index) => {
const entryPath = toPosixPath(descriptor.path);
// Parentheses and newlines would break the "(original name: ...)" suffix
// the parser looks for, so drop them from the display name.
const cleanName = descriptor.name?.replace(/[()\r\n]/g, '').trim();
return cleanName
? `${index + 1}. ${entryPath} (original name: ${cleanName})`
: `${index + 1}. ${entryPath}`;
});
return [
prompt,
'',
'<images_input>',
`The user attached ${descriptors.length} image(s) to this message. Read each file listed below with your file/image reading tool and use what you see to answer the prompt above. Respond as if the images were attached directly. Do not mention this block or the file paths unless the user asks about them.`,
...entryLines,
'</images_input>',
].join('\n');
}
// Matches one numbered attachment entry inside the tag body. Works for both
// the multi-line block and the Windows-flattened single-line form, where the
// next ` N. ` marker (or the end of the body) delimits each entry.
const IMAGES_INPUT_ENTRY_PATTERN = /\d+\.\s+(.+?)(?=\s+\d+\.\s+|\s*$)/g;
const ORIGINAL_NAME_SUFFIX_PATTERN = /\(original name: ([^)]*)\)\s*$/;
function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] {
const attachments: ParsedImageAttachment[] = [];
for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) {
let entryText = entryMatch[1].trim();
let name: string | undefined;
const nameMatch = ORIGINAL_NAME_SUFFIX_PATTERN.exec(entryText);
if (nameMatch) {
name = nameMatch[1].trim() || undefined;
entryText = entryText.slice(0, nameMatch.index).trim();
}
if (entryText) {
attachments.push(name ? { path: toPosixPath(entryText), name } : { path: toPosixPath(entryText) });
}
}
return attachments;
}
/**
* Strips one `<images_input>` block from persisted prompt text and returns
* the clean text plus the referenced attachments (path and original name).
*
* Only the LAST block in the text is treated as the attachment carrier — the
* composer always appends it at the end, so a user who literally typed
* `<images_input>` earlier in their prompt keeps that text intact.
*
* Understands the numbered-line body in both its multi-line and
* Windows-flattened single-line forms.
*/
export function parseImagesInputTag(text: string): ParsedImagesInput {
if (typeof text !== 'string' || !text.includes('<images_input>')) {
return { text, imagePaths: [], attachments: [] };
}
let lastMatch: RegExpExecArray | null = null;
IMAGES_INPUT_TAG_PATTERN.lastIndex = 0;
for (let match = IMAGES_INPUT_TAG_PATTERN.exec(text); match; match = IMAGES_INPUT_TAG_PATTERN.exec(text)) {
lastMatch = match;
}
if (!lastMatch) {
return { text, imagePaths: [], attachments: [] };
}
const attachments = parseNumberedImageEntries(lastMatch[1]);
const stripped = (
text.slice(0, lastMatch.index) + '\n' + text.slice(lastMatch.index + lastMatch[0].length)
).trim();
return {
text: stripped,
imagePaths: attachments.map((attachment) => attachment.path),
attachments,
};
}
/** Maps raw image paths to the attachment shape carried by NormalizedMessage.images. */
export function toImageAttachments(imagePaths: string[]): Array<{ path: string }> {
return imagePaths.map((imagePath) => ({ path: toPosixPath(imagePath) }));
}
type ClaudeContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
/**
* Builds the Claude user-message content list: the prompt text followed by one
* base64 `image` block per attachment. Images the Claude API cannot accept
* (e.g. SVG) or that fail to read are skipped with a warning so the prompt
* itself still goes through.
*/
export async function buildClaudeUserContent(
prompt: string,
images: unknown,
cwd?: string,
): Promise<ClaudeContentBlock[]> {
const blocks: ClaudeContentBlock[] = [{ type: 'text', text: prompt }];
for (const descriptor of normalizeImageDescriptors(images)) {
const mediaType = resolveImageMediaType(descriptor);
if (!mediaType || !CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType)) {
console.warn(`[Images] Skipping unsupported Claude image type for ${descriptor.path}`);
continue;
}
try {
const bytes = await fs.readFile(resolveImageAbsolutePath(cwd, descriptor.path));
blocks.push({
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: bytes.toString('base64'),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Images] Failed to read image ${descriptor.path}: ${message}`);
}
}
return blocks;
}
type CodexInputItem =
| { type: 'text'; text: string }
| { type: 'local_image'; path: string };
/**
* Builds the Codex `runStreamed` input list: prompt text plus one
* `local_image` item per attachment, resolved to absolute paths so the Codex
* runtime can read them regardless of its own working directory handling.
*/
export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] {
const items: CodexInputItem[] = [{ type: 'text', text: prompt }];
for (const descriptor of normalizeImageDescriptors(images)) {
items.push({
type: 'local_image',
path: resolveImageAbsolutePath(cwd, descriptor.path),
});
}
return items;
}

View File

@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
appendImagesInputTag,
buildClaudeUserContent,
buildCodexInputItems,
normalizeImageDescriptors,
parseImagesInputTag,
resolveImageMediaType,
toImageAttachments,
} from '@/shared/image-attachments.js';
// 1x1 transparent PNG
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => {
const descriptors = normalizeImageDescriptors([
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
'scripts/pic.jpg',
{ name: 'no-path.png' },
42,
null,
'',
]);
assert.deepEqual(descriptors, [
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
{ path: 'scripts/pic.jpg' },
]);
assert.deepEqual(normalizeImageDescriptors(undefined), []);
assert.deepEqual(normalizeImageDescriptors('not-an-array'), []);
});
test('appendImagesInputTag and parseImagesInputTag round-trip', () => {
const prompt = 'Describe these screenshots.\n\nFocus on the header.';
const tagged = appendImagesInputTag(prompt, [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli\\assets\\2-b.jpg' },
]);
assert.ok(tagged.startsWith(prompt));
assert.ok(tagged.includes('<images_input>'));
assert.ok(tagged.includes('</images_input>'));
assert.ok(tagged.includes('The user attached 2 image(s)'));
const parsed = parseImagesInputTag(tagged);
assert.equal(parsed.text, prompt);
// Backslashes are normalized so references stay portable.
assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']);
});
test('original filenames round-trip through the tag', () => {
const tagged = appendImagesInputTag('compare these', [
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' },
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
]);
const parsed = parseImagesInputTag(tagged);
assert.equal(parsed.text, 'compare these');
// Parentheses are dropped from names so the "(original name: ...)" suffix
// stays parseable; the path-only entry carries no name.
assert.deepEqual(parsed.attachments, [
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' },
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
]);
});
test('only the LAST images_input block is treated as the attachment carrier', () => {
const userTypedTag = 'What does <images_input> mean in this codebase?';
const tagged = appendImagesInputTag(
`${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\n\nAlso check this.`,
[{ path: 'C:/Users/x/.cloudcli/assets/real.png' }],
);
const parsed = parseImagesInputTag(tagged);
assert.ok(parsed.text.includes('fake user block'));
assert.ok(parsed.text.includes('Also check this.'));
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']);
});
test('appendImagesInputTag without images returns the prompt untouched', () => {
assert.equal(appendImagesInputTag('hello', []), 'hello');
assert.equal(appendImagesInputTag('hello', undefined), 'hello');
});
test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => {
// Windows spawn runtimes collapse newlines before passing the argument to
// .cmd-shimmed CLIs; the persisted prompt is then a single line.
const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }])
.replace(/\s*\r?\n\s*/g, ' ')
.trim();
assert.ok(!flattened.includes('\n'));
const parsed = parseImagesInputTag(flattened);
assert.equal(parsed.text, 'now?');
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']);
});
test('parseImagesInputTag leaves text without a tag untouched', () => {
const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.';
const parsed = parseImagesInputTag(text);
assert.equal(parsed.text, text);
assert.deepEqual(parsed.imagePaths, []);
});
test('parseImagesInputTag strips a malformed tag body without attaching images', () => {
const text = 'prompt\n\n<images_input>\nnot json here\n</images_input>';
const parsed = parseImagesInputTag(text);
assert.equal(parsed.text, 'prompt');
assert.deepEqual(parsed.imagePaths, []);
});
test('toImageAttachments maps paths to posix attachment records', () => {
assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [
{ path: 'a/b/c.png' },
{ path: 'd/e.jpg' },
]);
});
test('resolveImageMediaType prefers the mime type and falls back to the extension', () => {
assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp');
assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg');
assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png');
assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null);
});
test('buildClaudeUserContent reads image bytes into base64 blocks', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
try {
await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES);
const content = await buildClaudeUserContent(
'What is in this image?',
[{ path: 'shot.png', mimeType: 'image/png' }],
tempDir,
);
assert.equal(content.length, 2);
assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' });
assert.equal(content[1].type, 'image');
const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
assert.equal(imageBlock.source.type, 'base64');
assert.equal(imageBlock.source.media_type, 'image/png');
assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('buildClaudeUserContent skips unsupported types and unreadable files', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
try {
await writeFile(path.join(tempDir, 'vector.svg'), '<svg></svg>');
const content = await buildClaudeUserContent(
'prompt',
[
{ path: 'vector.svg', mimeType: 'image/svg+xml' },
{ path: 'missing.png', mimeType: 'image/png' },
],
tempDir,
);
// Only the text block survives; the prompt still goes through.
assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('buildCodexInputItems emits text plus absolute local_image paths', () => {
const cwd = path.join(os.tmpdir(), 'codex-project');
const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);
assert.equal(items.length, 2);
assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' });
assert.equal(items[1].type, 'local_image');
const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>;
assert.ok(path.isAbsolute(imageItem.path));
assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg'));
});

View File

@@ -1239,3 +1239,24 @@ export async function extractFirstValidJsonlData<T>(
return null;
}
// ---------------------------
//----------------- CLI PROMPT ARGUMENT UTILITIES ------------
/**
* Makes a prompt safe to pass as one CLI argument to `.cmd`-shimmed tools on
* Windows (cursor-agent, gemini, opencode installed via npm-style shims).
*
* cmd.exe cannot carry newlines inside an argument: everything after the
* first newline is silently dropped before the target CLI ever sees it, which
* truncates multi-line prompts and any appended `<images_input>` block.
* Collapsing newline runs to single spaces loses formatting but never loses
* content, so runtimes should call this on win32 right before spawning.
*
* Used by the cursor, gemini, and opencode spawn runtimes.
*/
export function flattenPromptForWindowsShell(prompt: string): string {
if (process.platform !== 'win32' || typeof prompt !== 'string') {
return prompt;
}
return prompt.replace(/\s*\r?\n\s*/g, ' ').trim();
}

View File

@@ -642,7 +642,7 @@ export function useChatComposerState({
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
const response = await authenticatedFetch('/api/assets/images', {
method: 'POST',
headers: {},
body: formData,

View File

@@ -51,7 +51,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
switch (msg.kind) {
case 'text': {
const content = msg.content || '';
if (!content.trim()) continue;
const images = Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined;
if (!content.trim() && !images) continue;
if (msg.role === 'user') {
// Parse task notifications
@@ -71,6 +72,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
type: 'user',
content: unescapeWithMathProtection(decodeHtmlEntities(content)),
timestamp: msg.timestamp,
images,
...sharedMetadata,
});
}

View File

@@ -83,6 +83,9 @@ function chatMessageToNormalized(
kind: 'text',
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content || '',
// Keep attachment references on the local echo so the user bubble shows
// its images immediately, before the server-backed copy replaces it.
images: Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined,
} as NormalizedMessage;
}

View File

@@ -10,8 +10,12 @@ export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
export interface ChatImage {
data: string;
name: string;
/** Inline data URL (Claude history stores attachments as base64). */
data?: string;
/** Project-relative path under `.cloudcli/assets` served via the files API. */
path?: string;
name?: string;
mimeType?: string;
}
export interface ToolResult {

View File

@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { authenticatedFetch } from '../../../../utils/api';
import type { ChatImage } from '../../types/types';
type ChatMessageImagesProps = {
images: ChatImage[];
projectId?: string | null;
};
/**
* Resolves one chat image to a displayable src. Inline data URLs are used
* directly; path-based attachments are fetched as blobs (a bare <img src>
* cannot carry the auth header) — first from the global assets route
* (`~/.cloudcli/assets`), then from the project files route as a fallback for
* sessions recorded before attachments moved to the global store.
*/
function useChatImageSrc(image: ChatImage, projectId?: string | null): { src: string | null; failed: boolean } {
const [src, setSrc] = useState<string | null>(image.data || null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (image.data) {
setSrc(image.data);
setFailed(false);
return;
}
const imagePath = image.path;
if (!imagePath) {
setSrc(null);
setFailed(true);
return;
}
const filename = imagePath.split(/[\\/]/).pop() || '';
const candidateUrls = [
`/api/assets/images/${encodeURIComponent(filename)}`,
...(projectId
? [`/api/projects/${projectId}/files/content?path=${encodeURIComponent(imagePath)}`]
: []),
];
let objectUrl: string | null = null;
const controller = new AbortController();
const load = async () => {
setFailed(false);
for (const url of candidateUrls) {
try {
const response = await authenticatedFetch(url, { signal: controller.signal });
if (!response.ok) {
continue;
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
}
}
setSrc(null);
setFailed(true);
};
void load();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [image.data, image.path, projectId]);
return { src, failed };
}
/**
* Fullscreen image overlay in the claude.ai style: dark backdrop, centered
* image, closes on backdrop click, close button, or Escape.
*/
function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={alt}
>
<button
type="button"
onClick={onClose}
aria-label="Close image preview"
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
>
<X className="h-5 w-5" />
</button>
<img
src={src}
alt={alt}
onClick={(event) => event.stopPropagation()}
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body,
);
}
function ChatMessageImage({ image, projectId }: { image: ChatImage; projectId?: string | null }) {
const { src, failed } = useChatImageSrc(image, projectId);
const [expanded, setExpanded] = useState(false);
const alt = image.name || 'Attached image';
if (failed) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-xl border border-border/50 bg-muted px-2 text-center text-[10px] text-muted-foreground">
{alt}
</div>
);
}
if (!src) {
return <div className="h-28 w-28 animate-pulse rounded-xl border border-border/50 bg-muted" />;
}
return (
<>
<button
type="button"
onClick={() => setExpanded(true)}
aria-label={`Expand ${alt}`}
className="block overflow-hidden rounded-xl border border-border/50 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/60"
>
<img
src={src}
alt={alt}
className="h-28 w-28 cursor-zoom-in object-cover transition-transform duration-200 hover:scale-105"
/>
</button>
{expanded && <ImageLightbox src={src} alt={alt} onClose={() => setExpanded(false)} />}
</>
);
}
/**
* Image attachments for a user turn, rendered claude.ai-style: standalone
* rounded square cards shown above the message bubble. Each thumbnail
* expands to a fullscreen lightbox on click.
*/
export default function ChatMessageImages({ images, projectId }: ChatMessageImagesProps) {
if (!images || images.length === 0) {
return null;
}
return (
<div className="flex flex-wrap justify-end gap-2">
{images.map((image, index) => (
<ChatMessageImage key={image.path || image.name || index} image={image} projectId={projectId} />
))}
</div>
);
}

View File

@@ -18,14 +18,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
return (
<div className="group relative">
<img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" />
<div className="overflow-hidden rounded-xl border border-border/50 shadow-sm">
<img src={preview} alt={file.name} className="h-20 w-20 object-cover" />
</div>
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/50">
<div className="text-xs text-white">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-red-500/50">
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-red-500/50">
<svg className="h-6 w-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>
@@ -34,7 +36,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
<button
type="button"
onClick={onRemove}
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="absolute -right-1.5 -top-1.5 rounded-full border border-border/40 bg-background/90 p-1 text-foreground shadow-sm backdrop-blur transition-opacity hover:bg-background focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove image"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -13,6 +13,7 @@ import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import ChatMessageImages from './ChatMessageImages';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
import MessageSpeakControl from './MessageSpeakControl';
@@ -84,31 +85,28 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (
/* User message bubble on the right */
/* User turn on the right: claude.ai-style attachment cards above the bubble */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
<div className="flex min-w-0 flex-1 flex-col items-end gap-2 sm:flex-initial">
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={img.name || idx}
src={img.data}
alt={img.name}
className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
onClick={() => window.open(img.data, '_blank')}
/>
))}
<ChatMessageImages
images={message.images}
projectId={selectedProject?.projectId}
/>
)}
{(userCopyContent.trim().length > 0 || !message.images?.length) && (
<div className="group max-w-full rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
)}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (
<div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">

View File

@@ -202,7 +202,7 @@ export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInput
type={isActive ? 'button' : 'submit'}
variant="default"
size="icon"
className={cn('h-8 w-8 rounded-lg', className)}
className={cn('h-8 w-8 shrink-0 rounded-lg', className)}
{...props}
>
{children ?? (isActive ? (

View File

@@ -60,7 +60,7 @@ export interface NormalizedMessage {
isLocalCommand?: boolean;
isLocalCommandStdout?: boolean;
isCompactSummary?: boolean;
images?: string[];
images?: Array<{ path?: string; data?: string; name?: string }>;
toolName?: string;
toolInput?: unknown;
toolId?: string;