feat: add markdown image gallery previews
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
|
import type { ToolPopupContent } from './message/types';
|
||||||
|
import {
|
||||||
|
extractMarkdownImageCandidates,
|
||||||
|
MAX_MARKDOWN_IMAGE_COUNT,
|
||||||
|
type MarkdownImageCandidate,
|
||||||
|
} from './markdown/markdownCore';
|
||||||
|
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||||
|
|
||||||
|
const MarkdownImageThumbnail: React.FC<{
|
||||||
|
candidate: MarkdownImageCandidate;
|
||||||
|
directory: string;
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
}> = ({ candidate, directory, onShowPopup }) => {
|
||||||
|
const [image, setImage] = React.useState<{
|
||||||
|
url: string;
|
||||||
|
status: 'loading' | 'ready' | 'error';
|
||||||
|
}>({ url: '', status: 'loading' });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setImage({ url: '', status: 'loading' });
|
||||||
|
void resolveMarkdownImageSource(candidate.source, directory, controller.signal)
|
||||||
|
.then((url) => {
|
||||||
|
if (!controller.signal.aborted) setImage({ url, status: 'loading' });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) setImage({ url: '', status: 'error' });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [candidate.source, directory]);
|
||||||
|
|
||||||
|
const openPreview = React.useCallback(() => {
|
||||||
|
if (image.status !== 'ready' || !onShowPopup) return;
|
||||||
|
onShowPopup({
|
||||||
|
open: true,
|
||||||
|
title: candidate.filename,
|
||||||
|
content: '',
|
||||||
|
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
|
||||||
|
image: { url: image.url, filename: candidate.filename },
|
||||||
|
});
|
||||||
|
}, [candidate.filename, image, onShowPopup]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||||
|
aria-label={candidate.filename}
|
||||||
|
disabled={image.status !== 'ready'}
|
||||||
|
onClick={openPreview}
|
||||||
|
data-openchamber-markdown-image-action="true"
|
||||||
|
data-openchamber-markdown-image-source={candidate.source}
|
||||||
|
data-openchamber-markdown-image-filename={candidate.filename}
|
||||||
|
>
|
||||||
|
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
|
||||||
|
{image.url && image.status !== 'error' ? (
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={candidate.filename}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
|
||||||
|
onError={() => setImage({ url: '', status: 'error' })}
|
||||||
|
data-openchamber-markdown-image="true"
|
||||||
|
data-openchamber-markdown-image-thumbnail="true"
|
||||||
|
data-openchamber-markdown-image-state={image.status}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
|
||||||
|
title={candidate.filename}
|
||||||
|
data-openchamber-markdown-image-caption="true"
|
||||||
|
>
|
||||||
|
<Icon name="file-image" className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownImageGallery: React.FC<{
|
||||||
|
contents: readonly string[];
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
}> = ({ contents, onShowPopup }) => {
|
||||||
|
const directory = useEffectiveDirectory() ?? '';
|
||||||
|
const candidates = React.useMemo(
|
||||||
|
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
|
||||||
|
[contents],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
|
||||||
|
data-openchamber-markdown-image-gallery="true"
|
||||||
|
>
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<MarkdownImageThumbnail
|
||||||
|
key={candidate.source}
|
||||||
|
candidate={candidate}
|
||||||
|
directory={directory}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
|||||||
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
|
||||||
|
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
|
||||||
|
);
|
||||||
|
|
||||||
const fallback = <div className="break-words w-full min-w-0" />;
|
const fallback = <div className="break-words w-full min-w-0" />;
|
||||||
|
|
||||||
const fallbackContentClassName = (variant: unknown): string => {
|
const fallbackContentClassName = (variant: unknown): string => {
|
||||||
@@ -48,3 +52,9 @@ export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typ
|
|||||||
<SimpleMarkdownRendererLazy {...props} />
|
<SimpleMarkdownRendererLazy {...props} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<MarkdownImageGalleryLazy {...props} />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
|
|||||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
import { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||||
|
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||||
import {
|
import {
|
||||||
@@ -107,6 +108,59 @@ const useExternalLinkInteractions = ({
|
|||||||
}, [containerRef, enabled]);
|
}, [containerRef, enabled]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useMarkdownImageLinkInteractions = ({
|
||||||
|
containerRef,
|
||||||
|
directory,
|
||||||
|
enabled,
|
||||||
|
onShowPopup,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
directory: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
}) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!enabled || !container || !onShowPopup) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented || event.button !== 0) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
|
||||||
|
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
|
||||||
|
if (!link || !container.contains(link)) return;
|
||||||
|
|
||||||
|
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
|
||||||
|
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|
||||||
|
|| getMarkdownImageFilename(source, '');
|
||||||
|
if (!source || !filename) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void resolveMarkdownImageSource(source, directory, controller.signal)
|
||||||
|
.then((url) => {
|
||||||
|
if (controller.signal.aborted || !link.isConnected) return;
|
||||||
|
onShowPopup({
|
||||||
|
open: true,
|
||||||
|
title: filename,
|
||||||
|
content: '',
|
||||||
|
metadata: { tool: 'markdown-image-preview', filename },
|
||||||
|
image: { url, filename },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
container.removeEventListener('click', handleClick);
|
||||||
|
};
|
||||||
|
}, [containerRef, directory, enabled, onShowPopup]);
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
||||||
download: true,
|
download: true,
|
||||||
copy: true,
|
copy: true,
|
||||||
@@ -140,6 +194,7 @@ interface MarkdownRendererProps {
|
|||||||
variant?: MarkdownVariant;
|
variant?: MarkdownVariant;
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
enableFileReferences?: boolean;
|
enableFileReferences?: boolean;
|
||||||
|
enableLocalImages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||||
@@ -497,6 +552,10 @@ const useFileReferenceInteractions = ({
|
|||||||
let linkedCount = 0;
|
let linkedCount = 0;
|
||||||
|
|
||||||
for (const candidate of Array.from(candidates)) {
|
for (const candidate of Array.from(candidates)) {
|
||||||
|
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
|
||||||
|
clearFileLinkAttributes(candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const rawCandidate = extractPathCandidateFromElement(candidate);
|
const rawCandidate = extractPathCandidateFromElement(candidate);
|
||||||
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
||||||
clearFileLinkAttributes(candidate);
|
clearFileLinkAttributes(candidate);
|
||||||
@@ -831,6 +890,7 @@ const useMorphdomMarkdown = ({
|
|||||||
text,
|
text,
|
||||||
streaming,
|
streaming,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
|
deferImages = false,
|
||||||
syntaxVars,
|
syntaxVars,
|
||||||
ctx,
|
ctx,
|
||||||
}: {
|
}: {
|
||||||
@@ -838,6 +898,7 @@ const useMorphdomMarkdown = ({
|
|||||||
text: string;
|
text: string;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
|
deferImages?: boolean;
|
||||||
syntaxVars: Record<string, string>;
|
syntaxVars: Record<string, string>;
|
||||||
ctx: DecorateContext;
|
ctx: DecorateContext;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -876,7 +937,7 @@ const useMorphdomMarkdown = ({
|
|||||||
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
||||||
// HTML body — the wrapper exists only for per-block reconciliation.
|
// HTML body — the wrapper exists only for per-block reconciliation.
|
||||||
block.style.display = 'contents';
|
block.style.display = 'contents';
|
||||||
block.innerHTML = renderMarkdownSync(text);
|
block.innerHTML = renderMarkdownSync(text, deferImages);
|
||||||
// Decorate synchronously too: wrap code blocks in their framed card,
|
// Decorate synchronously too: wrap code blocks in their framed card,
|
||||||
// mark inline code, build table controls, etc. The async pass re-decorates
|
// mark inline code, build table controls, etc. The async pass re-decorates
|
||||||
// its own DOM before morphing, so without this the first paint shows bare
|
// its own DOM before morphing, so without this the first paint shows bare
|
||||||
@@ -888,7 +949,7 @@ const useMorphdomMarkdown = ({
|
|||||||
refreshMermaidViewers();
|
refreshMermaidViewers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [containerRef, text, ctx, refreshMermaidViewers]);
|
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
|
||||||
|
|
||||||
React.useEffect(() => () => {
|
React.useEffect(() => () => {
|
||||||
mermaidViewerRef.current?.cleanup();
|
mermaidViewerRef.current?.cleanup();
|
||||||
@@ -901,7 +962,7 @@ const useMorphdomMarkdown = ({
|
|||||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
|
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const existing = Array.from(target.children) as HTMLElement[];
|
const existing = Array.from(target.children) as HTMLElement[];
|
||||||
|
|
||||||
@@ -952,7 +1013,7 @@ const useMorphdomMarkdown = ({
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
|
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -999,6 +1060,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
variant = 'assistant',
|
variant = 'assistant',
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
enableFileReferences = true,
|
enableFileReferences = true,
|
||||||
|
enableLocalImages = false,
|
||||||
}) => {
|
}) => {
|
||||||
streamPerfCount('ui.markdown_renderer.render');
|
streamPerfCount('ui.markdown_renderer.render');
|
||||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||||
@@ -1030,15 +1092,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
enabled: enableFileReferences && !isStreaming,
|
enabled: enableFileReferences && !isStreaming,
|
||||||
});
|
});
|
||||||
useExternalLinkInteractions({ containerRef });
|
useExternalLinkInteractions({ containerRef });
|
||||||
|
useMarkdownImageLinkInteractions({
|
||||||
|
containerRef,
|
||||||
|
directory: effectiveDirectory,
|
||||||
|
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||||
|
onShowPopup,
|
||||||
|
});
|
||||||
|
|
||||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||||
|
|
||||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
useMorphdomMarkdown({
|
||||||
|
containerRef,
|
||||||
|
text: content,
|
||||||
|
streaming: live,
|
||||||
|
cacheKey,
|
||||||
|
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||||
|
syntaxVars,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
|
||||||
const markdownContent = (
|
const markdownContent = (
|
||||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
<div
|
||||||
|
className={cn('break-words w-full min-w-0', className)}
|
||||||
|
ref={containerRef}
|
||||||
|
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
|
||||||
|
>
|
||||||
<div className={markdownContentClassName(variant)} data-markdown-content />
|
<div className={markdownContentClassName(variant)} data-markdown-content />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1065,6 +1145,7 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
|
|||||||
&& prev.messageId === next.messageId
|
&& prev.messageId === next.messageId
|
||||||
&& prev.onShowPopup === next.onShowPopup
|
&& prev.onShowPopup === next.onShowPopup
|
||||||
&& prev.enableFileReferences === next.enableFileReferences
|
&& prev.enableFileReferences === next.enableFileReferences
|
||||||
|
&& prev.enableLocalImages === next.enableLocalImages
|
||||||
&& prev.part?.id === next.part?.id;
|
&& prev.part?.id === next.part?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
mock.module('dompurify', () => ({
|
||||||
|
default: {
|
||||||
|
isSupported: true,
|
||||||
|
addHook: () => undefined,
|
||||||
|
sanitize: (html: string) => html,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module('./markdown-worker', () => ({
|
||||||
|
highlightCodeInWorker: async () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||||
|
|
||||||
|
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
|
||||||
|
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||||
|
|
||||||
describe('markdown sanitization', () => {
|
describe('markdown sanitization', () => {
|
||||||
test('turns raw assistant HTML into inert visible text', () => {
|
test('turns raw assistant HTML into inert visible text', () => {
|
||||||
const payload = '<style>@import url("https://example.test/theme.css");</style>';
|
const payload = '<style>@import url("https://example.test/theme.css");</style>';
|
||||||
@@ -16,3 +30,102 @@ describe('markdown sanitization', () => {
|
|||||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Markdown images', () => {
|
||||||
|
test('keeps local image links in text and emits inert image placeholders', () => {
|
||||||
|
const html = renderMarkdownSync([
|
||||||
|
'[linked image](packages/vscode/extension.jpg)',
|
||||||
|
'',
|
||||||
|
].join('\n\n'), true);
|
||||||
|
|
||||||
|
expect(html).toContain('data-openchamber-markdown-image-link="true"');
|
||||||
|
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
|
||||||
|
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||||
|
expect(html).toContain('image syntax');
|
||||||
|
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
|
||||||
|
expect(html).not.toContain('data-openchamber-markdown-image-state');
|
||||||
|
expect(html.match(/<a /g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps HTTP links as links and defers remote image tokens to finalized rendering', () => {
|
||||||
|
const html = renderMarkdownSync([
|
||||||
|
'[remote link](https://example.test/image.png)',
|
||||||
|
'',
|
||||||
|
].join('\n\n'), true);
|
||||||
|
|
||||||
|
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||||
|
expect(html).not.toContain('<img');
|
||||||
|
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||||
|
expect(html).toContain('remote image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves file URLs inertly and never activates unknown schemes', () => {
|
||||||
|
const html = renderMarkdownSync([
|
||||||
|
'',
|
||||||
|
')',
|
||||||
|
].join('\n\n'), true);
|
||||||
|
|
||||||
|
expect(html).toContain('role="img"');
|
||||||
|
expect(html).not.toContain('src="file:');
|
||||||
|
expect(html).not.toContain('src="javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
|
||||||
|
const candidates = extractMarkdownImageCandidates([
|
||||||
|
[
|
||||||
|
'Before [local link](screens/first%20view.png) and ``.',
|
||||||
|
'',
|
||||||
|
'- ',
|
||||||
|
'- ',
|
||||||
|
'',
|
||||||
|
'```md',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
].join('\n'),
|
||||||
|
'After .',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(candidates).toEqual([
|
||||||
|
{ source: 'screens/first%20view.png', filename: 'first view.png' },
|
||||||
|
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
|
||||||
|
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limits one finalized message gallery to twelve unique candidates', () => {
|
||||||
|
const markdown = Array.from({ length: 14 }, (_, index) => ``).join('\n');
|
||||||
|
|
||||||
|
const candidates = extractMarkdownImageCandidates([markdown]);
|
||||||
|
|
||||||
|
expect(candidates).toHaveLength(12);
|
||||||
|
expect(candidates.at(-1)?.source).toBe('screens/11.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates embedded image bytes against the declared MIME type', async () => {
|
||||||
|
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
|
||||||
|
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
|
||||||
|
() => { throw new Error('Expected mismatched image data to fail'); },
|
||||||
|
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not resolve images after cancellation', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
|
||||||
|
() => { throw new Error('Expected an aborted image load to fail'); },
|
||||||
|
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the existing image renderer outside finalized assistant text', () => {
|
||||||
|
const html = renderMarkdownSync('');
|
||||||
|
|
||||||
|
expect(html).toContain('<img src="https://example.test/image.png"');
|
||||||
|
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { marked, type Tokens } from 'marked';
|
import { Marked, marked, type Tokens } from 'marked';
|
||||||
import remend from 'remend';
|
import remend from 'remend';
|
||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
@@ -10,6 +10,95 @@ import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecuri
|
|||||||
const escapeAttr = (value: string): string =>
|
const escapeAttr = (value: string): string =>
|
||||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||||
|
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
||||||
|
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
|
||||||
|
|
||||||
|
export interface MarkdownImageCandidate {
|
||||||
|
source: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||||
|
|
||||||
|
const isLocalMarkdownImageSource = (source: string): boolean => {
|
||||||
|
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
|
||||||
|
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|
||||||
|
|| /^file:\/\//i.test(source)
|
||||||
|
|| !URL_SCHEME_RE.test(source);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSupportedMarkdownImageSource = (source: string): boolean => (
|
||||||
|
/^(?:https?:)?\/\//i.test(source)
|
||||||
|
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||||
|
|| isLocalMarkdownImageSource(source)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||||
|
if (/^data:/i.test(source)) return fallback.trim();
|
||||||
|
|
||||||
|
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
|
||||||
|
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
|
||||||
|
if (!encodedName) return fallback.trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(encodedName);
|
||||||
|
} catch {
|
||||||
|
return encodedName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractMarkdownImageCandidates = (
|
||||||
|
markdownTexts: readonly string[],
|
||||||
|
limit = MAX_MARKDOWN_IMAGE_COUNT,
|
||||||
|
): MarkdownImageCandidate[] => {
|
||||||
|
if (limit <= 0) return [];
|
||||||
|
|
||||||
|
const candidates: MarkdownImageCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const markdown of markdownTexts) {
|
||||||
|
if (!markdown || candidates.length >= limit) continue;
|
||||||
|
const tokens = marked.lexer(markdown);
|
||||||
|
marked.walkTokens(tokens, (token) => {
|
||||||
|
if (candidates.length >= limit) return;
|
||||||
|
|
||||||
|
if (token.type !== 'image' && token.type !== 'link') return;
|
||||||
|
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) return;
|
||||||
|
|
||||||
|
const source = token.href ?? '';
|
||||||
|
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||||
|
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||||
|
const filename = getMarkdownImageFilename(source, fallback);
|
||||||
|
if (!filename) return;
|
||||||
|
|
||||||
|
seen.add(source);
|
||||||
|
candidates.push({ source, filename });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMarkdownImage = ({
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
title?: string | null;
|
||||||
|
text: string;
|
||||||
|
}): string => {
|
||||||
|
const source = href ?? '';
|
||||||
|
const alt = escapeAttr(text ?? '');
|
||||||
|
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||||
|
const supported = isSupportedMarkdownImageSource(source);
|
||||||
|
if (!supported) {
|
||||||
|
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -162,7 +251,7 @@ const blockMathExtension = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const parser = marked.use({
|
const createParser = (deferImages: boolean) => new Marked().use({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: false,
|
breaks: false,
|
||||||
extensions: [inlineMathExtension, blockMathExtension],
|
extensions: [inlineMathExtension, blockMathExtension],
|
||||||
@@ -175,6 +264,11 @@ const parser = marked.use({
|
|||||||
},
|
},
|
||||||
link({ href, title, text }) {
|
link({ href, title, text }) {
|
||||||
const target = href ?? '';
|
const target = href ?? '';
|
||||||
|
if (deferImages && isLocalMarkdownImageSource(target)) {
|
||||||
|
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||||
|
const filename = getMarkdownImageFilename(target, '');
|
||||||
|
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
|
||||||
|
}
|
||||||
const agentName = parseAgentHref(target);
|
const agentName = parseAgentHref(target);
|
||||||
if (agentName) {
|
if (agentName) {
|
||||||
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||||
@@ -186,9 +280,13 @@ const parser = marked.use({
|
|||||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||||
},
|
},
|
||||||
|
...(deferImages ? { image: renderMarkdownImage } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parser = createParser(false);
|
||||||
|
const imageParser = createParser(true);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -341,8 +439,8 @@ const touch = (key: string, entry: { hash: string; html: string }): void => {
|
|||||||
if (oldest) htmlCache.delete(oldest);
|
if (oldest) htmlCache.delete(oldest);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
|
||||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
|
||||||
const withMath = renderMathExpressions(parsed);
|
const withMath = renderMathExpressions(parsed);
|
||||||
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
||||||
return sanitize(highlighted);
|
return sanitize(highlighted);
|
||||||
@@ -357,9 +455,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
|||||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||||
* worker round-trip.
|
* worker round-trip.
|
||||||
*/
|
*/
|
||||||
export const renderMarkdownSync = (text: string): string => {
|
export const renderMarkdownSync = (text: string, deferImages = false): string => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const parsed = parser.parse(text) as string;
|
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
|
||||||
const withMath = renderMathExpressions(parsed);
|
const withMath = renderMathExpressions(parsed);
|
||||||
return sanitize(withMath);
|
return sanitize(withMath);
|
||||||
};
|
};
|
||||||
@@ -382,6 +480,7 @@ export const renderMarkdownBlocks = async (
|
|||||||
text: string,
|
text: string,
|
||||||
streaming: boolean,
|
streaming: boolean,
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
|
deferImages = false,
|
||||||
): Promise<RenderedBlock[]> => {
|
): Promise<RenderedBlock[]> => {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|
||||||
@@ -389,14 +488,14 @@ export const renderMarkdownBlocks = async (
|
|||||||
return Promise.all(
|
return Promise.all(
|
||||||
blocks.map(async (block, index) => {
|
blocks.map(async (block, index) => {
|
||||||
const contentHash = hash(block.raw);
|
const contentHash = hash(block.raw);
|
||||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
|
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
|
||||||
const key = `${cacheKey}:${index}:${block.mode}`;
|
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
|
||||||
const cached = htmlCache.get(key);
|
const cached = htmlCache.get(key);
|
||||||
if (cached && cached.hash === contentHash) {
|
if (cached && cached.hash === contentHash) {
|
||||||
touch(key, cached);
|
touch(key, cached);
|
||||||
return { id, html: cached.html };
|
return { id, html: cached.html };
|
||||||
}
|
}
|
||||||
const html = await parseBlock(block);
|
const html = await parseBlock(block, deferImages);
|
||||||
touch(key, { hash: contentHash, html });
|
touch(key, { hash: contentHash, html });
|
||||||
return { id, html };
|
return { id, html };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
|
||||||
|
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parseLocalImagePath = (source: string): string => {
|
||||||
|
let value = source;
|
||||||
|
if (/^file:\/\//i.test(value)) {
|
||||||
|
try {
|
||||||
|
const fileUrl = new URL(value);
|
||||||
|
if (fileUrl.protocol !== 'file:') return '';
|
||||||
|
value = fileUrl.host && fileUrl.host !== 'localhost'
|
||||||
|
? `//${fileUrl.host}${fileUrl.pathname}`
|
||||||
|
: fileUrl.pathname;
|
||||||
|
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = value.split(/[?#]/, 1)[0] ?? '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(path);
|
||||||
|
} catch {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => typeof reader.result === 'string'
|
||||||
|
? resolve(reader.result)
|
||||||
|
: reject(new Error('Unable to encode image'));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
|
||||||
|
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
||||||
|
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
|
||||||
|
if (mimeType === 'image/png') {
|
||||||
|
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||||
|
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||||
|
}
|
||||||
|
if (mimeType === 'image/jpeg') {
|
||||||
|
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||||
|
}
|
||||||
|
if (mimeType === 'image/gif') {
|
||||||
|
const gif = ascii(0, 6);
|
||||||
|
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||||
|
}
|
||||||
|
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
|
||||||
|
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
|
||||||
|
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
|
||||||
|
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDataImage = async (source: string): Promise<void> => {
|
||||||
|
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
|
||||||
|
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
|
||||||
|
const encoded = match[2];
|
||||||
|
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
|
||||||
|
throw new Error('Image is too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
let binary: string;
|
||||||
|
try {
|
||||||
|
binary = atob(encoded);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid image data URL');
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||||
|
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveMarkdownImageSource = async (
|
||||||
|
source: string,
|
||||||
|
directory: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||||
|
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||||
|
|
||||||
|
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||||
|
await validateDataImage(source);
|
||||||
|
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPath = parseLocalImagePath(source);
|
||||||
|
const absolutePath = toAbsoluteFilePath(directory, localPath);
|
||||||
|
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
|
||||||
|
throw new Error('Image path is outside the active workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statResponse = await runtimeFetch('/api/fs/stat', {
|
||||||
|
query: { path: absolutePath, directory, optional: 'true' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
|
||||||
|
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
|
||||||
|
if (!stat.isFile) throw new Error('Image path is not a file');
|
||||||
|
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||||
|
throw new Error('Image is too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await runtimeFetch('/api/fs/raw', {
|
||||||
|
query: { path: absolutePath, directory },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
|
||||||
|
|
||||||
|
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
|
||||||
|
const contentLength = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||||
|
throw new Error('Image is too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
await validateImageBlob(blob, mimeType);
|
||||||
|
return blobToDataUrl(blob);
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||||
|
|
||||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
|
||||||
@@ -1211,6 +1211,11 @@ const AssistantMessageBody = React.memo(({
|
|||||||
const assistantTextParts = React.useMemo(() => {
|
const assistantTextParts = React.useMemo(() => {
|
||||||
return visibleParts.filter((part) => part.type === 'text');
|
return visibleParts.filter((part) => part.type === 'text');
|
||||||
}, [visibleParts]);
|
}, [visibleParts]);
|
||||||
|
const finalizedAssistantMarkdownContents = React.useMemo(() => (
|
||||||
|
isMessageCompleted
|
||||||
|
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
|
||||||
|
: []
|
||||||
|
), [assistantTextParts, isMessageCompleted]);
|
||||||
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
||||||
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
||||||
|
|
||||||
@@ -1863,6 +1868,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
chatRenderMode={chatRenderMode}
|
chatRenderMode={chatRenderMode}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
onShowPopup={onShowPopup}
|
onShowPopup={onShowPopup}
|
||||||
|
enableMarkdownImages={isMessageCompleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2017,6 +2023,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
collapsedPreviewCount,
|
collapsedPreviewCount,
|
||||||
expandedTools,
|
expandedTools,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isMessageCompleted,
|
||||||
isActivityOwnerMessage,
|
isActivityOwnerMessage,
|
||||||
isSortedRenderMode,
|
isSortedRenderMode,
|
||||||
lastRenderableTextPartIndex,
|
lastRenderableTextPartIndex,
|
||||||
@@ -2228,6 +2235,10 @@ const AssistantMessageBody = React.memo(({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||||
|
<MarkdownImageGallery
|
||||||
|
contents={finalizedAssistantMarkdownContents}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
|
/>
|
||||||
{shouldRenderStandaloneActionsAfterContent && (
|
{shouldRenderStandaloneActionsAfterContent && (
|
||||||
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
|
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
|
||||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
|
||||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||||
|
|
||||||
describe('getMermaidDataUrlSourcePromise', () => {
|
describe('getMermaidDataUrlSourcePromise', () => {
|
||||||
@@ -29,3 +30,43 @@ describe('Mermaid load request ids', () => {
|
|||||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Markdown image preview bounds', () => {
|
||||||
|
test('uses sixty percent of the viewport width with vertical containment', () => {
|
||||||
|
expect(getImagePreviewBounds({ width: 1200, height: 800 }, false, true)).toEqual({
|
||||||
|
maxWidth: 720,
|
||||||
|
maxHeight: 640,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves existing attachment preview bounds', () => {
|
||||||
|
expect(getImagePreviewBounds({ width: 1200, height: 800 }, false, false)).toEqual({
|
||||||
|
maxWidth: 900,
|
||||||
|
maxHeight: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a readable modal width for narrow portrait images', () => {
|
||||||
|
expect(getImagePreviewDialogLayout(
|
||||||
|
{ width: 29, height: 576 },
|
||||||
|
{ width: 1280, height: 720 },
|
||||||
|
false,
|
||||||
|
)).toEqual({
|
||||||
|
dialogWidth: 320,
|
||||||
|
imageWidth: 29,
|
||||||
|
imageHeight: 576,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fits image content inside the mobile dialog chrome without cropping', () => {
|
||||||
|
expect(getImagePreviewDialogLayout(
|
||||||
|
{ width: 275, height: 500 },
|
||||||
|
{ width: 320, height: 700 },
|
||||||
|
true,
|
||||||
|
)).toEqual({
|
||||||
|
dialogWidth: 304,
|
||||||
|
imageWidth: 270,
|
||||||
|
imageHeight: 491,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
||||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -29,6 +29,11 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||||
|
import {
|
||||||
|
getImagePreviewBounds,
|
||||||
|
getImagePreviewDialogLayout,
|
||||||
|
type ImagePreviewViewport,
|
||||||
|
} from './imagePreviewSizing';
|
||||||
|
|
||||||
interface ToolOutputDialogProps {
|
interface ToolOutputDialogProps {
|
||||||
popup: ToolPopupContent;
|
popup: ToolPopupContent;
|
||||||
@@ -158,7 +163,7 @@ const usePierreThemeConfig = (): PierreThemeConfig => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewportSize = { width: number; height: number };
|
type ViewportSize = ImagePreviewViewport;
|
||||||
|
|
||||||
const getWindowViewport = (): ViewportSize => ({
|
const getWindowViewport = (): ViewportSize => ({
|
||||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||||
@@ -331,8 +336,11 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
}, [popup.image]);
|
}, [popup.image]);
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||||
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
|
const [loadedImageSize, setLoadedImageSize] = React.useState<{
|
||||||
const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
const viewport = usePreviewViewport(popup.open);
|
const viewport = usePreviewViewport(popup.open);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -352,9 +360,10 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
|
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
|
||||||
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
|
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
|
||||||
|
|
||||||
const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
|
const currentImage = gallery[currentIndex] ?? gallery[0];
|
||||||
const imageTitle = currentImage?.filename || popup.title || 'Image preview';
|
const imageTitle = currentImage?.filename || popup.title || 'Image preview';
|
||||||
const hasMultipleImages = gallery.length > 1;
|
const hasMultipleImages = gallery.length > 1;
|
||||||
|
const markdownImage = popup.metadata?.tool === 'markdown-image-preview';
|
||||||
|
|
||||||
const showPrevious = React.useCallback(() => {
|
const showPrevious = React.useCallback(() => {
|
||||||
if (gallery.length <= 1) return;
|
if (gallery.length <= 1) return;
|
||||||
@@ -372,11 +381,6 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onOpenChange(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft' && hasMultipleImages) {
|
if (event.key === 'ArrowLeft' && hasMultipleImages) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showPrevious();
|
showPrevious();
|
||||||
@@ -393,126 +397,108 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
};
|
};
|
||||||
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
|
}, [hasMultipleImages, popup.open, showNext, showPrevious]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const imageNaturalSize = loadedImageSize?.url === currentImage?.url
|
||||||
setImageNaturalSize(null);
|
? loadedImageSize
|
||||||
}, [currentImage?.url]);
|
: null;
|
||||||
|
|
||||||
const imageDisplaySize = React.useMemo(() => {
|
|
||||||
const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75));
|
|
||||||
const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75));
|
|
||||||
|
|
||||||
if (!imageNaturalSize) {
|
|
||||||
return {
|
|
||||||
width: Math.round(maxWidth),
|
|
||||||
height: Math.round(maxHeight),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const { maxWidth, maxHeight } = getImagePreviewBounds(viewport, isMobile, markdownImage);
|
||||||
|
let imageDisplaySize = {
|
||||||
|
width: Math.round(maxWidth),
|
||||||
|
height: Math.round(maxHeight),
|
||||||
|
};
|
||||||
|
if (imageNaturalSize) {
|
||||||
const widthScale = maxWidth / imageNaturalSize.width;
|
const widthScale = maxWidth / imageNaturalSize.width;
|
||||||
const heightScale = maxHeight / imageNaturalSize.height;
|
const heightScale = maxHeight / imageNaturalSize.height;
|
||||||
const scale = Math.min(widthScale, heightScale);
|
const scale = Math.min(widthScale, heightScale);
|
||||||
|
imageDisplaySize = {
|
||||||
return {
|
|
||||||
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
|
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
|
||||||
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
|
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
|
||||||
};
|
};
|
||||||
}, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
|
}
|
||||||
|
|
||||||
if (!isRendered || !currentImage || typeof document === 'undefined') {
|
const handleImageLoad = React.useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const element = event.currentTarget;
|
||||||
|
const width = element.naturalWidth;
|
||||||
|
const height = element.naturalHeight;
|
||||||
|
if (width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
|
const url = element.getAttribute('src') ?? '';
|
||||||
|
setLoadedImageSize((previous) => {
|
||||||
|
if (previous && previous.url === url && previous.width === width && previous.height === height) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return { url, width, height };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!currentImage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
const dialogLayout = getImagePreviewDialogLayout(imageDisplaySize, viewport, isMobile);
|
||||||
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 bg-black/40',
|
|
||||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
onMouseDown={() => onOpenChange(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasMultipleImages && (
|
return (
|
||||||
<>
|
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||||
<button
|
<DialogContent
|
||||||
type="button"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={showPrevious}
|
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
|
||||||
aria-label={t('chat.toolOutputDialog.image.previousAria')}
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left-s" className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={showNext}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
|
||||||
aria-label={t('chat.toolOutputDialog.image.nextAria')}
|
|
||||||
>
|
|
||||||
<Icon name="arrow-right-s" className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 flex items-center justify-center pointer-events-none',
|
'max-w-none gap-3 overflow-hidden p-4',
|
||||||
isMobile ? 'p-2.5' : 'p-4'
|
'[&>button]:right-3 [&>button]:top-3',
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${dialogLayout.dialogWidth}px`,
|
||||||
|
maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)',
|
||||||
|
maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)',
|
||||||
|
}}
|
||||||
|
data-openchamber-image-preview-dialog="true"
|
||||||
|
data-openchamber-markdown-image-dialog={markdownImage ? 'true' : undefined}
|
||||||
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div
|
<DialogHeader className="min-w-0 pr-8">
|
||||||
className={cn(
|
<DialogTitle className="flex min-w-0 items-center gap-2 text-left">
|
||||||
'pointer-events-auto flex flex-col gap-2',
|
<Icon name="file-image" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
<span className="min-w-0 truncate" title={imageTitle}>{imageTitle}</span>
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
</DialogTitle>
|
||||||
)}
|
</DialogHeader>
|
||||||
style={{ width: `${imageDisplaySize.width}px` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1 text-foreground typography-ui-header font-semibold truncate" title={imageTitle}>
|
|
||||||
{imageTitle}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-label={t('chat.toolOutputDialog.image.closeAria')}
|
|
||||||
>
|
|
||||||
<Icon name="close" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20"
|
||||||
|
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
|
||||||
|
>
|
||||||
|
{hasMultipleImages ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={showPrevious}
|
||||||
|
className="absolute left-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm hover:bg-background focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
aria-label={t('chat.toolOutputDialog.image.previousAria')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left-s" className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={showNext}
|
||||||
|
className="absolute right-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm hover:bg-background focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
aria-label={t('chat.toolOutputDialog.image.nextAria')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-right-s" className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<img
|
<img
|
||||||
src={currentImage.url}
|
src={currentImage.url}
|
||||||
alt={imageTitle}
|
alt={imageTitle}
|
||||||
className="block object-contain"
|
className="block h-full w-full object-contain"
|
||||||
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onLoad={(event) => {
|
onLoad={handleImageLoad}
|
||||||
const element = event.currentTarget;
|
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
|
||||||
const width = element.naturalWidth;
|
|
||||||
const height = element.naturalHeight;
|
|
||||||
if (width > 0 && height > 0) {
|
|
||||||
setImageNaturalSize((previous) => {
|
|
||||||
if (previous && previous.width === width && previous.height === height) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
return { width, height };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div aria-hidden="true" className="h-4 shrink-0" />
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
return createPortal(content, document.body);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── PERF-007: Virtualised sub-components for dialog ──────────────────
|
// ── PERF-007: Virtualised sub-components for dialog ──────────────────
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type ImagePreviewViewport = { width: number; height: number };
|
||||||
|
type ImagePreviewSize = { width: number; height: number };
|
||||||
|
|
||||||
|
export const getImagePreviewBounds = (
|
||||||
|
viewport: ImagePreviewViewport,
|
||||||
|
isMobile: boolean,
|
||||||
|
markdownImage: boolean,
|
||||||
|
): { maxWidth: number; maxHeight: number } => ({
|
||||||
|
maxWidth: Math.max(160, viewport.width * (markdownImage ? 0.6 : (isMobile ? 0.86 : 0.75))),
|
||||||
|
maxHeight: Math.max(160, viewport.height * (markdownImage ? 0.8 : (isMobile ? 0.72 : 0.75))),
|
||||||
|
});
|
||||||
|
|
||||||
|
const IMAGE_DIALOG_MIN_WIDTH = 320;
|
||||||
|
const IMAGE_DIALOG_CHROME_WIDTH = 34;
|
||||||
|
|
||||||
|
export const getImagePreviewDialogLayout = (
|
||||||
|
image: ImagePreviewSize,
|
||||||
|
viewport: ImagePreviewViewport,
|
||||||
|
isMobile: boolean,
|
||||||
|
): { dialogWidth: number; imageWidth: number; imageHeight: number } => {
|
||||||
|
const viewportInset = isMobile ? 16 : 32;
|
||||||
|
const maxDialogWidth = Math.max(160, viewport.width - viewportInset);
|
||||||
|
const minDialogWidth = Math.min(IMAGE_DIALOG_MIN_WIDTH, maxDialogWidth);
|
||||||
|
const dialogWidth = Math.min(
|
||||||
|
maxDialogWidth,
|
||||||
|
Math.max(minDialogWidth, image.width + IMAGE_DIALOG_CHROME_WIDTH),
|
||||||
|
);
|
||||||
|
const availableImageWidth = Math.max(1, dialogWidth - IMAGE_DIALOG_CHROME_WIDTH);
|
||||||
|
const scale = Math.min(1, availableImageWidth / Math.max(1, image.width));
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialogWidth: Math.round(dialogWidth),
|
||||||
|
imageWidth: Math.max(1, Math.round(image.width * scale)),
|
||||||
|
imageHeight: Math.max(1, Math.round(image.height * scale)),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ interface AssistantTextPartProps {
|
|||||||
chatRenderMode?: 'sorted' | 'live';
|
chatRenderMode?: 'sorted' | 'live';
|
||||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
enableMarkdownImages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||||
@@ -27,6 +28,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
streamPhase,
|
streamPhase,
|
||||||
chatRenderMode = 'live',
|
chatRenderMode = 'live',
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
|
enableMarkdownImages = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Use part directly from props — parent provides the latest version from the store.
|
// Use part directly from props — parent provides the latest version from the store.
|
||||||
// No store subscription here to avoid re-render cascade from unrelated delta events.
|
// No store subscription here to avoid re-render cascade from unrelated delta events.
|
||||||
@@ -101,6 +103,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
disableStreamAnimation={chatRenderMode === 'sorted'}
|
disableStreamAnimation={chatRenderMode === 'sorted'}
|
||||||
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
||||||
enableFileReferences={isFinalized}
|
enableFileReferences={isFinalized}
|
||||||
|
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
|
||||||
onShowPopup={onShowPopup}
|
onShowPopup={onShowPopup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
|||||||
HTML is sanitized as defense in depth, with script and style elements
|
HTML is sanitized as defense in depth, with script and style elements
|
||||||
forbidden, so message content cannot inject active DOM or application-wide
|
forbidden, so message content cannot inject active DOM or application-wide
|
||||||
CSS into any runtime surface.
|
CSS into any runtime surface.
|
||||||
|
- Final assistant Markdown collects HTTP(S), embedded, and workspace-local
|
||||||
|
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
|
||||||
|
message-completion area after all message text and above the turn's changed
|
||||||
|
files. Each muted filename caption includes the shared image-file icon.
|
||||||
|
HTTP(S) images keep their browser URL. Embedded and workspace-local images
|
||||||
|
are limited to 10 MiB, validated as PNG/JPEG/GIF/WebP, and local paths are
|
||||||
|
fetched through the active runtime before conversion to data URLs. Local
|
||||||
|
Markdown links whose target has one of
|
||||||
|
those image suffixes stay links in the text and open the same standard modal
|
||||||
|
preview as the gallery; image syntax does not insert a large inline image. A
|
||||||
|
completed assistant message hydrates at most 12 unique image candidates,
|
||||||
|
including persisted text parts that omit their optional part-level end time.
|
||||||
|
The image modal reserves readable title width even for narrow portrait media.
|
||||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||||
|
|||||||
@@ -1151,6 +1151,12 @@ html:not(.dark) .chat-scroll {
|
|||||||
color: var(--markdown-link-hover, var(--primary));
|
color: var(--markdown-link-hover, var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-openchamber-finalized-assistant-images="true"] [data-openchamber-markdown-image-placeholder="true"],
|
||||||
|
[data-openchamber-finalized-assistant-images="true"] p:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child),
|
||||||
|
[data-openchamber-finalized-assistant-images="true"] li:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-content [data-openchamber-file-link="true"] {
|
.markdown-content [data-openchamber-file-link="true"] {
|
||||||
color: var(--markdown-link, var(--primary));
|
color: var(--markdown-link, var(--primary));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
|||||||
- directory listing
|
- directory listing
|
||||||
- file search
|
- file search
|
||||||
- file read path safety checks
|
- file read path safety checks
|
||||||
|
- active-directory selection across multi-root workspaces
|
||||||
- dropped-file parsing and attachment reading
|
- dropped-file parsing and attachment reading
|
||||||
- models metadata fetch helper
|
- models metadata fetch helper
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,13 @@ export const fetchModelsMetadata = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
const getFsAccessRoot = (requestedRoot?: string): string => {
|
||||||
|
const workspaceRoots = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [];
|
||||||
|
const requested = requestedRoot ? path.resolve(requestedRoot) : '';
|
||||||
|
return workspaceRoots.find((root) => path.resolve(root) === requested)
|
||||||
|
|| workspaceRoots[0]
|
||||||
|
|| os.homedir();
|
||||||
|
};
|
||||||
|
|
||||||
export const getFsMimeType = (filePath: string): string => {
|
export const getFsMimeType = (filePath: string): string => {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
@@ -564,13 +570,13 @@ export type FsReadPathResolution =
|
|||||||
| { ok: true; resolvedPath: string }
|
| { ok: true; resolvedPath: string }
|
||||||
| { ok: false; status: number; error: string };
|
| { ok: false; status: number; error: string };
|
||||||
|
|
||||||
export const resolveFileReadPath = async (targetPath: string): Promise<FsReadPathResolution> => {
|
export const resolveFileReadPath = async (targetPath: string, requestedRoot?: string): Promise<FsReadPathResolution> => {
|
||||||
const trimmed = targetPath.trim();
|
const trimmed = targetPath.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return { ok: false, status: 400, error: 'Path is required' };
|
return { ok: false, status: 400, error: 'Path is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseRoot = getFsAccessRoot();
|
const baseRoot = getFsAccessRoot(requestedRoot);
|
||||||
const resolved = resolveUserPath(trimmed, baseRoot);
|
const resolved = resolveUserPath(trimmed, baseRoot);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return { ok: false, status: 400, error: 'Path is required' };
|
return { ok: false, status: 400, error: 'Path is required' };
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it, mock } from 'bun:test';
|
||||||
|
|
||||||
|
const existingFiles = new Set();
|
||||||
|
const fsPromises = {
|
||||||
|
realpath: mock(async (filePath) => {
|
||||||
|
if (existingFiles.has(filePath)) return filePath;
|
||||||
|
const error = new Error('missing');
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
stat: mock(async (filePath) => {
|
||||||
|
if (existingFiles.has(filePath)) return { isFile: () => true, size: 4, mtimeMs: 1 };
|
||||||
|
const error = new Error('missing');
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
readFile: mock(async () => Buffer.from('test')),
|
||||||
|
};
|
||||||
|
|
||||||
mock.module('fs', () => ({
|
mock.module('fs', () => ({
|
||||||
promises: {
|
promises: fsPromises,
|
||||||
realpath: mock(async () => {
|
default: { promises: fsPromises },
|
||||||
const error = new Error('missing');
|
|
||||||
error.code = 'ENOENT';
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
stat: mock(async () => {
|
|
||||||
const error = new Error('missing');
|
|
||||||
error.code = 'ENOENT';
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
promises: {
|
|
||||||
realpath: mock(async () => {
|
|
||||||
const error = new Error('missing');
|
|
||||||
error.code = 'ENOENT';
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
stat: mock(async () => {
|
|
||||||
const error = new Error('missing');
|
|
||||||
error.code = 'ENOENT';
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('vscode', () => ({
|
mock.module('vscode', () => ({
|
||||||
@@ -34,7 +27,10 @@ mock.module('vscode', () => ({
|
|||||||
file: (fsPath) => ({ fsPath }),
|
file: (fsPath) => ({ fsPath }),
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
|
workspaceFolders: [
|
||||||
|
{ uri: { fsPath: '/workspace' } },
|
||||||
|
{ uri: { fsPath: '/workspace-two' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -56,4 +52,15 @@ describe('bridge local fs proxy', () => {
|
|||||||
|
|
||||||
expect(response?.status).toBe(404);
|
expect(response?.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads from the active directory when it is the second workspace root', async () => {
|
||||||
|
existingFiles.add('/workspace-two/image.png');
|
||||||
|
const response = await tryHandleLocalFsProxy(
|
||||||
|
'GET',
|
||||||
|
'/api/fs/raw?path=%2Fworkspace-two%2Fimage.png&directory=%2Fworkspace-two',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response?.status).toBe(200);
|
||||||
|
expect(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString()).toBe('test');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
|||||||
|
|
||||||
const targetPath = parsed.searchParams.get('path') || '';
|
const targetPath = parsed.searchParams.get('path') || '';
|
||||||
const optional = parsed.searchParams.get('optional') === 'true';
|
const optional = parsed.searchParams.get('optional') === 'true';
|
||||||
const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
|
const resolution: FsReadPathResolution = await resolveFileReadPath(
|
||||||
|
targetPath,
|
||||||
|
parsed.searchParams.get('directory') || undefined,
|
||||||
|
);
|
||||||
if (!resolution.ok) {
|
if (!resolution.ok) {
|
||||||
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
|
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user