diff --git a/FEEDBACK.md b/FEEDBACK.md index 74ee5e2..4737e7f 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -38,7 +38,8 @@ ## Item #6 - **Date:** 2026-02-11 - **Priority:** high -- **Status:** pending +- **Status:** done +- **Completed:** 2026-02-11 — commit `5fd7300` - **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). - **docker-compose.yml** : exemple simple avec juste le container PinchChat @@ -87,3 +88,12 @@ - URL : `https://marlburrow.github.io/pinchchat/` - Ajouter un lien "Website" dans les settings du repo GitHub - 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 diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 446378a..dc1c9e4 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -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) { + return ; +} + +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 ; + }); +} + 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 ; } @@ -233,6 +252,9 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType }) )} + {/* Inline images */} + {renderImageBlocks(message.blocks)} + {/* Streaming dots */} {message.isStreaming && (
diff --git a/src/components/ImageBlock.tsx b/src/components/ImageBlock.tsx new file mode 100644 index 0000000..c238868 --- /dev/null +++ b/src/components/ImageBlock.tsx @@ -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 ( +
+ + {alt e.stopPropagation()} + /> +
+ ); +} + +export function ImageBlock({ src, alt }: ImageBlockProps) { + const [lightbox, setLightbox] = useState(false); + + return ( + <> +
+ {alt setLightbox(true)} + loading="lazy" + /> +
+ {lightbox && 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 ''; +} diff --git a/src/components/ToolCall.tsx b/src/components/ToolCall.tsx index b747470..69988b0 100644 --- a/src/components/ToolCall.tsx +++ b/src/components/ToolCall.tsx @@ -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 hljs from 'highlight.js/lib/common'; 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 }; @@ -148,6 +149,31 @@ function truncate(s: string, max: number): string { 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 }) { const t = useT(); const [open, setOpen] = useState(false); @@ -188,15 +214,30 @@ export function ToolCall({ name, input, result }: { name: string; input?: any; r />
)} - {result && ( -
-
{t('tool.result')}
- -
- )} + {result && (() => { + const imageData = extractImageFromResult(result); + return ( +
+
{t('tool.result')}
+ {imageData ? ( + <> + {imageData.remaining && ( + + )} + + + ) : ( + + )} +
+ ); + })()} )} diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index f9bb221..9847a1b 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -231,6 +231,13 @@ export function useGateway() { for (const block of m.content) { 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 === '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_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 }); diff --git a/src/types/index.ts b/src/types/index.ts index 63886f9..b97a9f1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,8 @@ export type MessageBlock = | { type: 'text'; text: string } | { type: 'thinking'; text: 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 { key: string;