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

@@ -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();
}