feat: inline image display with lightbox

- Add image block type for base64 and URL images
- Parse image/image_url blocks from gateway history
- Render images inline in chat messages (rounded, dark-themed)
- Click-to-zoom lightbox with Escape to close
- Markdown images also use the lightbox component
- Detect base64 images in tool results (e.g. Read tool on image files)
- Support png, jpg, gif, webp formats
This commit is contained in:
Nicolas Varrot
2026-02-11 17:18:10 +00:00
parent 375302a27b
commit 762a5f2026
6 changed files with 158 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
import { ThinkingBlock } from './ThinkingBlock';
import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { ImageBlock, buildImageSrc } from './ImageBlock';
import { Bot, User, Wrench } from 'lucide-react';
import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale';
@@ -127,11 +128,19 @@ function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'text' && b.text.trim());
}
function getImageBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'image');
}
function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
}
const markdownComponents = { pre: CodeBlock };
function MarkdownImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
return <ImageBlock src={props.src || ''} alt={props.alt} />;
}
const markdownComponents = { pre: CodeBlock, img: MarkdownImage };
function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => (
@@ -143,6 +152,15 @@ function renderTextBlocks(blocks: MessageBlock[]) {
));
}
function renderImageBlocks(blocks: MessageBlock[]) {
return getImageBlocks(blocks).map((block, i) => {
const b = block as { type: 'image'; mediaType: string; data?: string; url?: string };
const src = buildImageSrc(b.mediaType, b.data, b.url);
if (!src) return null;
return <ImageBlock key={`img-${i}`} src={src} alt="Image" />;
});
}
function renderInternalBlocks(blocks: MessageBlock[]) {
const elements: React.ReactElement[] = [];
const internals = getInternalBlocks(blocks);
@@ -201,7 +219,8 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
// Assistant message with no text content — only tool calls / thinking
if (!isUser && message.blocks.length > 0) {
const textBlocks = getTextBlocks(message.blocks);
const hasText = textBlocks.length > 0 || (message.isStreaming && message.content?.trim());
const imageBlocks = getImageBlocks(message.blocks);
const hasText = textBlocks.length > 0 || imageBlocks.length > 0 || (message.isStreaming && message.content?.trim());
if (!hasText && !message.isStreaming) {
return <InternalOnlyMessage message={message} />;
}
@@ -233,6 +252,9 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
</div>
)}
{/* Inline images */}
{renderImageBlocks(message.blocks)}
{/* Streaming dots */}
{message.isStreaming && (
<div className="flex gap-1 mt-2">