chat: render images in a turn, and stop mangling the ones that were already links
Remote images were broken in the web chat and the failure looked like a network fault. It was a rewrite. injectImages scans a turn for absolute file paths ending in an image extension and points them at the file browser — good behaviour, since an agent that writes /tmp/shot.png should show it rather than make you go and open it. But 'https://host/images/duck.png' CONTAINS '/images/duck.png', so a perfectly good markdown image became  scheme stripped, host read as a directory. Every remote image failed. injectImages now skips regions that are already links — !?[..](..) alongside the code fences it already skipped — and refuses any match preceded by ':' or '/', or starting with '//', since those are the tail or the whole of a URL somebody wrote deliberately. Moved to chat-images.ts with tests, because a regex with three exceptions is exactly the thing that regresses silently. Rendering added with it: markdown images now go through ChatImage, capped at 14rem HIGH — not wide, so a wide screenshot and a tall phone capture take comparable room — and open full size in a dialog on click. A failure names the alt text and the URL, because the useful question is always which host did not answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
/**
|
||||
* An image inside a chat turn: a thumbnail in the bubble, full size when clicked.
|
||||
*
|
||||
* A conversation is made of words, so a picture in one is a reference to something rather than the
|
||||
* thing itself — capped small enough that a screenshot cannot push the reply off the screen, and
|
||||
* opened properly when you actually want to look at it.
|
||||
*
|
||||
* Capped by HEIGHT rather than width, deliberately: a wide screenshot and a tall phone capture then
|
||||
* take comparable room, instead of one of them owning the pane. Same rule the mobile client uses.
|
||||
*/
|
||||
export const ChatImage = ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
/*
|
||||
A broken image says which one and where from.
|
||||
|
||||
These URLs come from the model or from a tool, and the interesting question when one fails is
|
||||
almost always WHICH host did not answer — a machine that is asleep, a tailnet that is down, a link
|
||||
that expired. A broken-image icon answers none of that.
|
||||
*/
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="inline-block rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
Image could not be loaded{alt ? `: ${alt}` : ''}
|
||||
<span className="block truncate opacity-60 max-w-[28rem]">{src}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
className="max-h-56 w-auto max-w-full rounded-lg border border-border/60 my-1 cursor-zoom-in hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{/* No chrome and no padding: the picture is the content, and a header would take height from
|
||||
it on the one screen where height is what you came for. */}
|
||||
<DialogContent className="max-w-[95vw] w-auto p-0 bg-transparent border-0 shadow-none">
|
||||
<DialogTitle className="sr-only">{alt || 'Image'}</DialogTitle>
|
||||
<img src={src} alt={alt ?? ''} className="max-h-[90vh] max-w-[95vw] w-auto rounded-lg" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -27,10 +27,12 @@ import { getRawUrl } from '../../FileViewer/file-types';
|
||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { ChatImage } from './ChatImage';
|
||||
import { injectImages } from './chat-images';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
|
||||
/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
|
||||
const MD_COMPONENTS = { pre: CodeBlock };
|
||||
const MD_COMPONENTS = { pre: CodeBlock, img: ChatImage };
|
||||
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
@@ -43,29 +45,6 @@ const sanitizeSchema = {
|
||||
},
|
||||
};
|
||||
|
||||
// Matches absolute image file paths, e.g. /home/user/pic.png or /tmp/photo.jpg
|
||||
const IMAGE_PATH_RE = /(\/(?:home\/[^/\s]+\/)?[^\s`"'<>\n\r[\]()]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|ico))/gi;
|
||||
|
||||
function pathToImageUrl(filePath: string): string {
|
||||
// Strip /home/{username} prefix — the file browser root=home serves from the user's home dir
|
||||
const relative = filePath.replace(/^\/home\/[^/]+/, '') || '/';
|
||||
return getRawUrl(relative, 'home');
|
||||
}
|
||||
|
||||
function injectImages(text: string): string {
|
||||
// Split on fenced code blocks and inline code so we don't touch paths inside backticks
|
||||
const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part; // inside code — leave untouched
|
||||
return part.replace(IMAGE_PATH_RE, (match) => {
|
||||
const filename = match.split('/').pop() ?? 'image';
|
||||
return `})`;
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
|
||||
|
||||
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test, mock } from 'bun:test';
|
||||
|
||||
mock.module('../../FileViewer/file-types', () => ({
|
||||
getRawUrl: (path: string, root: string) => `/api/file-browser/raw?path=${path}&root=${root}`,
|
||||
}));
|
||||
|
||||
const { injectImages } = await import('./chat-images');
|
||||
|
||||
test('leaves an existing remote markdown image untouched', () => {
|
||||
const src = '';
|
||||
expect(injectImages(src)).toBe(src);
|
||||
});
|
||||
|
||||
test('leaves a bare remote URL untouched', () => {
|
||||
const src = 'see https://oas.officer.dev/preview/textcmp.png for the comparison';
|
||||
expect(injectImages(src)).toBe(src);
|
||||
});
|
||||
|
||||
test('leaves a protocol-relative URL untouched', () => {
|
||||
const src = 'at //host.example/images/x.png there';
|
||||
expect(injectImages(src)).toBe(src);
|
||||
});
|
||||
|
||||
test('still turns a bare local path into an image', () => {
|
||||
expect(injectImages('here it is /home/pastilhas/shot.png ok')).toContain(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves paths inside inline code alone', () => {
|
||||
const src = 'run `magick /tmp/a.png out.png` now';
|
||||
expect(injectImages(src)).toBe(src);
|
||||
});
|
||||
|
||||
test('leaves paths inside a fenced block alone', () => {
|
||||
const src = '```\ncp /tmp/a.png /tmp/b.png\n```';
|
||||
expect(injectImages(src)).toBe(src);
|
||||
});
|
||||
|
||||
test('handles a local path and a remote image in the same message', () => {
|
||||
const out = injectImages('local /tmp/a.png and ');
|
||||
expect(out).toContain('');
|
||||
expect(out).toContain('');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getRawUrl } from '../../FileViewer/file-types';
|
||||
|
||||
/**
|
||||
* Turning bare file paths in a turn into images, without touching anything that is already a link.
|
||||
*
|
||||
* An agent that has just written a screenshot says so in prose — "saved to /tmp/shot.png" — and
|
||||
* showing it beats making you go and open it. That is what injectImages is for.
|
||||
*/
|
||||
|
||||
/** Absolute image file paths, e.g. `/home/user/pic.png` or `/tmp/photo.jpg`. */
|
||||
export const IMAGE_PATH_RE = /(\/(?:home\/[^/\s]+\/)?[^\s`"'<>\n\r[\]()]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|ico))/gi;
|
||||
|
||||
/*
|
||||
Regions to leave alone: fenced code, inline code, and markdown links or images that ALREADY exist —
|
||||
`[text](url)` and ``.
|
||||
|
||||
That last one is not hypothetical. A URL like `https://host/images/duck.png` contains
|
||||
`/images/duck.png`, which IMAGE_PATH_RE matches happily, so a perfectly good markdown image was
|
||||
rewritten into `` — scheme stripped,
|
||||
host read as a directory. Every remote image in chat broke, and it looked like a network fault
|
||||
rather than a rewrite, which is why it survived as long as it did.
|
||||
*/
|
||||
export const SKIP_RE = /(```[\s\S]*?```|`[^`\n]+`|!?\[[^\]]*\]\([^)]*\))/g;
|
||||
|
||||
/** Strip `/home/{username}` — the file browser's `home` root already serves from there. */
|
||||
export function pathToImageUrl(filePath: string): string {
|
||||
const relative = filePath.replace(/^\/home\/[^/]+/, '') || '/';
|
||||
return getRawUrl(relative, 'home');
|
||||
}
|
||||
|
||||
export function injectImages(text: string): string {
|
||||
return text
|
||||
.split(SKIP_RE)
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part; // code, or a link somebody already wrote
|
||||
return part.replace(IMAGE_PATH_RE, (match, _g, offset: number, whole: string) => {
|
||||
// A bare path only. Two ways it can be part of a URL instead:
|
||||
// preceded by `:` or `/` — the tail of `https://host/x.png`
|
||||
// starting with `//` — a protocol-relative `//host/x.png`, matched whole
|
||||
// Either way it belongs to whoever wrote it, not to the file browser.
|
||||
const before = whole[offset - 1];
|
||||
if (before === ':' || before === '/' || match.startsWith('//')) return match;
|
||||
const filename = match.split('/').pop() ?? 'image';
|
||||
return `})`;
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user