Add inline image previews in assistant messages

This commit is contained in:
Mohamed Boudra
2026-04-12 22:41:54 +07:00
parent ec445e606c
commit 4547d597ef
9 changed files with 738 additions and 11 deletions
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearAssistantImageMetadataCache,
estimateAssistantMessageHeightFromCache,
extractAssistantImageSources,
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "./assistant-image-metadata";
describe("assistant image metadata", () => {
beforeEach(() => {
clearAssistantImageMetadataCache();
});
it("extracts markdown image sources", () => {
expect(
extractAssistantImageSources(
'Before\n\n![local](/tmp/paseo.png)\n\n![remote](https://example.com/test.png "Remote")',
),
).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
});
it("reuses cached metadata across canonical and raw source keys", () => {
setAssistantImageMetadata(
{
source: "/tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/moboudra/dev/paseo",
serverId: "server-1",
},
{ width: 1200, height: 800 },
);
expect(
getAssistantImageMetadata({
source: "/tmp/paseo-codex-screenshot.png",
}),
).toEqual({
width: 1200,
height: 800,
aspectRatio: 1.5,
});
});
it("estimates assistant message height from cached image metadata", () => {
setAssistantImageMetadata(
{
source: "https://example.com/landscape.png",
},
{ width: 1200, height: 800 },
);
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)",
),
).toBeGreaterThan(220);
});
});
@@ -0,0 +1,213 @@
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
export interface AssistantImageMetadata {
width: number;
height: number;
aspectRatio: number;
}
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
cache.delete(key);
cache.set(key, value);
if (cache.size <= limit) {
return;
}
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) {
cache.delete(oldestKey);
}
}
function normalizeAssistantImageSourceToken(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
const inner = trimmed.slice(1, -1).trim();
return inner || null;
}
const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
const source = titleMatch?.[1]?.trim() ?? trimmed;
return source || null;
}
function createSourceAliasKey(source: string): string {
return `source:${source}`;
}
function createResolutionKey(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): string | null {
const resolution = resolveAssistantImageSource({
source: input.source,
workspaceRoot: input.workspaceRoot,
});
if (!resolution) {
return null;
}
if (resolution.kind === "direct") {
return `direct:${resolution.uri}`;
}
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
}
function getAssistantImageMetadataKeys(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): string[] {
const source = input.source.trim();
if (!source) {
return [];
}
const keys = [createSourceAliasKey(source)];
const resolutionKey = createResolutionKey(input);
if (resolutionKey) {
keys.unshift(resolutionKey);
}
return [...new Set(keys)];
}
export function getAssistantImageMetadata(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): AssistantImageMetadata | null {
for (const key of getAssistantImageMetadataKeys(input)) {
const metadata = assistantImageMetadataCache.get(key);
if (metadata) {
touchCacheEntry(
assistantImageMetadataCache,
key,
metadata,
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
);
return metadata;
}
}
return null;
}
export function setAssistantImageMetadata(
input: {
source: string;
workspaceRoot?: string;
serverId?: string;
},
dimensions: { width: number; height: number },
): AssistantImageMetadata | null {
const { width, height } = dimensions;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
const metadata: AssistantImageMetadata = {
width,
height,
aspectRatio: width / height,
};
for (const key of getAssistantImageMetadataKeys(input)) {
touchCacheEntry(
assistantImageMetadataCache,
key,
metadata,
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
);
}
return metadata;
}
export function extractAssistantImageSources(markdown: string): string[] {
const cachedParse = assistantImageParseCache.get(markdown);
if (cachedParse) {
touchCacheEntry(
assistantImageParseCache,
markdown,
cachedParse,
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
);
return cachedParse.sources;
}
const sources: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
if (normalized) {
sources.push(normalized);
}
}
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
touchCacheEntry(
assistantImageParseCache,
markdown,
{ sources, hasNonImageText },
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
);
return sources;
}
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
const cachedParse = assistantImageParseCache.get(markdown);
const parsed =
cachedParse ??
(() => {
const sources = extractAssistantImageSources(markdown);
const nextParsed = assistantImageParseCache.get(markdown);
return nextParsed ?? { sources, hasNonImageText: true };
})();
if (parsed.sources.length === 0) {
return null;
}
const knownHeights = parsed.sources
.map((source) => getAssistantImageMetadata({ source }))
.filter((metadata): metadata is AssistantImageMetadata => metadata !== null)
.map((metadata) =>
Math.max(
ASSISTANT_IMAGE_MIN_HEIGHT,
Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
),
);
if (knownHeights.length === 0) {
return null;
}
const baseHeight = parsed.hasNonImageText
? ASSISTANT_MESSAGE_BASE_HEIGHT
: ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
const estimatedHeight =
baseHeight +
knownHeights.reduce((sum, height) => sum + height, 0) +
ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length;
return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
}
export function clearAssistantImageMetadataCache(): void {
assistantImageMetadataCache.clear();
assistantImageParseCache.clear();
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantImageSource } from "./assistant-image-source";
describe("resolveAssistantImageSource", () => {
it("passes through direct image URIs", () => {
expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({
kind: "direct",
uri: "https://example.com/image.png",
});
expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({
kind: "direct",
uri: "data:image/png;base64,abc",
});
});
it("uses the workspace root for relative paths", () => {
expect(
resolveAssistantImageSource({
source: "screenshots/output.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/Users/test/project",
path: "screenshots/output.png",
});
});
it("uses the workspace root for absolute paths inside the workspace", () => {
expect(
resolveAssistantImageSource({
source: "/Users/test/project/screenshots/output.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/Users/test/project",
path: "/Users/test/project/screenshots/output.png",
});
});
it("falls back to filesystem root for absolute paths outside the workspace", () => {
expect(
resolveAssistantImageSource({
source: "/tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/",
path: "/tmp/paseo-codex-screenshot.png",
});
});
it("normalizes file URIs into file RPC requests", () => {
expect(
resolveAssistantImageSource({
source: "file:///tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/",
path: "/tmp/paseo-codex-screenshot.png",
});
});
it("falls back to the drive root for Windows absolute paths", () => {
expect(
resolveAssistantImageSource({
source: "C:/Users/test/Desktop/screenshot.png",
workspaceRoot: "D:/repo",
}),
).toEqual({
kind: "file_rpc",
cwd: "C:/",
path: "C:/Users/test/Desktop/screenshot.png",
});
});
});
@@ -0,0 +1,109 @@
import { fileUriToPath } from "@/attachments/utils";
import { isAbsolutePath } from "@/utils/path";
export type AssistantImageSourceResolution =
| { kind: "direct"; uri: string }
| { kind: "file_rpc"; cwd: string; path: string };
function trimTrailingSeparators(value: string): string {
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
return value.replace(/\\/g, "/");
}
return value.replace(/[\\/]+$/, "");
}
function normalizeForPathComparison(value: string): string {
const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
if (/^[A-Za-z]:\//.test(normalized)) {
return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const candidate = normalizeForPathComparison(candidatePath);
const root = normalizeForPathComparison(rootPath);
if (!candidate || !root) {
return false;
}
if (root === "/") {
return candidate.startsWith("/");
}
if (candidate === root) {
return true;
}
return candidate.startsWith(`${root}/`);
}
function deriveFallbackRootFromAbsolutePath(value: string): string | null {
if (value.startsWith("/")) {
return "/";
}
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
if (driveMatch?.[1]) {
return `${driveMatch[1]}/`;
}
const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
if (uncMatch?.[1]) {
return uncMatch[1];
}
return null;
}
export function resolveAssistantImageSource(input: {
source: string;
workspaceRoot?: string;
}): AssistantImageSourceResolution | null {
const source = input.source.trim();
if (!source) {
return null;
}
if (/^(https?:|data:|blob:)/i.test(source)) {
return { kind: "direct", uri: source };
}
const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
if (!sourcePath) {
return null;
}
if (!isAbsolutePath(sourcePath)) {
const workspaceRoot = input.workspaceRoot?.trim();
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
}
return {
kind: "file_rpc",
cwd: workspaceRoot,
path: sourcePath,
};
}
const workspaceRoot = input.workspaceRoot?.trim();
if (
workspaceRoot &&
isAbsolutePath(workspaceRoot) &&
isPathWithinRoot(sourcePath, workspaceRoot)
) {
return {
kind: "file_rpc",
cwd: workspaceRoot,
path: sourcePath,
};
}
const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath);
if (!fallbackRoot) {
return null;
}
return {
kind: "file_rpc",
cwd: fallbackRoot,
path: sourcePath,
};
}