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; 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 ( setFailed(true)} /> ); }; // 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 }> = ({ tableRef }) => { const { t } = useI18n(); const [copied, setCopied] = React.useState(false); const [showMenu, setShowMenu] = React.useState(false); const menuRef = React.useRef(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 (
{showMenu && (
)}
); }; // Table download button with dropdown const TableDownloadButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { const { t } = useI18n(); const [showMenu, setShowMenu] = React.useState(false); const menuRef = React.useRef(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 (
{showMenu && (
)}
); }; // Table wrapper with custom controls const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => { const tableRef = React.useRef(null); const { isMobile, isTablet } = useDeviceInfo(); const alwaysShowActions = isMobile || isTablet; return (
{children}
); }; 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 (
{asciiText}
); } if (!svg) { return (
{source}
); } return (
); }; 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([]); 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(code); const timerRef = React.useRef | 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 (
{language}
{canPreview ? ( ) : null} {canPreview ? ( ) : null}
{canPreview && viewMode === 'preview' ? (