Merge upstream/main into feat/shiki-re-highlighting-performance-dd3a
Conflict: packages/ui/src/components/chat/markdown/markdownCore.ts main added per-image-mode markdown parsers (`imageMode` threaded through `parseBlock` and into the block cache key); this branch replaced the identity-keyed block cache with a content-addressed LRU. Resolution keeps the content-addressed cache and folds `imageMode` into the content key, so the `inline` and `label` renderings of the same source cannot answer for each other.
This commit is contained in:
@@ -57,6 +57,7 @@ const ICONS = {
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
image: spriteIcon('file-image'),
|
||||
} as const;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
@@ -66,6 +67,18 @@ const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
for (const label of Array.from(root.querySelectorAll<HTMLElement>('[data-openchamber-markdown-image-label="true"]'))) {
|
||||
if (label.querySelector('[data-openchamber-markdown-image-label-icon]')) continue;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIconHtml(icon, ICONS.image);
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
|
||||
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
@@ -487,6 +500,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
|
||||
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
decorateImageLabels(root);
|
||||
decorateInlineCode(root);
|
||||
decorateMermaid(root, ctx);
|
||||
decorateCodeBlocks(root, ctx);
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
}));
|
||||
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const {
|
||||
__markdownImageCandidateCacheForTests,
|
||||
extractMarkdownImageCandidates,
|
||||
renderMarkdownSync,
|
||||
} = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
describe('markdown sanitization', () => {
|
||||
test('turns raw assistant HTML into inert visible text', () => {
|
||||
@@ -15,4 +33,157 @@ describe('markdown sanitization', () => {
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
||||
});
|
||||
|
||||
test('allows only local file URLs through the sanitizer policy', () => {
|
||||
expect(isLocalFileUrl('file:///private/tmp/report%20viewer.html')).toBe(true);
|
||||
expect(isLocalFileUrl('file://localhost/private/tmp/REPORT.md')).toBe(true);
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
test('renders assistant images as icon-ready text without loading the source', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[linked image](packages/vscode/extension.jpg)',
|
||||
'',
|
||||
].join('\n\n'), 'label');
|
||||
|
||||
expect(html).toContain('data-openchamber-markdown-image-label="true"');
|
||||
expect(html).toContain('extension.jpg');
|
||||
expect(html).not.toContain('image syntax');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps non-chat Markdown images inline', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[remote link](https://example.test/image.png)',
|
||||
'',
|
||||
].join('\n\n'));
|
||||
|
||||
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-label');
|
||||
});
|
||||
|
||||
test('collects image syntax across mixed Markdown and ignores links and 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('does not add an ordinary local image link to the gallery', () => {
|
||||
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
|
||||
});
|
||||
|
||||
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('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const contents = Array.from({ length: 20 }, (_, index) => ``);
|
||||
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
|
||||
|
||||
for (let round = 0; round < 1000; round += 1) {
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
}
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(12);
|
||||
expect(stats.scans).toBe(12);
|
||||
});
|
||||
|
||||
test('scans one thousand independent messages once across virtualized remounts', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const messages = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, index) => ``,
|
||||
);
|
||||
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(1000);
|
||||
expect(stats.scans).toBe(1000);
|
||||
});
|
||||
|
||||
test('gives embedded images without alt text a stable filename', () => {
|
||||
const source = 'data:image/png;base64,AAAA';
|
||||
|
||||
expect(extractMarkdownImageCandidates([``])).toEqual([
|
||||
{ source, filename: 'image.png' },
|
||||
]);
|
||||
expect(renderMarkdownSync(``, 'label')).toContain('image.png');
|
||||
});
|
||||
|
||||
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
for (let index = 0; index < 1025; index += 1) {
|
||||
extractMarkdownImageCandidates([``]);
|
||||
}
|
||||
const boundedStats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(boundedStats.entries).toBe(1024);
|
||||
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
|
||||
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const oversized = `\n${'x'.repeat(64 * 1024)}`;
|
||||
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -6,11 +6,169 @@ import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/mess
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const escapeAttr = (value: string): string =>
|
||||
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 type MarkdownImageMode = 'inline' | 'label';
|
||||
|
||||
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
|
||||
|
||||
type MarkdownImageCandidateCacheEntry = {
|
||||
candidates: MarkdownImageCandidate[];
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
|
||||
let markdownImageCandidateCacheBytes = 0;
|
||||
let markdownImageCandidateScanCount = 0;
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
|
||||
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
|
||||
return fallback.trim() || `image.${extension}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const estimateMarkdownImageCandidateCacheEntryBytes = (
|
||||
markdown: string,
|
||||
candidates: readonly MarkdownImageCandidate[],
|
||||
): number => (
|
||||
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
|
||||
);
|
||||
|
||||
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
markdownImageCandidateScanCount += 1;
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (token.type !== 'image') 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 getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
const cached = markdownImageCandidateCache.get(markdown);
|
||||
if (cached) {
|
||||
markdownImageCandidateCache.delete(markdown);
|
||||
markdownImageCandidateCache.set(markdown, cached);
|
||||
return cached.candidates;
|
||||
}
|
||||
|
||||
const candidates = scanMarkdownImageCandidates(markdown);
|
||||
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
|
||||
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
|
||||
|
||||
while (
|
||||
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|
||||
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldest = markdownImageCandidateCache.entries().next().value;
|
||||
if (!oldest) break;
|
||||
markdownImageCandidateCache.delete(oldest[0]);
|
||||
markdownImageCandidateCacheBytes -= oldest[1].bytes;
|
||||
}
|
||||
markdownImageCandidateCache.set(markdown, { candidates, bytes });
|
||||
markdownImageCandidateCacheBytes += bytes;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** @internal Test-only cache instrumentation for deterministic regression tests. */
|
||||
export const __markdownImageCandidateCacheForTests = {
|
||||
reset: (): void => {
|
||||
markdownImageCandidateCache.clear();
|
||||
markdownImageCandidateCacheBytes = 0;
|
||||
markdownImageCandidateScanCount = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
entries: markdownImageCandidateCache.size,
|
||||
bytes: markdownImageCandidateCacheBytes,
|
||||
scans: markdownImageCandidateScanCount,
|
||||
}),
|
||||
};
|
||||
|
||||
const renderMarkdownImageLabel = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const label = getMarkdownImageFilename(href ?? '', text);
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
|
||||
};
|
||||
|
||||
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;
|
||||
for (const candidate of getMarkdownImageCandidates(markdown)) {
|
||||
if (candidates.length >= limit) break;
|
||||
if (seen.has(candidate.source)) continue;
|
||||
seen.add(candidate.source);
|
||||
candidates.push({ ...candidate });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -163,7 +321,7 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const parser = marked.use({
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
@@ -187,9 +345,13 @@ const parser = marked.use({
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
},
|
||||
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const inlineImageParser = createParser('inline');
|
||||
const imageLabelParser = createParser('label');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -308,6 +470,10 @@ const ensureSanitizeHook = (): void => {
|
||||
if (sanitizeHookInstalled) return;
|
||||
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
if (node.target !== '_blank') return;
|
||||
@@ -346,14 +512,16 @@ export const markdownBlockCacheKey = (
|
||||
contentHash: string,
|
||||
mode: MarkdownBlock['mode'],
|
||||
highlight: boolean,
|
||||
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}`;
|
||||
imageMode: MarkdownImageMode,
|
||||
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
|
||||
|
||||
/** Test-only: clear the render HTML cache between cases. */
|
||||
export const resetMarkdownHtmlCacheForTests = (): void => {
|
||||
htmlCache.clear();
|
||||
};
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
||||
@@ -369,8 +537,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string): string => {
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
return sanitize(withMath);
|
||||
@@ -398,6 +567,7 @@ export const renderMarkdownBlocks = async (
|
||||
text: string,
|
||||
streaming: boolean,
|
||||
cacheKey: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): Promise<RenderedBlock[]> => {
|
||||
// Retained for call-site compatibility / debugging; lookup is content-addressed.
|
||||
void cacheKey;
|
||||
@@ -407,12 +577,12 @@ export const renderMarkdownBlocks = async (
|
||||
return Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const cached = htmlCache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return { id, html: cached };
|
||||
}
|
||||
const html = await parseBlock(block);
|
||||
const html = await parseBlock(block, imageMode);
|
||||
htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
return { id, html };
|
||||
}),
|
||||
|
||||
@@ -151,10 +151,12 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
});
|
||||
|
||||
test('block cache keys are content-addressed (mode + highlight + hash)', () => {
|
||||
expect(markdownBlockCacheKey('abc', 'full', true)).toBe('abc:full:1');
|
||||
expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0');
|
||||
expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false));
|
||||
test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => {
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline');
|
||||
expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline');
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline'));
|
||||
// Image mode changes the rendered HTML, so it must not share a cache entry.
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label'));
|
||||
});
|
||||
|
||||
test('multiple code fences in one document highlight concurrently', async () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
let requestCount = 0;
|
||||
let requestPaths: string[] = [];
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
|
||||
requestPaths.push(path);
|
||||
if (path === '/api/fs/stat') {
|
||||
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (path === '/api/fs/raw') {
|
||||
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
requestCount += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { sources: string[] };
|
||||
return new Response(JSON.stringify({
|
||||
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const resolver = {
|
||||
api: () => '',
|
||||
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
|
||||
return `${path}?${params}`;
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
|
||||
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
|
||||
|
||||
class TestFileReader {
|
||||
result: string | ArrayBuffer | null = null;
|
||||
error: DOMException | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
readAsDataURL(blob: Blob) {
|
||||
void blob.arrayBuffer().then((buffer) => {
|
||||
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
|
||||
this.onload?.();
|
||||
}).catch((error) => {
|
||||
this.error = error as DOMException;
|
||||
this.onerror?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
|
||||
|
||||
const {
|
||||
getPreparedMarkdownImageUrl,
|
||||
prepareLocalMarkdownImages,
|
||||
resolveWorkspaceMarkdownImageSource,
|
||||
} = await import('./markdownImageAssets');
|
||||
|
||||
describe('Markdown image asset preparation', () => {
|
||||
test('prepares many images in one message-level request', async () => {
|
||||
requestCount = 0;
|
||||
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
|
||||
|
||||
const result = await prepareLocalMarkdownImages({
|
||||
sources,
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_batch',
|
||||
messageId: 'msg_batch',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(12);
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
|
||||
requestCount = 0;
|
||||
const requests = Array.from({ length: 1000 }, (_, index) => ({
|
||||
sources: [`${index}.png`],
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_long',
|
||||
messageId: `msg_${index}`,
|
||||
signal: new AbortController().signal,
|
||||
}));
|
||||
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
|
||||
expect(requestCount).toBe(1000);
|
||||
});
|
||||
|
||||
test('reuses the existing authenticated raw-file asset URL', () => {
|
||||
const url = getPreparedMarkdownImageUrl({
|
||||
status: 'ready',
|
||||
path: '/tmp/opencode/image.png',
|
||||
outsideFileGrant: 'grant-1',
|
||||
}, '/repo');
|
||||
|
||||
expect(url).toContain('/api/fs/raw?');
|
||||
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
|
||||
expect(url).toContain('outsideFileGrant=grant-1');
|
||||
});
|
||||
|
||||
test('loads a workspace image through the local filesystem bridge', async () => {
|
||||
requestPaths = [];
|
||||
|
||||
const url = await resolveWorkspaceMarkdownImageSource(
|
||||
'screens/image.png',
|
||||
'/repo',
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(url.startsWith('data:image/png;base64,')).toBe(true);
|
||||
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_PREPARE_CACHE_ENTRIES = 1024;
|
||||
const NON_READY_CACHE_MS = 30_000;
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export type PreparedMarkdownImage =
|
||||
| { status: 'ready'; path: string; outsideFileGrant?: string; expiresAt?: number }
|
||||
| { status: 'missing' | 'error' };
|
||||
|
||||
type PrepareCacheEntry = {
|
||||
result: Map<string, PreparedMarkdownImage>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const prepareCaches = new WeakMap<RuntimeUrlResolver, Map<string, PrepareCacheEntry>>();
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal): void => {
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
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));
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||
case 'image/jpeg':
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
case 'image/gif': {
|
||||
const gif = ascii(0, 6);
|
||||
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||
}
|
||||
case 'image/webp':
|
||||
return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
if (match[2].length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) throw new Error('Image is too large');
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(match[2]);
|
||||
} catch {
|
||||
throw new Error('Invalid image data URL');
|
||||
}
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
|
||||
};
|
||||
|
||||
export const isLocalMarkdownImageSource = (source: string): boolean => (
|
||||
!/^(?:https?:)?\/\//i.test(source)
|
||||
&& !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
);
|
||||
|
||||
export const prepareLocalMarkdownImages = async ({
|
||||
sources,
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
signal,
|
||||
}: {
|
||||
sources: readonly string[];
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<Map<string, PreparedMarkdownImage>> => {
|
||||
const resolver = getRuntimeUrlResolver();
|
||||
let cache = prepareCaches.get(resolver);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
prepareCaches.set(resolver, cache);
|
||||
}
|
||||
const key = `${sessionId}\0${messageId}\0${directory}\0${sources.join('\0')}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached.result;
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const response = await runtimeFetch(
|
||||
`/api/openchamber/sessions/${encodeURIComponent(sessionId)}/markdown-image-grants`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ directory, messageId, sources }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Unable to prepare images (${response.status})`);
|
||||
const payload = await response.json() as {
|
||||
results?: Array<{
|
||||
source?: string;
|
||||
status?: string;
|
||||
path?: string;
|
||||
outsideFileGrant?: string;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
};
|
||||
const prepared = new Map<string, PreparedMarkdownImage>();
|
||||
for (const result of payload.results ?? []) {
|
||||
if (!result.source) continue;
|
||||
if (result.status === 'ready' && result.path) {
|
||||
prepared.set(result.source, {
|
||||
status: 'ready',
|
||||
path: result.path,
|
||||
outsideFileGrant: result.outsideFileGrant,
|
||||
expiresAt: result.expiresAt,
|
||||
});
|
||||
} else if (result.status === 'missing') {
|
||||
prepared.set(result.source, { status: 'missing' });
|
||||
} else {
|
||||
prepared.set(result.source, { status: 'error' });
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (!prepared.has(source)) prepared.set(source, { status: 'error' });
|
||||
}
|
||||
while (cache.size >= MAX_PREPARE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
|
||||
const allReady = [...prepared.values()].every((value) => value.status === 'ready');
|
||||
const grantExpiry = Math.min(...[...prepared.values()]
|
||||
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
|
||||
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
|
||||
cache.set(key, {
|
||||
result: prepared,
|
||||
expiresAt: allReady ? grantExpiry : Date.now() + NON_READY_CACHE_MS,
|
||||
});
|
||||
return prepared;
|
||||
};
|
||||
|
||||
export const resolveMarkdownImageSource = async (
|
||||
source: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
throwIfAborted(signal);
|
||||
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||
await validateDataImage(source);
|
||||
throwIfAborted(signal);
|
||||
return source;
|
||||
}
|
||||
throw new Error('Local image has not been prepared');
|
||||
};
|
||||
|
||||
/**
|
||||
* VS Code has no OpenChamber server route for message-scoped temporary-file
|
||||
* grants. Preserve its existing workspace-only gallery path through the local
|
||||
* filesystem bridge, including the same size and signature validation.
|
||||
*/
|
||||
export const resolveWorkspaceMarkdownImageSource = async (
|
||||
source: string,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
throwIfAborted(signal);
|
||||
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);
|
||||
throwIfAborted(signal);
|
||||
return blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
export const getPreparedMarkdownImageUrl = (
|
||||
image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
|
||||
directory: string,
|
||||
): string => getRuntimeUrlResolver().authenticatedAsset(
|
||||
'/api/fs/raw',
|
||||
{
|
||||
path: image.path,
|
||||
directory,
|
||||
allowOutsideWorkspace: image.outsideFileGrant ? 'true' : undefined,
|
||||
outsideFileGrant: image.outsideFileGrant,
|
||||
},
|
||||
);
|
||||
@@ -4,3 +4,12 @@ export const escapeRawMarkdownHtml = (value: string): string =>
|
||||
|
||||
/** Active elements forbidden again at the final DOMPurify boundary. */
|
||||
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
|
||||
|
||||
export const isLocalFileUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'file:' && (!parsed.hostname || parsed.hostname === 'localhost');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user