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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
})];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user