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

@@ -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;