Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed. Session history loading and pagination: - Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview. - Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time. - Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat. - Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits. - Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back. - Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state. - Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache. VS Code cache and memory pressure reductions: - Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run. - Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session. - Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation. - Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work. - Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared. - Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages. - Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size. Chat render-path reductions: - Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription. - Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders. - Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders. - Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates. - Remount the chat viewport when the current session changes, isolating per-session viewport and list state. - Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history. VS Code layout and header improvements: - Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence. - Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed. - Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice. - Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests. Markdown and file-reference safeguards: - Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web. - Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages. - Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks. - Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes. Assistant-message action and preview reductions: - Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable. - Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces. - Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces. - Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list. Tool and task rendering optimizations: - Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present. - Avoid polling or final-fetching task child sessions once a final metadata summary is available. - Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches. - Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays. - Count write-tool lines by scanning content instead of allocating a split array for large files. - Avoid trimming large patch strings just to test whether they contain content. - Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render. VS Code bridge improvements: - Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses. - Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract. - Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body. Validation: - bun run type-check - bun run lint - bun run vscode:build
1837 lines
59 KiB
TypeScript
1837 lines
59 KiB
TypeScript
import React from 'react';
|
|
import 'katex/dist/katex.min.css';
|
|
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import type { Components } from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import remarkMath from 'remark-math';
|
|
import rehypeKatex from 'rehype-katex';
|
|
import { marked, type Tokens } from 'marked';
|
|
import remend from 'remend';
|
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
|
import { cn } from '@/lib/utils';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { toast } from '@/components/ui';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
|
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
|
import type { ToolPopupContent } from './message/types';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import type { EditorAPI } from '@/lib/api/types';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
|
|
const useCurrentMermaidTheme = () => {
|
|
const themeSystem = useOptionalThemeSystem();
|
|
const fallbackLight = getDefaultTheme(false);
|
|
const fallbackDark = getDefaultTheme(true);
|
|
|
|
return themeSystem?.currentTheme
|
|
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
? fallbackDark
|
|
: fallbackLight);
|
|
};
|
|
|
|
const useExternalLinkInteractions = ({
|
|
containerRef,
|
|
enabled,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
enabled?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
if (enabled === false) {
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const anchor = target.closest('a[href]');
|
|
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
return;
|
|
}
|
|
|
|
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
|
return;
|
|
}
|
|
|
|
const href = anchor.getAttribute('href') ?? '';
|
|
if (!isExternalHttpUrl(href)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void openExternalUrl(href);
|
|
};
|
|
|
|
container.addEventListener('click', handleClick);
|
|
return () => {
|
|
container.removeEventListener('click', handleClick);
|
|
};
|
|
}, [containerRef, enabled]);
|
|
};
|
|
|
|
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
|
|
const [failed, setFailed] = React.useState(false);
|
|
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
|
|
|
|
if (!faviconUrl || failed) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<span className="mr-1 inline-flex size-[18px] items-center justify-center rounded border border-[var(--border)] bg-[var(--interactive-hover)] align-middle">
|
|
<img
|
|
src={faviconUrl}
|
|
alt=""
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="size-3.5 rounded-sm"
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Table utility functions
|
|
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
|
const headers: string[] = [];
|
|
const rows: string[][] = [];
|
|
|
|
const thead = tableEl.querySelector('thead');
|
|
if (thead) {
|
|
const headerCells = thead.querySelectorAll('th');
|
|
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
|
|
}
|
|
|
|
const tbody = tableEl.querySelector('tbody');
|
|
if (tbody) {
|
|
const rowEls = tbody.querySelectorAll('tr');
|
|
rowEls.forEach(row => {
|
|
const cells = row.querySelectorAll('td');
|
|
const rowData: string[] = [];
|
|
cells.forEach(cell => rowData.push(cell.innerText.trim()));
|
|
rows.push(rowData);
|
|
});
|
|
}
|
|
|
|
return { headers, rows };
|
|
};
|
|
|
|
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
const escapeCell = (cell: string): string => {
|
|
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|
|
return `"${cell.replace(/"/g, '""')}"`;
|
|
}
|
|
return cell;
|
|
};
|
|
|
|
const lines: string[] = [];
|
|
if (headers.length > 0) {
|
|
lines.push(headers.map(escapeCell).join(','));
|
|
}
|
|
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
const escapeCell = (cell: string): string => {
|
|
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
};
|
|
|
|
const lines: string[] = [];
|
|
if (headers.length > 0) {
|
|
lines.push(headers.map(escapeCell).join('\t'));
|
|
}
|
|
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
if (headers.length === 0) return '';
|
|
|
|
const escapeCell = (cell: string): string => {
|
|
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
};
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
|
|
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
|
rows.forEach(row => {
|
|
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
|
|
lines.push(`| ${paddedRow.join(' | ')} |`);
|
|
});
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Table copy button with dropdown
|
|
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
|
const { t } = useI18n();
|
|
const [copied, setCopied] = React.useState(false);
|
|
const [showMenu, setShowMenu] = React.useState(false);
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setShowMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleCopy = async (format: 'csv' | 'tsv') => {
|
|
const tableEl = tableRef.current?.querySelector('table');
|
|
if (!tableEl) return;
|
|
|
|
const data = extractTableData(tableEl);
|
|
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
|
|
|
try {
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({
|
|
'text/plain': new Blob([content], { type: 'text/plain' }),
|
|
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
|
|
}),
|
|
]);
|
|
setCopied(true);
|
|
setShowMenu(false);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (err) {
|
|
const fallbackResult = await copyTextToClipboard(content);
|
|
if (fallbackResult.ok) {
|
|
setCopied(true);
|
|
setShowMenu(false);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
return;
|
|
}
|
|
console.error('Failed to copy table:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => setShowMenu(!showMenu)}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.table.actions.copyTitle')}
|
|
>
|
|
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
|
</button>
|
|
{showMenu && (
|
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
|
|
<button
|
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
|
onClick={() => handleCopy('csv')}
|
|
>
|
|
CSV
|
|
</button>
|
|
<button
|
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
|
onClick={() => handleCopy('tsv')}
|
|
>
|
|
TSV
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Table download button with dropdown
|
|
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
|
const { t } = useI18n();
|
|
const [showMenu, setShowMenu] = React.useState(false);
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setShowMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleDownload = (format: 'csv' | 'markdown') => {
|
|
const tableEl = tableRef.current?.querySelector('table');
|
|
if (!tableEl) return;
|
|
|
|
const data = extractTableData(tableEl);
|
|
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
|
|
const filename = format === 'csv' ? 'table.csv' : 'table.md';
|
|
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
|
|
downloadFile(filename, content, mimeType);
|
|
setShowMenu(false);
|
|
toast.success(t('markdownRenderer.table.toast.downloadedAsFormat', { format: format.toUpperCase() }));
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => setShowMenu(!showMenu)}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.table.actions.downloadTitle')}
|
|
>
|
|
<Icon name="download" className="size-3.5" />
|
|
</button>
|
|
{showMenu && (
|
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
|
|
<button
|
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
|
onClick={() => handleDownload('csv')}
|
|
>
|
|
CSV
|
|
</button>
|
|
<button
|
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
|
onClick={() => handleDownload('markdown')}
|
|
>
|
|
Markdown
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Table wrapper with custom controls
|
|
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
|
const tableRef = React.useRef<HTMLDivElement>(null);
|
|
const { isMobile, isTablet } = useDeviceInfo();
|
|
const alwaysShowActions = isMobile || isTablet;
|
|
|
|
return (
|
|
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}>
|
|
<div className={cn(
|
|
"flex items-center justify-end gap-1 transition-opacity",
|
|
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
)}>
|
|
<TableCopyButton tableRef={tableRef} />
|
|
<TableDownloadButton tableRef={tableRef} />
|
|
</div>
|
|
<div className="overflow-x-auto rounded-lg border border-border/80 bg-[var(--surface-elevated)]">
|
|
<table className={cn('w-full border-collapse text-sm', className)} data-markdown="table">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
|
|
const { t } = useI18n();
|
|
const currentTheme = useCurrentMermaidTheme();
|
|
const { isMobile, isTablet } = useDeviceInfo();
|
|
const [copied, setCopied] = React.useState(false);
|
|
const [downloaded, setDownloaded] = React.useState(false);
|
|
|
|
const svg = React.useMemo(() => {
|
|
if (mode !== 'svg') return '';
|
|
try {
|
|
return renderMermaidSVG(source, {
|
|
bg: currentTheme.colors.surface.elevated,
|
|
fg: currentTheme.colors.surface.foreground,
|
|
line: currentTheme.colors.interactive.border,
|
|
accent: currentTheme.colors.primary.base,
|
|
muted: currentTheme.colors.surface.mutedForeground,
|
|
surface: currentTheme.colors.surface.muted,
|
|
border: currentTheme.colors.interactive.border,
|
|
transparent: true,
|
|
font: 'IBM Plex Sans, sans-serif',
|
|
});
|
|
} catch {
|
|
return '';
|
|
}
|
|
}, [currentTheme, mode, source]);
|
|
|
|
const ascii = React.useMemo(() => {
|
|
if (mode !== 'ascii') return '';
|
|
try {
|
|
return renderMermaidASCII(source);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}, [mode, source]);
|
|
|
|
const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
|
|
|
|
const handleCopyAscii = async (asciiText: string) => {
|
|
if (!asciiText) return;
|
|
const result = await copyTextToClipboard(asciiText);
|
|
if (result.ok) {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleCopyMermaidSource = async () => {
|
|
if (!source) return;
|
|
const result = await copyTextToClipboard(source);
|
|
if (result.ok) {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleDownloadSvg = () => {
|
|
if (!svg) return;
|
|
try {
|
|
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `diagram-${Date.now()}.svg`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
setDownloaded(true);
|
|
setTimeout(() => setDownloaded(false), 2000);
|
|
} catch {
|
|
toast.error(t('markdownRenderer.mermaid.toast.downloadFailed'));
|
|
}
|
|
};
|
|
|
|
if (mode === 'ascii') {
|
|
const asciiText = ascii || source;
|
|
|
|
return (
|
|
<div data-markdown="mermaid-block" className="group">
|
|
<div data-markdown="mermaid-scroll">
|
|
<pre data-markdown="mermaid-ascii">{asciiText}</pre>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'absolute top-1 right-2 transition-opacity',
|
|
copyVisibilityClass,
|
|
)}
|
|
>
|
|
<button
|
|
onClick={() => handleCopyAscii(asciiText)}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.mermaid.actions.copyTitle')}
|
|
>
|
|
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!svg) {
|
|
return (
|
|
<div data-markdown="mermaid-block" className="group">
|
|
<div data-markdown="mermaid-scroll">
|
|
<pre data-markdown="mermaid-ascii">{source}</pre>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'absolute top-1 right-2 transition-opacity',
|
|
copyVisibilityClass,
|
|
)}
|
|
>
|
|
<button
|
|
onClick={() => handleCopyAscii(source)}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.mermaid.actions.copyTitle')}
|
|
>
|
|
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div data-markdown="mermaid-block" className="group">
|
|
<div data-markdown="mermaid-scroll">
|
|
<div data-markdown="mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'absolute top-1 right-2 flex items-center gap-1 transition-opacity',
|
|
copyVisibilityClass,
|
|
)}
|
|
>
|
|
<button
|
|
onClick={handleCopyMermaidSource}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.mermaid.actions.copySourceTitle')}
|
|
>
|
|
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
|
</button>
|
|
<button
|
|
onClick={handleDownloadSvg}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('markdownRenderer.mermaid.actions.downloadSvgTitle')}
|
|
>
|
|
{downloaded ? <Icon name="check" className="size-3.5" /> : <Icon name="download" className="size-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type MermaidControlOptions = {
|
|
download: boolean;
|
|
copy: boolean;
|
|
fullscreen: boolean;
|
|
panZoom: boolean;
|
|
};
|
|
|
|
const extractMermaidBlocks = (markdown: string): string[] => {
|
|
if (!markdown.includes('mermaid')) return [];
|
|
const blocks: string[] = [];
|
|
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
|
|
let match: RegExpExecArray | null = regex.exec(markdown);
|
|
|
|
while (match) {
|
|
const block = (match[2] ?? '').replace(/\s+$/, '');
|
|
blocks.push(block);
|
|
match = regex.exec(markdown);
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
const stripLeadingFrontmatter = (markdown: string): string => {
|
|
const frontmatterMatch = markdown.match(
|
|
/^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/,
|
|
);
|
|
|
|
if (!frontmatterMatch) {
|
|
return markdown;
|
|
}
|
|
|
|
return markdown.slice(frontmatterMatch[0].length);
|
|
};
|
|
|
|
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
|
|
|
|
type MarkdownStreamBlock = {
|
|
key: string;
|
|
raw: string;
|
|
src: string;
|
|
mode: 'full' | 'live';
|
|
};
|
|
|
|
const hasReferenceDefinitions = (text: string): boolean => {
|
|
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text);
|
|
};
|
|
|
|
const hasOpenFence = (raw: string): boolean => {
|
|
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
|
|
if (!match) return false;
|
|
const marker = match[1];
|
|
if (!marker) return false;
|
|
const char = marker[0];
|
|
const size = marker.length;
|
|
const last = raw.trimEnd().split('\n').at(-1)?.trim() ?? '';
|
|
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
|
|
};
|
|
|
|
const healMarkdown = (text: string): string => {
|
|
return remend(text, { linkMode: 'text-only' });
|
|
};
|
|
|
|
const fnv1a32 = (input: string): string => {
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < input.length; i += 1) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
|
}
|
|
return hash.toString(16);
|
|
};
|
|
|
|
const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode: 'full' | 'live'): string => {
|
|
const sample = raw.length > 400 ? `${raw.slice(0, 200)}${raw.slice(-200)}` : raw;
|
|
return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`;
|
|
};
|
|
|
|
const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
|
|
if (!live) {
|
|
return [{
|
|
key: buildMarkdownCacheKey(baseKey, text, 0, 'full'),
|
|
raw: text,
|
|
src: text,
|
|
mode: 'full',
|
|
}];
|
|
}
|
|
|
|
const healed = healMarkdown(text);
|
|
if (hasReferenceDefinitions(text)) {
|
|
return [{
|
|
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
|
|
raw: text,
|
|
src: healed,
|
|
mode: 'live',
|
|
}];
|
|
}
|
|
|
|
const tokens = marked.lexer(text);
|
|
const blocks: MarkdownStreamBlock[] = [];
|
|
let blockIndex = 0;
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens[index] as Tokens.Generic;
|
|
if (token.type === 'space') {
|
|
continue;
|
|
}
|
|
|
|
const raw = token.raw ?? '';
|
|
const isLast = index === tokens.length - 1 || tokens.slice(index + 1).every((nextToken) => nextToken.type === 'space');
|
|
const mode: 'full' | 'live' = isLast ? 'live' : 'full';
|
|
const src = isLast && token.type === 'code' && hasOpenFence(raw)
|
|
? raw
|
|
: healMarkdown(raw);
|
|
|
|
blocks.push({
|
|
key: buildMarkdownCacheKey(baseKey, raw, blockIndex, mode),
|
|
raw,
|
|
src,
|
|
mode,
|
|
});
|
|
blockIndex += 1;
|
|
}
|
|
|
|
if (blocks.length === 0) {
|
|
return [{
|
|
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
|
|
raw: text,
|
|
src: healed,
|
|
mode: 'live',
|
|
}];
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
const useStableMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
|
|
const previousRef = React.useRef<MarkdownStreamBlock[]>([]);
|
|
|
|
return React.useMemo(() => {
|
|
const nextBlocks = streamMarkdownBlocks(text, live, baseKey);
|
|
const previousBlocks = previousRef.current;
|
|
const stabilized = nextBlocks.map((block, index) => {
|
|
const previous = previousBlocks[index];
|
|
if (previous && previous.key === block.key && previous.src === block.src) {
|
|
return previous;
|
|
}
|
|
return block;
|
|
});
|
|
|
|
const unchanged = stabilized.length === previousBlocks.length
|
|
&& stabilized.every((block, index) => block === previousBlocks[index]);
|
|
|
|
if (unchanged) {
|
|
return previousBlocks;
|
|
}
|
|
|
|
previousRef.current = stabilized;
|
|
return stabilized;
|
|
}, [baseKey, live, text]);
|
|
};
|
|
|
|
const extractCodeText = (children: React.ReactNode): string => {
|
|
if (typeof children === 'string') return children;
|
|
if (Array.isArray(children)) {
|
|
return children.map((child) => extractCodeText(child)).join('');
|
|
}
|
|
if (React.isValidElement(children)) {
|
|
return extractCodeText((children.props as { children?: React.ReactNode }).children);
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const getCodeLanguage = (className: string | undefined): string => {
|
|
const match = className?.match(/language-([\w-]+)/);
|
|
return match?.[1]?.toLowerCase() ?? 'text';
|
|
};
|
|
|
|
const decodeHtmlEntities = (value: string): string => {
|
|
let decoded = value;
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const next = decoded
|
|
.replace(/"/g, '"')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
if (next === decoded) {
|
|
return decoded;
|
|
}
|
|
decoded = next;
|
|
}
|
|
return decoded;
|
|
};
|
|
|
|
const normalizeCodeBlockText = (code: string, language: string): string => {
|
|
if (!['json', 'jsonc', 'json5'].includes(language)) {
|
|
return code;
|
|
}
|
|
if (!/&(quot|#34|amp;quot|lt|gt|amp|apos|#39);/.test(code)) {
|
|
return code;
|
|
}
|
|
return decodeHtmlEntities(code);
|
|
};
|
|
|
|
const CODE_HIGHLIGHT_SETTLE_MS = 300;
|
|
const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
|
|
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
|
|
const CODE_SHARED_STYLE: React.CSSProperties = {
|
|
margin: 0,
|
|
background: 'transparent',
|
|
padding: 0,
|
|
fontSize: 'var(--text-code)',
|
|
lineHeight: 'var(--markdown-code-block-line-height)',
|
|
};
|
|
|
|
const exceedsLineLimit = (value: string, limit: number): boolean => {
|
|
let lineCount = 1;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
if (value.charCodeAt(index) === 10) {
|
|
lineCount += 1;
|
|
if (lineCount > limit) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const getCodeHighlightLineLimit = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT
|
|
);
|
|
|
|
const downloadTextFile = (content: string, filename: string, mimeType: string) => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
// Best-effort; callers can optionally toast.
|
|
}
|
|
};
|
|
|
|
const MarkdownCodeBlock: React.FC<{
|
|
code: string;
|
|
language: string;
|
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
|
}> = ({ code, language, syntaxTheme }) => {
|
|
const [copied, setCopied] = React.useState(false);
|
|
const [highlight, setHighlight] = React.useState(true);
|
|
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
|
|
const prevCodeRef = React.useRef<string>(code);
|
|
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const { isMobile, isTablet } = useDeviceInfo();
|
|
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
|
|
|
|
const canPreview = language === 'html' || language === 'htm';
|
|
|
|
React.useEffect(() => {
|
|
if (!canPreview && viewMode !== 'code') {
|
|
setViewMode('code');
|
|
}
|
|
}, [canPreview, viewMode]);
|
|
|
|
// Defer Prism highlighting while code is actively streaming.
|
|
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
|
|
React.useEffect(() => {
|
|
if (prevCodeRef.current === code) return;
|
|
prevCodeRef.current = code;
|
|
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setHighlight(false);
|
|
timerRef.current = setTimeout(() => {
|
|
setHighlight(true);
|
|
timerRef.current = null;
|
|
}, CODE_HIGHLIGHT_SETTLE_MS);
|
|
|
|
return () => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
};
|
|
}, [code]);
|
|
|
|
const handleCopy = React.useCallback(async () => {
|
|
const result = await copyTextToClipboard(code);
|
|
if (!result.ok) return;
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
}, [code]);
|
|
|
|
const handleDownload = React.useCallback(() => {
|
|
if (!canPreview) {
|
|
return;
|
|
}
|
|
|
|
const safeSuffix = Date.now().toString(36);
|
|
downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8');
|
|
}, [canPreview, code]);
|
|
|
|
return (
|
|
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
|
|
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
|
|
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
|
|
<div className={cn(
|
|
"flex items-center gap-1 transition-opacity",
|
|
isMobile || isTablet ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
|
)}>
|
|
{canPreview ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
|
|
aria-pressed={viewMode === 'preview'}
|
|
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
|
|
>
|
|
{viewMode === 'preview' ? <Icon name="code" className="size-3.5" /> : <Icon name="eye" className="size-3.5" />}
|
|
</button>
|
|
) : null}
|
|
{canPreview ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleDownload}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Download HTML"
|
|
aria-label="Download HTML"
|
|
>
|
|
<Icon name="download" className="size-3.5" />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => { void handleCopy(); }}
|
|
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={copied ? 'Copied' : 'Copy code'}
|
|
aria-label={copied ? 'Copied' : 'Copy code'}
|
|
>
|
|
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{canPreview && viewMode === 'preview' ? (
|
|
<div className="h-[320px] md:h-[420px] bg-background">
|
|
<iframe
|
|
srcDoc={code}
|
|
title="HTML preview"
|
|
className="h-full w-full border-0"
|
|
sandbox="allow-scripts allow-forms"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-2.5">
|
|
{highlight && !skipHighlight ? (
|
|
<SyntaxHighlighter
|
|
language={language}
|
|
style={syntaxTheme}
|
|
customStyle={CODE_SHARED_STYLE}
|
|
codeTagProps={{ style: CODE_SHARED_STYLE }}
|
|
PreTag="pre"
|
|
>
|
|
{code}
|
|
</SyntaxHighlighter>
|
|
) : (
|
|
<pre style={CODE_SHARED_STYLE}>
|
|
<code style={CODE_SHARED_STYLE}>{code}</code>
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const buildMarkdownComponents = ({
|
|
syntaxTheme,
|
|
onPreviewLoopback,
|
|
previewLabel,
|
|
previewTitle,
|
|
}: {
|
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
|
onPreviewLoopback?: (url: string) => void;
|
|
previewLabel?: string;
|
|
previewTitle?: string;
|
|
}): Components => ({
|
|
table({ children, ...props }) {
|
|
return <TableWrapper className={props.className}>{children}</TableWrapper>;
|
|
},
|
|
h1({ children, ...props }) {
|
|
return <h1 {...props} className={cn('typography-markdown-h1 mt-4 mb-2 text-[var(--markdown-heading1,var(--primary))] font-semibold', props.className)}>{children}</h1>;
|
|
},
|
|
h2({ children, ...props }) {
|
|
return <h2 {...props} className={cn('typography-markdown-h2 mt-3.5 mb-1.5 text-[var(--markdown-heading2,var(--primary))] font-semibold', props.className)}>{children}</h2>;
|
|
},
|
|
h3({ children, ...props }) {
|
|
return <h3 {...props} className={cn('typography-markdown-h3 mt-3 mb-1 text-[var(--markdown-heading3,var(--primary))] font-semibold', props.className)}>{children}</h3>;
|
|
},
|
|
h4({ children, ...props }) {
|
|
return <h4 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h4>;
|
|
},
|
|
h5({ children, ...props }) {
|
|
return <h5 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h5>;
|
|
},
|
|
h6({ children, ...props }) {
|
|
return <h6 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h6>;
|
|
},
|
|
p({ children, ...props }) {
|
|
return <p {...props} className={cn('typography-markdown-body my-2 text-foreground/90', props.className)}>{children}</p>;
|
|
},
|
|
thead({ children, ...props }) {
|
|
return <thead {...props} className={cn('[&_tr]:border-b [&_tr]:border-border/80', props.className)}>{children}</thead>;
|
|
},
|
|
tbody({ children, ...props }) {
|
|
return <tbody {...props} className={cn('[&_tr:last-child]:border-0', props.className)}>{children}</tbody>;
|
|
},
|
|
tr({ children, ...props }) {
|
|
return <tr {...props} className={cn('border-b border-border/60', props.className)}>{children}</tr>;
|
|
},
|
|
th({ children, ...props }) {
|
|
return <th {...props} className={cn('border-r border-border/60 px-4 py-2.5 text-left align-middle font-semibold text-foreground last:border-r-0', props.className)}>{children}</th>;
|
|
},
|
|
td({ children, ...props }) {
|
|
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
|
|
},
|
|
ul({ children, ...props }) {
|
|
return <ul {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ul>;
|
|
},
|
|
ol({ children, ...props }) {
|
|
return <ol {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ol>;
|
|
},
|
|
li({ children, ...props }) {
|
|
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
|
|
},
|
|
blockquote({ children, ...props }) {
|
|
return <blockquote {...props} className={cn('my-3 border-l-2 border-[var(--markdown-blockquote-border,var(--border))] pl-4 typography-markdown-body text-[var(--markdown-blockquote,var(--muted-foreground))]', props.className)}>{children}</blockquote>;
|
|
},
|
|
pre({ children, ...props }) {
|
|
const child = React.Children.only(children) as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
|
|
const className = child.props.className;
|
|
const language = getCodeLanguage(className);
|
|
const code = normalizeCodeBlockText(extractCodeText(child.props.children).replace(/\n$/, ''), language);
|
|
if (language === 'mermaid') {
|
|
return <MermaidBlock source={code} mode={useUIStore.getState().mermaidRenderingMode} />;
|
|
}
|
|
return <MarkdownCodeBlock code={code} language={language} syntaxTheme={syntaxTheme} {...props} />;
|
|
},
|
|
code({ className, children, ...props }) {
|
|
return (
|
|
<code
|
|
{...props}
|
|
className={cn('rounded bg-[var(--surface-elevated)] px-1 py-0.5 font-mono text-[0.95em]', className)}
|
|
data-markdown="inline-code"
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
a({ href, children, ...props }) {
|
|
const targetHref = href ?? '';
|
|
const isExternal = isExternalHttpUrl(targetHref);
|
|
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
|
|
return (
|
|
<>
|
|
<a
|
|
{...props}
|
|
href={href}
|
|
target={isExternal ? '_blank' : undefined}
|
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
|
>
|
|
{isExternal ? <ExternalLinkFavicon href={targetHref} /> : null}
|
|
{children}
|
|
</a>
|
|
{isLoopback && onPreviewLoopback ? (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onPreviewLoopback(targetHref);
|
|
}}
|
|
className="ml-1 inline-flex h-5 items-center gap-0.5 rounded border border-[var(--border)] bg-[var(--surface-background)] px-1.5 align-middle text-[11px] leading-none text-[var(--muted-foreground)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--foreground)]"
|
|
aria-label={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
|
title={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
|
data-loopback-preview-trigger="true"
|
|
>
|
|
<Icon name="eye" className="size-3" aria-hidden="true"/>
|
|
<span className="font-medium">{previewLabel ?? 'Preview'}</span>
|
|
</button>
|
|
) : null}
|
|
</>
|
|
);
|
|
},
|
|
});
|
|
|
|
const MarkdownBlockView: React.FC<{
|
|
block: MarkdownStreamBlock;
|
|
components: Components;
|
|
}> = React.memo(({ block, components }) => {
|
|
return (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypeKatex, { throwOnError: false, errorColor: 'var(--destructive)' }]]} components={components}>
|
|
{block.src}
|
|
</ReactMarkdown>
|
|
);
|
|
}, (prev, next) => prev.block === next.block && prev.components === next.components);
|
|
|
|
MarkdownBlockView.displayName = 'MarkdownBlockView';
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string;
|
|
part?: Part;
|
|
messageId: string;
|
|
isAnimated?: boolean;
|
|
skipFadeIn?: boolean;
|
|
className?: string;
|
|
isStreaming?: boolean;
|
|
disableStreamAnimation?: boolean;
|
|
variant?: MarkdownVariant;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
enableFileReferences?: boolean;
|
|
}
|
|
|
|
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
|
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
|
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
|
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
|
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
|
const FILE_REFERENCE_LINK_LIMIT = 200;
|
|
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
|
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
|
let activeFileReferenceStatCount = 0;
|
|
const pendingFileReferenceStats: Array<() => void> = [];
|
|
|
|
const getFileReferenceStatCacheMax = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
|
);
|
|
|
|
const getFileReferenceLinkLimit = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
|
);
|
|
|
|
type ParsedFileReference = {
|
|
path: string;
|
|
line?: number;
|
|
column?: number;
|
|
};
|
|
|
|
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
|
const KNOWN_FILE_BASENAMES = new Set([
|
|
'dockerfile',
|
|
'makefile',
|
|
'readme',
|
|
'license',
|
|
'.env',
|
|
'.gitignore',
|
|
'.npmrc',
|
|
]);
|
|
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
|
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
.join('|');
|
|
|
|
const normalizePath = (value: string): string => {
|
|
const source = (value || '').trim();
|
|
if (!source) {
|
|
return '';
|
|
}
|
|
|
|
const withSlashes = source.replace(/\\/g, '/');
|
|
const hadUncPrefix = withSlashes.startsWith('//');
|
|
|
|
let normalized = withSlashes.replace(/\/+/g, '/');
|
|
if (hadUncPrefix && !normalized.startsWith('//')) {
|
|
normalized = `/${normalized}`;
|
|
}
|
|
|
|
const isUnixRoot = normalized === '/';
|
|
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
|
|
if (!isUnixRoot && !isWindowsDriveRoot) {
|
|
normalized = normalized.replace(/\/+$/, '');
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const isAbsolutePath = (value: string): boolean => {
|
|
return value.startsWith('/')
|
|
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|
|
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|
|
|| value.startsWith('//');
|
|
};
|
|
|
|
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
|
const normalizedTarget = normalizePath(targetPath);
|
|
if (!normalizedTarget) {
|
|
return normalizePath(basePath);
|
|
}
|
|
|
|
if (isAbsolutePath(normalizedTarget)) {
|
|
return normalizedTarget;
|
|
}
|
|
|
|
const normalizedBase = normalizePath(basePath);
|
|
if (!normalizedBase) {
|
|
return normalizedTarget;
|
|
}
|
|
|
|
const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
|
|
const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
|
|
const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
|
|
|
|
const stack = baseRemainder.split('/').filter(Boolean);
|
|
const parts = normalizedTarget.split('/').filter(Boolean);
|
|
for (const part of parts) {
|
|
if (part === '.') {
|
|
continue;
|
|
}
|
|
if (part === '..') {
|
|
if (stack.length > 0) {
|
|
stack.pop();
|
|
}
|
|
continue;
|
|
}
|
|
stack.push(part);
|
|
}
|
|
|
|
if (isWindowsDriveBase) {
|
|
return `${prefix}/${stack.join('/')}`;
|
|
}
|
|
|
|
return `/${stack.join('/')}`;
|
|
};
|
|
|
|
const trimPathCandidate = (value: string): string => {
|
|
let next = (value || '').trim();
|
|
if (!next) {
|
|
return '';
|
|
}
|
|
|
|
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
|
next = next.slice(1, -1).trim();
|
|
}
|
|
|
|
next = next.replace(/[.,;!?]+$/g, '');
|
|
|
|
if (next.endsWith(')') && !next.includes('(')) {
|
|
next = next.slice(0, -1);
|
|
}
|
|
if (next.endsWith(']') && !next.includes('[')) {
|
|
next = next.slice(0, -1);
|
|
}
|
|
|
|
return next;
|
|
};
|
|
|
|
const stripTrailingReference = (value: string): string => {
|
|
let next = trimPathCandidate(value);
|
|
if (!next) {
|
|
return '';
|
|
}
|
|
|
|
const semicolonIndex = next.indexOf(';');
|
|
if (semicolonIndex >= 0) {
|
|
next = next.slice(0, semicolonIndex);
|
|
}
|
|
|
|
next = next.replace(/#.*$/, '');
|
|
|
|
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
|
if (extensionSuffixMatch) {
|
|
next = extensionSuffixMatch[1] ?? next;
|
|
}
|
|
|
|
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
|
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
|
: null;
|
|
if (basenameSuffixMatch) {
|
|
next = basenameSuffixMatch[1] ?? next;
|
|
}
|
|
|
|
return trimPathCandidate(next);
|
|
};
|
|
|
|
const parseFileReference = (value: string): ParsedFileReference | null => {
|
|
const trimmed = trimPathCandidate(value);
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const semicolonIndex = trimmed.indexOf(';');
|
|
const withoutSemicolonSuffix = semicolonIndex >= 0
|
|
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
|
: trimmed;
|
|
if (!withoutSemicolonSuffix) {
|
|
return null;
|
|
}
|
|
|
|
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
|
if (hashMatch) {
|
|
const path = stripTrailingReference(hashMatch[1] ?? '');
|
|
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
|
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
|
if (!path || !Number.isFinite(line)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path,
|
|
line,
|
|
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
|
};
|
|
}
|
|
|
|
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
|
|
if (colonMatch) {
|
|
const path = stripTrailingReference(colonMatch[1] ?? '');
|
|
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
|
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
|
if (!path || !Number.isFinite(line)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path,
|
|
line,
|
|
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
|
};
|
|
}
|
|
|
|
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
|
if (!pathOnly) {
|
|
return null;
|
|
}
|
|
|
|
return { path: pathOnly };
|
|
};
|
|
|
|
const hasFileExtension = (path: string): boolean => {
|
|
const base = path.split('/').filter(Boolean).pop() ?? '';
|
|
if (!base || base.endsWith('.')) {
|
|
return false;
|
|
}
|
|
return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
|
|
};
|
|
|
|
const isLikelyFilePathValue = (path: string): boolean => {
|
|
if (!path || path.startsWith('--') || path.includes('://')) {
|
|
return false;
|
|
}
|
|
|
|
if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
|
|
return false;
|
|
}
|
|
|
|
const normalized = normalizePath(path);
|
|
const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
|
|
if (!baseName || baseName === '.' || baseName === '..') {
|
|
return false;
|
|
}
|
|
|
|
const base = baseName.toLowerCase();
|
|
if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
|
|
return true;
|
|
}
|
|
|
|
return hasFileExtension(normalized);
|
|
};
|
|
|
|
const isLikelyFilePath = (value: string): boolean => {
|
|
const parsed = parseFileReference(value);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
return isLikelyFilePathValue(parsed.path);
|
|
};
|
|
|
|
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
|
if (element.tagName.toLowerCase() === 'a') {
|
|
const href = element.getAttribute('href')?.trim();
|
|
if (href && isLikelyFilePath(href)) {
|
|
return href;
|
|
}
|
|
}
|
|
|
|
return (element.textContent || '').trim();
|
|
};
|
|
|
|
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
|
|
const parsed = parseFileReference(rawValue);
|
|
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
|
|
return null;
|
|
}
|
|
|
|
const resolvedPath = isAbsolutePath(parsed.path)
|
|
? normalizePath(parsed.path)
|
|
: toAbsolutePath(effectiveDirectory, parsed.path);
|
|
if (!resolvedPath) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
resolvedPath,
|
|
};
|
|
};
|
|
|
|
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
|
const normalizedPath = normalizePath(resolvedPath);
|
|
if (!normalizedPath) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
|
|
if (cached) {
|
|
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
|
|
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
|
|
return cached;
|
|
}
|
|
|
|
const request = new Promise<boolean>((resolve) => {
|
|
const run = () => {
|
|
activeFileReferenceStatCount += 1;
|
|
void fetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
})
|
|
.then((response) => resolve(response.ok))
|
|
.catch(() => resolve(false))
|
|
.finally(() => {
|
|
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
|
pendingFileReferenceStats.shift()?.();
|
|
});
|
|
};
|
|
|
|
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
|
run();
|
|
return;
|
|
}
|
|
|
|
pendingFileReferenceStats.push(run);
|
|
});
|
|
|
|
const maxCacheEntries = getFileReferenceStatCacheMax();
|
|
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
|
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
|
if (typeof oldest !== 'string') {
|
|
break;
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
|
|
return request;
|
|
};
|
|
|
|
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
|
const normalizedDirectory = normalizePath(effectiveDirectory);
|
|
if (normalizedDirectory) {
|
|
return normalizedDirectory;
|
|
}
|
|
|
|
const normalizedPath = normalizePath(resolvedPath);
|
|
const parent = normalizedPath.replace(/\/[^/]*$/, '');
|
|
return parent || normalizedPath;
|
|
};
|
|
|
|
const useFileReferenceInteractions = ({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor,
|
|
enabled,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
effectiveDirectory: string;
|
|
editor?: EditorAPI;
|
|
preferRuntimeEditor?: boolean;
|
|
enabled: boolean;
|
|
}) => {
|
|
const annotationDebounceRef = React.useRef<number | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
|
|
|
const clearFileLinkAttributes = (candidate: HTMLElement) => {
|
|
candidate.removeAttribute('data-openchamber-file-link');
|
|
candidate.removeAttribute('data-openchamber-file-ref');
|
|
candidate.removeAttribute('data-openchamber-file-path');
|
|
if (candidate.getAttribute('title') === 'Open file') {
|
|
candidate.removeAttribute('title');
|
|
}
|
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
candidate.removeAttribute('role');
|
|
candidate.removeAttribute('tabindex');
|
|
}
|
|
};
|
|
|
|
const clearAnnotatedFileLinks = () => {
|
|
const annotated = container.querySelectorAll<HTMLElement>(FILE_LINK_SELECTOR);
|
|
for (const candidate of Array.from(annotated)) {
|
|
clearFileLinkAttributes(candidate);
|
|
}
|
|
};
|
|
|
|
if (!enabled) {
|
|
clearAnnotatedFileLinks();
|
|
return;
|
|
}
|
|
|
|
const annotateFileLinks = () => {
|
|
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
|
|
let linkedCount = 0;
|
|
|
|
for (const candidate of Array.from(candidates)) {
|
|
const rawCandidate = extractPathCandidateFromElement(candidate);
|
|
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
|
clearFileLinkAttributes(candidate);
|
|
|
|
if (!resolved) {
|
|
continue;
|
|
}
|
|
|
|
if (linkedCount >= fileReferenceLinkLimit) {
|
|
continue;
|
|
}
|
|
|
|
linkedCount += 1;
|
|
|
|
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
|
|
if (cancelled || !exists || !container.contains(candidate)) {
|
|
return;
|
|
}
|
|
|
|
const latestRawCandidate = extractPathCandidateFromElement(candidate);
|
|
const latestResolved = getResolvedReference(latestRawCandidate, effectiveDirectory);
|
|
if (!latestResolved || latestResolved.resolvedPath !== resolved.resolvedPath) {
|
|
return;
|
|
}
|
|
|
|
candidate.setAttribute('data-openchamber-file-link', 'true');
|
|
candidate.setAttribute('data-openchamber-file-ref', latestRawCandidate);
|
|
candidate.setAttribute('data-openchamber-file-path', latestResolved.resolvedPath);
|
|
candidate.setAttribute('title', 'Open file');
|
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
candidate.setAttribute('role', 'button');
|
|
candidate.setAttribute('tabindex', '0');
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const openFileReference = (sourceElement: HTMLElement) => {
|
|
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
|
|
const resolved = getResolvedReference(raw, effectiveDirectory);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
|
|
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
|
|
if (preferRuntimeEditor && editor) {
|
|
void editor.openFile(
|
|
resolved.resolvedPath,
|
|
Number.isFinite(resolved.line ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.line as number))
|
|
: undefined,
|
|
Number.isFinite(resolved.column ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.column as number))
|
|
: undefined,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const uiStore = useUIStore.getState();
|
|
if (Number.isFinite(resolved.line ?? Number.NaN)) {
|
|
uiStore.openContextFileAtLine(
|
|
contextDirectory,
|
|
resolved.resolvedPath,
|
|
Math.max(1, Math.trunc(resolved.line as number)),
|
|
Number.isFinite(resolved.column ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.column as number))
|
|
: 1,
|
|
);
|
|
} else {
|
|
uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
|
|
}
|
|
};
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const fileRefElement = target.closest(FILE_LINK_SELECTOR);
|
|
if (!(fileRefElement instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
openFileReference(fileRefElement);
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
openFileReference(target);
|
|
};
|
|
|
|
annotateFileLinks();
|
|
|
|
const observer = new MutationObserver(() => {
|
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(annotationDebounceRef.current);
|
|
}
|
|
if (typeof window === 'undefined') {
|
|
annotateFileLinks();
|
|
return;
|
|
}
|
|
annotationDebounceRef.current = window.setTimeout(() => {
|
|
annotationDebounceRef.current = null;
|
|
annotateFileLinks();
|
|
}, 120);
|
|
});
|
|
observer.observe(container, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
|
|
container.addEventListener('click', handleClick);
|
|
container.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(annotationDebounceRef.current);
|
|
}
|
|
annotationDebounceRef.current = null;
|
|
observer.disconnect();
|
|
container.removeEventListener('click', handleClick);
|
|
container.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, enabled]);
|
|
};
|
|
|
|
const useMermaidInlineInteractions = ({
|
|
containerRef,
|
|
mermaidBlocks,
|
|
onShowPopup,
|
|
allowWheelZoom,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
mermaidBlocks: string[];
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
allowWheelZoom?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const handleMermaidClick = (event: MouseEvent) => {
|
|
if (!onShowPopup) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (target.closest('button, a, [role="button"]')) {
|
|
return;
|
|
}
|
|
|
|
const block = target.closest(MERMAID_BLOCK_SELECTOR);
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
|
|
const blockIndex = renderedBlocks.indexOf(block);
|
|
if (blockIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const source = mermaidBlocks[blockIndex];
|
|
if (!source || source.trim().length === 0) {
|
|
return;
|
|
}
|
|
|
|
const filename = `Diagram ${blockIndex + 1}`;
|
|
onShowPopup({
|
|
open: true,
|
|
title: filename,
|
|
content: '',
|
|
metadata: {
|
|
tool: 'mermaid-preview',
|
|
filename,
|
|
},
|
|
mermaid: {
|
|
url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`,
|
|
source,
|
|
filename,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleInlineWheel = (event: WheelEvent) => {
|
|
if (allowWheelZoom) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const block = target.closest(MERMAID_BLOCK_SELECTOR);
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
// Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers.
|
|
event.stopPropagation();
|
|
};
|
|
|
|
container.addEventListener('click', handleMermaidClick);
|
|
container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true });
|
|
|
|
return () => {
|
|
container.removeEventListener('click', handleMermaidClick);
|
|
container.removeEventListener('wheel', handleInlineWheel, true);
|
|
};
|
|
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
|
|
};
|
|
|
|
const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|
content,
|
|
part,
|
|
messageId,
|
|
isAnimated = true,
|
|
skipFadeIn = false,
|
|
className,
|
|
isStreaming = false,
|
|
disableStreamAnimation = false,
|
|
variant = 'assistant',
|
|
onShowPopup,
|
|
enableFileReferences = true,
|
|
}) => {
|
|
const currentTheme = useCurrentMermaidTheme();
|
|
const { editor, runtime } = useRuntimeAPIs();
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
|
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences && !isStreaming,
|
|
});
|
|
useExternalLinkInteractions({ containerRef });
|
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
|
const { t } = useI18n();
|
|
const handlePreviewLoopback = React.useCallback((url: string) => {
|
|
if (!effectiveDirectory) return;
|
|
openContextPreview(effectiveDirectory, url);
|
|
}, [effectiveDirectory, openContextPreview]);
|
|
const previewLabel = t('terminalView.preview.open');
|
|
const previewTitle = t('terminalView.preview.openTitle');
|
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
|
const markdownComponents = React.useMemo(
|
|
() => buildMarkdownComponents({
|
|
syntaxTheme,
|
|
onPreviewLoopback: effectiveDirectory ? handlePreviewLoopback : undefined,
|
|
previewLabel,
|
|
previewTitle,
|
|
}),
|
|
[syntaxTheme, effectiveDirectory, handlePreviewLoopback, previewLabel, previewTitle],
|
|
);
|
|
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
|
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
|
|
|
|
const markdownClassName = variant === 'tool'
|
|
? 'markdown-content markdown-tool'
|
|
: variant === 'reasoning'
|
|
? 'markdown-content markdown-reasoning'
|
|
: 'markdown-content leading-relaxed';
|
|
|
|
const markdownContent = (
|
|
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
|
<div className={markdownClassName}>
|
|
{markdownBlocks.map((block) => (
|
|
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (isAnimated) {
|
|
return (
|
|
<FadeInOnReveal key={componentKey} skipAnimation={skipFadeIn}>
|
|
{markdownContent}
|
|
</FadeInOnReveal>
|
|
);
|
|
}
|
|
|
|
return markdownContent;
|
|
};
|
|
|
|
export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) => {
|
|
return prev.content === next.content
|
|
&& prev.isStreaming === next.isStreaming
|
|
&& prev.disableStreamAnimation === next.disableStreamAnimation
|
|
&& prev.variant === next.variant
|
|
&& prev.isAnimated === next.isAnimated
|
|
&& prev.skipFadeIn === next.skipFadeIn
|
|
&& prev.className === next.className
|
|
&& prev.messageId === next.messageId
|
|
&& prev.onShowPopup === next.onShowPopup
|
|
&& prev.part?.id === next.part?.id;
|
|
});
|
|
|
|
const SimpleMarkdownRendererImpl: React.FC<{
|
|
content: string;
|
|
className?: string;
|
|
variant?: MarkdownVariant;
|
|
disableLinkSafety?: boolean;
|
|
stripFrontmatter?: boolean;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
mermaidControls?: MermaidControlOptions;
|
|
allowMermaidWheelZoom?: boolean;
|
|
enableFileReferences?: boolean;
|
|
}> = ({
|
|
content,
|
|
className,
|
|
variant = 'assistant',
|
|
disableLinkSafety,
|
|
stripFrontmatter = false,
|
|
onShowPopup,
|
|
allowMermaidWheelZoom = false,
|
|
enableFileReferences = true,
|
|
}) => {
|
|
const { editor, runtime } = useRuntimeAPIs();
|
|
const renderedContent = React.useMemo(
|
|
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
|
[content, stripFrontmatter],
|
|
);
|
|
const currentTheme = useCurrentMermaidTheme();
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
|
|
useMermaidInlineInteractions({
|
|
containerRef,
|
|
mermaidBlocks,
|
|
onShowPopup,
|
|
allowWheelZoom: allowMermaidWheelZoom,
|
|
});
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences,
|
|
});
|
|
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
|
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
|
|
const markdownBlocks = useStableMarkdownBlocks(renderedContent, false, `simple:${variant}`);
|
|
|
|
const markdownClassName = variant === 'tool'
|
|
? 'markdown-content markdown-tool'
|
|
: variant === 'reasoning'
|
|
? 'markdown-content markdown-reasoning'
|
|
: 'markdown-content leading-relaxed';
|
|
|
|
return (
|
|
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
|
<div className={markdownClassName}>
|
|
{markdownBlocks.map((block) => (
|
|
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
|
|
return prev.content === next.content
|
|
&& prev.variant === next.variant
|
|
&& prev.className === next.className
|
|
&& prev.disableLinkSafety === next.disableLinkSafety
|
|
&& prev.stripFrontmatter === next.stripFrontmatter
|
|
&& prev.onShowPopup === next.onShowPopup
|
|
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
|
|
&& prev.enableFileReferences === next.enableFileReferences;
|
|
});
|