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:
12
FEEDBACK.md
12
FEEDBACK.md
@@ -38,7 +38,8 @@
|
|||||||
## Item #6
|
## Item #6
|
||||||
- **Date:** 2026-02-11
|
- **Date:** 2026-02-11
|
||||||
- **Priority:** high
|
- **Priority:** high
|
||||||
- **Status:** pending
|
- **Status:** done
|
||||||
|
- **Completed:** 2026-02-11 — commit `5fd7300`
|
||||||
- **Description:** Installation simplifiée — Docker + oneliner
|
- **Description:** Installation simplifiée — Docker + oneliner
|
||||||
- **Dockerfile** : image légère (nginx:alpine ou similar) qui sert le build statique. Multi-stage : node pour build, nginx pour serve. Pas de secrets dans l'image (tout est runtime via le login screen).
|
- **Dockerfile** : image légère (nginx:alpine ou similar) qui sert le build statique. Multi-stage : node pour build, nginx pour serve. Pas de secrets dans l'image (tout est runtime via le login screen).
|
||||||
- **docker-compose.yml** : exemple simple avec juste le container PinchChat
|
- **docker-compose.yml** : exemple simple avec juste le container PinchChat
|
||||||
@@ -87,3 +88,12 @@
|
|||||||
- URL : `https://marlburrow.github.io/pinchchat/`
|
- URL : `https://marlburrow.github.io/pinchchat/`
|
||||||
- Ajouter un lien "Website" dans les settings du repo GitHub
|
- Ajouter un lien "Website" dans les settings du repo GitHub
|
||||||
- Ajouter le workflow GitHub Actions pour déployer automatiquement
|
- Ajouter le workflow GitHub Actions pour déployer automatiquement
|
||||||
|
|
||||||
|
## Item #10
|
||||||
|
- **Date:** 2026-02-11
|
||||||
|
- **Priority:** medium
|
||||||
|
- **Status:** pending
|
||||||
|
- **Description:** Remplacer le diagramme d'architecture ASCII art dans le README par un diagramme Mermaid
|
||||||
|
- GitHub rend nativement les blocs ```mermaid dans les README
|
||||||
|
- Utiliser un flowchart ou graph LR/TD montrant : Browser → WebSocket → OpenClaw Gateway → LLM Provider, avec les composants internes (LoginScreen, Chat, Sidebar, Gateway client, etc.)
|
||||||
|
- Plus lisible et maintenable que l'ASCII art
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
|||||||
import { ThinkingBlock } from './ThinkingBlock';
|
import { ThinkingBlock } from './ThinkingBlock';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
|
import { ImageBlock, buildImageSrc } from './ImageBlock';
|
||||||
import { Bot, User, Wrench } from 'lucide-react';
|
import { Bot, User, Wrench } from 'lucide-react';
|
||||||
import { t, getLocale } from '../lib/i18n';
|
import { t, getLocale } from '../lib/i18n';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
@@ -127,11 +128,19 @@ function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] {
|
|||||||
return blocks.filter(b => b.type === 'text' && b.text.trim());
|
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[] {
|
function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
|
||||||
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
|
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[]) {
|
function renderTextBlocks(blocks: MessageBlock[]) {
|
||||||
return getTextBlocks(blocks).map((block, i) => (
|
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[]) {
|
function renderInternalBlocks(blocks: MessageBlock[]) {
|
||||||
const elements: React.ReactElement[] = [];
|
const elements: React.ReactElement[] = [];
|
||||||
const internals = getInternalBlocks(blocks);
|
const internals = getInternalBlocks(blocks);
|
||||||
@@ -201,7 +219,8 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
|
|||||||
// Assistant message with no text content — only tool calls / thinking
|
// Assistant message with no text content — only tool calls / thinking
|
||||||
if (!isUser && message.blocks.length > 0) {
|
if (!isUser && message.blocks.length > 0) {
|
||||||
const textBlocks = getTextBlocks(message.blocks);
|
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) {
|
if (!hasText && !message.isStreaming) {
|
||||||
return <InternalOnlyMessage message={message} />;
|
return <InternalOnlyMessage message={message} />;
|
||||||
}
|
}
|
||||||
@@ -233,6 +252,9 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline images */}
|
||||||
|
{renderImageBlocks(message.blocks)}
|
||||||
|
|
||||||
{/* Streaming dots */}
|
{/* Streaming dots */}
|
||||||
{message.isStreaming && (
|
{message.isStreaming && (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
|
|||||||
64
src/components/ImageBlock.tsx
Normal file
64
src/components/ImageBlock.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ImageBlockProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Lightbox({ src, alt, onClose }: ImageBlockProps & { onClose: () => void }) {
|
||||||
|
const handleKey = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
|
}, [handleKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800/80 border border-white/10 text-zinc-300 hover:text-white hover:bg-zinc-700/80 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || 'Image'}
|
||||||
|
className="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageBlock({ src, alt }: ImageBlockProps) {
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="my-2">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || 'Image'}
|
||||||
|
className="max-w-full max-h-80 rounded-xl border border-white/8 cursor-pointer hover:brightness-110 transition-all"
|
||||||
|
onClick={() => setLightbox(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{lightbox && <Lightbox src={src} alt={alt} onClose={() => setLightbox(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a data URL from base64 image data */
|
||||||
|
export function buildImageSrc(mediaType: string, data?: string, url?: string): string {
|
||||||
|
if (url) return url;
|
||||||
|
if (data) return `data:${mediaType};base64,${data}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
|||||||
import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-react';
|
||||||
import hljs from 'highlight.js/lib/common';
|
import hljs from 'highlight.js/lib/common';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { ImageBlock } from './ImageBlock';
|
||||||
|
|
||||||
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
||||||
|
|
||||||
@@ -148,6 +149,31 @@ function truncate(s: string, max: number): string {
|
|||||||
return clean.length <= max ? clean : clean.slice(0, max) + '…';
|
return clean.length <= max ? clean : clean.slice(0, max) + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detect if a tool result contains a base64 image and extract it */
|
||||||
|
function extractImageFromResult(result: string): { src: string; remaining: string } | null {
|
||||||
|
if (!result) return null;
|
||||||
|
// Match "data:image/..." URLs
|
||||||
|
const dataUrlMatch = result.match(/(data:image\/[a-z+]+;base64,[A-Za-z0-9+/=\s]+)/);
|
||||||
|
if (dataUrlMatch) {
|
||||||
|
const src = dataUrlMatch[1].replace(/\s/g, '');
|
||||||
|
const remaining = result.replace(dataUrlMatch[0], '').trim();
|
||||||
|
return { src, remaining };
|
||||||
|
}
|
||||||
|
// Match raw base64 after image file markers (e.g. from Read tool returning an image)
|
||||||
|
const readImageMatch = result.match(/^.*?\[image\/(png|jpeg|jpg|gif|webp)\].*$/m);
|
||||||
|
if (readImageMatch) {
|
||||||
|
const mediaType = `image/${readImageMatch[1]}`;
|
||||||
|
// Look for a large base64 block after it
|
||||||
|
const afterMarker = result.slice(result.indexOf(readImageMatch[0]) + readImageMatch[0].length);
|
||||||
|
const b64Match = afterMarker.match(/([A-Za-z0-9+/=\n]{100,})/);
|
||||||
|
if (b64Match) {
|
||||||
|
const data = b64Match[1].replace(/\n/g, '');
|
||||||
|
return { src: `data:${mediaType};base64,${data}`, remaining: readImageMatch[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -188,15 +214,30 @@ export function ToolCall({ name, input, result }: { name: string; input?: any; r
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{result && (() => {
|
||||||
<div>
|
const imageData = extractImageFromResult(result);
|
||||||
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>{t('tool.result')}</div>
|
return (
|
||||||
<HighlightedPre
|
<div>
|
||||||
text={result}
|
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>{t('tool.result')}</div>
|
||||||
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono"
|
{imageData ? (
|
||||||
/>
|
<>
|
||||||
</div>
|
{imageData.remaining && (
|
||||||
)}
|
<HighlightedPre
|
||||||
|
text={imageData.remaining}
|
||||||
|
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 font-mono mb-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ImageBlock src={imageData.src} alt={`${name} result`} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<HighlightedPre
|
||||||
|
text={result}
|
||||||
|
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ export function useGateway() {
|
|||||||
for (const block of m.content) {
|
for (const block of m.content) {
|
||||||
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
|
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
|
||||||
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
|
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
|
||||||
|
else if (block.type === 'image') {
|
||||||
|
const src = block.source || {};
|
||||||
|
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
|
||||||
|
}
|
||||||
|
else if (block.type === 'image_url') {
|
||||||
|
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
|
||||||
|
}
|
||||||
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
|
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
|
||||||
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
|
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
|
||||||
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
|
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export type MessageBlock =
|
|||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'tool_use'; name: string; input: any; id?: string }
|
| { type: 'tool_use'; name: string; input: any; id?: string }
|
||||||
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string };
|
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string }
|
||||||
|
| { type: 'image'; mediaType: string; data?: string; url?: string };
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user