mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
feat: support attached images for all providers
This commit is contained in:
188
server/shared/tests/image-attachments.test.ts
Normal file
188
server/shared/tests/image-attachments.test.ts
Normal 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'));
|
||||
});
|
||||
Reference in New Issue
Block a user