import Prism from 'prismjs'; import katex from 'katex'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import remarkRehype from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeStringify from 'rehype-stringify'; import { visit } from 'unist-util-visit'; let latexRenderTimer: number | null = null; let streamingCodeHighlightTimer: number | null = null; const markdownCache = new Map(); let showHtmlDebugCount = 0; const SHOW_HTML_DEBUG_MAX = 500; function isShowHtmlDebugEnabled() { if (typeof window === 'undefined') return false; try { const flag = (window as any).__SHOW_HTML_DEBUG__; if (flag === true || flag === '1') return true; if (flag === false || flag === '0') return false; const localFlag = window.localStorage?.getItem('showHtmlDebug'); if (localFlag === '1' || localFlag === 'true') return true; return false; } catch { return false; } } function showHtmlDebugLog(event: string, payload: Record = {}) { if (!isShowHtmlDebugEnabled()) return; if (showHtmlDebugCount >= SHOW_HTML_DEBUG_MAX) return; showHtmlDebugCount += 1; if (showHtmlDebugCount === SHOW_HTML_DEBUG_MAX) { console.warn('[SHOW_HTML_DEBUG]', 'log-limit-reached', { max: SHOW_HTML_DEBUG_MAX }); return; } console.log('[SHOW_HTML_DEBUG]', event, payload); } function encodeBase64Utf8(input: string) { try { return btoa(unescape(encodeURIComponent(input))); } catch { return ''; } } function escapeHtmlAttribute(input: string): string { return input .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const SHOW_HTML_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const; const SHOW_HTML_RATIO_VALUES: Record = { '1:1': 1, '16:9': 16 / 9, '9:16': 9 / 16, '4:3': 4 / 3, '3:4': 3 / 4 }; function normalizeShowHtmlRatio(raw: string | null | undefined) { if (!raw) return '1:1'; const cleaned = raw.trim().toLowerCase().replace(/[x/]/g, ':').replace(/\s+/g, ''); if ((SHOW_HTML_ALLOWED_RATIOS as readonly string[]).includes(cleaned)) return cleaned; return '1:1'; } function pickNearestAllowedRatio(width: number, height: number) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { return '1:1'; } const target = width / height; let best = '1:1'; let bestDiff = Number.POSITIVE_INFINITY; for (const ratio of SHOW_HTML_ALLOWED_RATIOS) { const diff = Math.abs(SHOW_HTML_RATIO_VALUES[ratio] - target); if (diff < bestDiff) { bestDiff = diff; best = ratio; } } return best; } function extractShowHtmlRatio(tagSource: string) { const ratioMatch = tagSource.match(/ratio\s*=\s*["']?\s*([0-9]{1,2}\s*[:xX/]\s*[0-9]{1,2})/i); if (ratioMatch?.[1]) { return normalizeShowHtmlRatio(ratioMatch[1]); } const widthMatch = tagSource.match(/width\s*=\s*["']?(\d{1,4})/i); const heightMatch = tagSource.match(/height\s*=\s*["']?(\d{1,4})/i); if (widthMatch?.[1] && heightMatch?.[1]) { const width = Number.parseInt(widthMatch[1], 10); const height = Number.parseInt(heightMatch[1], 10); return pickNearestAllowedRatio(width, height); } return '1:1'; } function normalizeShowHtmlJsMode(raw: string | null | undefined) { if (!raw) return 'off'; const cleaned = raw.trim().toLowerCase(); if (['on', '1', 'true', 'yes', 'enabled', 'enable'].includes(cleaned)) { return 'on'; } return 'off'; } function extractShowHtmlJsMode(tagSource: string) { const jsMatch = tagSource.match(/js\s*=\s*["']?\s*([a-zA-Z0-9_-]+)/i); if (jsMatch?.[1]) { return normalizeShowHtmlJsMode(jsMatch[1]); } return 'off'; } function isShowHtmlTagInsideMarkdownCode(raw: string, tagIndex: number) { if (!raw || tagIndex <= 0) return false; let i = 0; let inFence = false; let inInlineCode = false; while (i < tagIndex) { // fenced code block: ``` if (!inInlineCode && raw.startsWith('```', i)) { inFence = !inFence; i += 3; continue; } if (!inFence && raw[i] === '`') { // 处理转义反引号 \` const escaped = i > 0 && raw[i - 1] === '\\'; if (!escaped) { inInlineCode = !inInlineCode; } i += 1; continue; } i += 1; } return inFence || inInlineCode; } function transformShowHtmlBlocks(raw: string, isStreaming: boolean) { if (!raw || raw.indexOf(' 仅作为文本展示,不参与渲染替换 output += raw.slice(cursor, start + 1); cursor = start + 1; continue; } blockCount += 1; output += raw.slice(cursor, start); const openEnd = raw.indexOf('>', start); if (openEnd === -1) { if (!isStreaming) { output += raw.slice(start); break; } const partialOpen = raw.slice(start); const ratio = extractShowHtmlRatio(partialOpen); const jsMode = extractShowHtmlJsMode(partialOpen); const ratioAttr = ratio ? ` ratio="${ratio}"` : ''; const jsAttr = ` js="${jsMode}"`; showHtmlDebugLog('md:transform:open-tag-partial', { blockCount, isStreaming, ratio, jsMode, partialLength: partialOpen.length }); output += `${closeTagForMarkdown}`; break; } const openTag = raw.slice(start, openEnd + 1); const ratio = extractShowHtmlRatio(openTag); const jsMode = extractShowHtmlJsMode(openTag); const canonicalOpenTag = ``; const closeStart = raw.indexOf(closeTag, openEnd + 1); if (closeStart === -1) { if (!isStreaming) { output += raw.slice(start); break; } const innerPartial = raw.slice(openEnd + 1); const encoded = encodeBase64Utf8(innerPartial); showHtmlDebugLog('md:transform:close-tag-partial', { blockCount, isStreaming, ratio, jsMode, innerPartialLength: innerPartial.length, encodedLength: encoded.length }); const safeOpen = canonicalOpenTag.replace( />$/, encoded ? ` data-encoded="${encoded}" data-partial="1">` : ' data-partial="1">' ); output += `${safeOpen}${closeTagForMarkdown}`; break; } const inner = raw.slice(openEnd + 1, closeStart); const encoded = encodeBase64Utf8(inner); showHtmlDebugLog('md:transform:block-complete', { blockCount, isStreaming, ratio, jsMode, innerLength: inner.length, encodedLength: encoded.length }); const safeOpen = canonicalOpenTag.replace(/>$/, encoded ? ` data-encoded="${encoded}">` : '>'); output += `${safeOpen}${closeTagForMarkdown}`; cursor = closeStart + closeTag.length; } showHtmlDebugLog('md:transform:end', { isStreaming, blocks: blockCount, outLength: output.length }); return output; } function transformShowImageBlocks(raw: string) { if (!raw || raw.indexOf('show_image') === -1) return raw; let output = ''; let cursor = 0; const openToken = ' 标签(自闭合或开闭形式)。 * markdown parser 在 inline 位置会把自定义标签当文本转义, * 转成显式闭合形式后可以被 rehypeRaw 识别为 raw HTML 元素。 * show_file 不含富文本内容,不需要 base64 编码。 */ function transformShowFileBlocks(raw: string) { if (!raw || (raw.indexOf('show_file') === -1 && raw.indexOf('show-file') === -1)) return raw; // 1. 自闭合: let output = raw.replace( /]*?)\s*\/>/gi, (match, attrs: string) => `` ); // 2. 开闭形式: ... ,忽略内部内容,只保留属性 output = output.replace( /]*)>([\s\S]*?)<\/show[_-]file>/gi, (match, attrs: string) => `` ); return output; } /** * 预处理 LaTeX 数学公式,避免被 markdown parser 拆段。 * $$...$$ 转换为块级占位符,$...$ 转换为行内占位符, * 后续由 renderMathBlocks / renderMathInElement 统一渲染。 */ function transformMathBlocks(raw: string): string { if (!raw || raw.indexOf('$') === -1) return raw; // 先保护代码块和行内代码,避免其中的 $ 被误替换 const CODE_BLOCK_PLACEHOLDER = '__MATH_PROTECT_CODE_BLOCK_'; const INLINE_CODE_PLACEHOLDER = '__MATH_PROTECT_INLINE_CODE_'; const codeBlocks: string[] = []; let protectedText = raw.replace(/```[\s\S]*?```/g, (match) => { codeBlocks.push(match); return `${CODE_BLOCK_PLACEHOLDER}${codeBlocks.length - 1}__`; }); const inlineCodes: string[] = []; protectedText = protectedText.replace(/`[^`\n]+`/g, (match) => { inlineCodes.push(match); return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}__`; }); // 块级 $$...$$ protectedText = protectedText.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex: string) => { const trimmed = latex.replace(/\s+/g, ' ').trim(); if (!trimmed) return match; return ``; }); // 行内 $...$(排除 $ 本身和反斜杠转义) protectedText = protectedText.replace( /(? { const trimmed = latex.replace(/\s+/g, ' ').trim(); if (!trimmed) return match; return ``; } ); // 还原保护内容 protectedText = protectedText.replace( new RegExp(`${CODE_BLOCK_PLACEHOLDER}(\\d+)__`, 'g'), (_, i: string) => codeBlocks[Number(i)] ); protectedText = protectedText.replace( new RegExp(`${INLINE_CODE_PLACEHOLDER}(\\d+)__`, 'g'), (_, i: string) => inlineCodes[Number(i)] ); return protectedText; } type RehypeProps = Record; function readAttr(props: RehypeProps | undefined, name: string): string { if (!props) return ''; const direct = props[name]; if (typeof direct === 'string') return direct; const camel = name.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase()); const camelVal = props[camel]; if (typeof camelVal === 'string') return camelVal; return ''; } function writeAttr(props: RehypeProps, name: string, value: string | null) { const camel = name.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase()); if (value == null || value === '') { delete props[name]; delete props[camel]; return; } props[name] = value; } // show_file 预览类型:text/code/json/csv/markdown/image/pdf/binary const SHOW_FILE_PREVIEW_TYPES = new Set([ 'text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf', 'binary' ]); const SHOW_FILE_EXT_TO_TYPE: Record = { '.txt': 'text', '.md': 'markdown', '.markdown': 'markdown', '.json': 'json', '.json5': 'json', '.csv': 'csv', '.tsv': 'csv', '.yaml': 'text', '.yml': 'text', '.toml': 'text', '.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text', '.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code', '.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code', '.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code', '.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code', '.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code', '.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code', '.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code', '.png': 'image', '.jpg': 'image', '.jpeg': 'image', '.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image', '.avif': 'image', '.pdf': 'pdf', }; function inferShowFileType(path: string, explicit?: string): string { if (explicit && SHOW_FILE_PREVIEW_TYPES.has(explicit)) return explicit; const lowerPath = path.toLowerCase(); const dotIdx = lowerPath.lastIndexOf('.'); if (dotIdx >= 0) { const ext = lowerPath.slice(dotIdx); if (SHOW_FILE_EXT_TO_TYPE[ext]) return SHOW_FILE_EXT_TO_TYPE[ext]; } return 'binary'; } function normalizeShowTagsPlugin() { return (tree: any) => { visit(tree, 'element', (node: any) => { if (!node || typeof node.tagName !== 'string') return; if (node.tagName === 'show_html' || node.tagName === 'show-html') { node.tagName = 'show_html'; const props = (node.properties || {}) as RehypeProps; const ratio = normalizeShowHtmlRatio(readAttr(props, 'ratio')); const jsMode = normalizeShowHtmlJsMode(readAttr(props, 'js')); const encoded = readAttr(props, 'data-encoded'); const partial = readAttr(props, 'data-partial'); writeAttr(props, 'ratio', ratio); writeAttr(props, 'js', jsMode); writeAttr(props, 'data-encoded', encoded); writeAttr(props, 'data-partial', partial === '1' ? '1' : null); node.properties = props; } if (node.tagName === 'show_image' || node.tagName === 'show-image') { node.tagName = 'show_image'; const props = (node.properties || {}) as RehypeProps; const src = readAttr(props, 'src').trim(); const alt = readAttr(props, 'alt').trim(); const title = readAttr(props, 'title').trim(); writeAttr(props, 'src', src); writeAttr(props, 'alt', alt || title || ''); if (!title) { writeAttr(props, 'title', null); } node.properties = props; } if (node.tagName === 'show_file' || node.tagName === 'show-file') { node.tagName = 'show_file'; const props = (node.properties || {}) as RehypeProps; const rawPath = readAttr(props, 'path').trim(); const path = rawPath.replace(/^\/+/, ''); writeAttr(props, 'path', path); node.properties = props; } }); }; } /** * 拦截 markdown 链接里的 download:// 协议,标记为下载链接。 * 输入:[文件名.pdf](download:///output/file.pdf) 或 (download://output/file.pdf) * 输出:文件名.pdf * 点击事件由 bootstrap.ts 统一拦截。 */ function transformDownloadLinksPlugin() { return (tree: any) => { visit(tree, 'element', (node: any) => { if (!node || node.tagName !== 'a' || !node.properties) return; const href = node.properties.href; if (typeof href !== 'string') return; // 兼容 download:///path(三个斜杠,旧写法)和 download://path(两个斜杠,推荐写法) const match = href.match(/^download:\/\/\/?(.*)$/i); if (!match) return; const rawPath = match[1]; const path = rawPath.replace(/^\/+/, ''); if (!path) return; node.properties.href = `download://${path}`; node.properties['data-path'] = path; node.properties['data-download'] = '1'; const existingClass = node.properties.className; if (Array.isArray(existingClass)) { if (!existingClass.includes('md-download-link')) { existingClass.push('md-download-link'); } } else { node.properties.className = ['md-download-link']; } }); }; } function wrapMarkdownTablesPlugin() { return (tree: any) => { visit(tree, 'element', (node: any, index: number | undefined, parent: any) => { if (!node || node.tagName !== 'table' || !parent || typeof index !== 'number') return; if (parent?.tagName === 'div' && Array.isArray(parent?.properties?.className)) { const classes = parent.properties.className as string[]; if (classes.includes('md-table-scroll')) { return; } } parent.children[index] = { type: 'element', tagName: 'div', properties: { className: ['md-table-scroll'], 'data-md-table-scroll': '1' }, children: [node] }; }); }; } const sanitizedSchema: Record = { ...defaultSchema, tagNames: [ ...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show_file', 'show-html', 'show-image', 'show-file' ], attributes: { ...(defaultSchema.attributes || {}), div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'], span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered'], a: [ 'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy', 'dataFootnoteBackref', 'dataFootnoteRef', 'data-path', 'data-download', 'href', // className: 合并默认的 data-footnote-backref 和我们的 md-download-link ['className', 'data-footnote-backref', 'md-download-link'] ], show_html: ['ratio', 'js', 'data-encoded', 'data-partial'], show_image: ['src', 'alt', 'title', 'width', 'height'], show_file: ['path'], 'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'], 'show-image': ['src', 'alt', 'title', 'width', 'height'], 'show-file': ['path'] }, // 允许 链接 protocols: { ...(defaultSchema.protocols || {}), href: [ ...((defaultSchema.protocols || {}).href || []), 'download' ] } }; const markdownProcessor = unified() .use(remarkParse) .use(remarkGfm) .use(remarkBreaks) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(normalizeShowTagsPlugin) .use(transformDownloadLinksPlugin) .use(wrapMarkdownTablesPlugin) .use(rehypeSanitize, sanitizedSchema) .use(rehypeStringify, { allowDangerousHtml: false }); function stableHash(input: string): string { let hash = 5381; for (let i = 0; i < input.length; i += 1) { hash = (hash * 33) ^ input.charCodeAt(i); } return (hash >>> 0).toString(36); } function buildCacheKey(text: string): string { return `md_${text.length}_${stableHash(text)}`; } function wrapCodeBlocks(html: string, isStreaming = false) { return html.replace( /
]*)>([\s\S]*?)<\/code><\/pre>/g,
    (match, attributes, content) => {
      const langMatch = attributes.match(/class="[^"]*language-([\w-]+)/);
      const language = langMatch ? langMatch[1] : 'text';
      const blockId = `code-${stableHash(`${attributes}|${content}`)}`;
      const streamingAttr = isStreaming ? ' data-streaming="1"' : '';
      const escapedContent = content
        .replace(/&/g, '&')
        .replace(//g, '>')
        .replace(/"/g, '"');

      return `
            
${language}
${content}
`; } ); } function scheduleStreamingCodeHighlight() { if (typeof window === 'undefined') return; if (streamingCodeHighlightTimer !== null) return; streamingCodeHighlightTimer = window.setTimeout(() => { streamingCodeHighlightTimer = null; if (typeof Prism === 'undefined') return; const codeBlocks = document.querySelectorAll('.code-block-wrapper[data-streaming="1"] pre code'); codeBlocks.forEach((block) => { try { Prism.highlightElement(block as HTMLElement); } catch (error) { console.warn('流式代码高亮失败:', error); } }); }, 120); } function renderMarkdownToHtml(text: string): string { const file = markdownProcessor.processSync(text); return String(file); } export interface MarkdownSegment { type: 'text' | 'code'; content: string; language?: string; closed: boolean; key: string; } function hashCodeBlock(language: string, content: string): string { return stableHash(`${language || ''}|${content}`); } export function parseMarkdownSegments(text: string, isStreaming = false): MarkdownSegment[] { if (!text) { return [{ type: 'text', content: '', closed: true, key: 'text-0' }]; } const segments: MarkdownSegment[] = []; let cursor = 0; const regex = /^```([^\n]*)\n([\s\S]*?)^```/gm; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { if (match.index > cursor) { segments.push({ type: 'text', content: text.slice(cursor, match.index), closed: true, key: `text-${cursor}` }); } const language = match[1].trim(); const content = match[2]; const key = `code-${hashCodeBlock(language, content)}`; segments.push({ type: 'code', content, language, closed: true, key }); cursor = regex.lastIndex; } if (cursor < text.length) { const remaining = text.slice(cursor); if (isStreaming) { const openMatch = remaining.match(/^```([^\n]*)\n([\s\S]*)$/m); if (openMatch) { if (openMatch.index && openMatch.index > 0) { segments.push({ type: 'text', content: remaining.slice(0, openMatch.index), closed: true, key: `text-${cursor}` }); } const language = openMatch[1].trim(); const content = openMatch[2]; segments.push({ type: 'code', content, language, closed: false, key: 'code-streaming-active' }); return segments; } } segments.push({ type: 'text', content: remaining, closed: true, key: `text-${cursor}` }); } return segments; } export function renderMarkdownText(text: string): string { if (!text) return ''; const safeText = transformMathBlocks( transformShowFileBlocks( transformShowImageBlocks(transformShowHtmlBlocks(text, false)) ) ); let html = ''; try { html = renderMarkdownToHtml(safeText); } catch (error) { console.warn('Markdown 渲染失败,降级为纯文本渲染:', error); html = escapeHtml(safeText).replace(/\n/g, '
'); } return html; } export function renderMathBlocks(container: HTMLElement) { if (!container || typeof window === 'undefined') return; const elements = container.querySelectorAll( '.math-block:not([data-math-rendered]), .math-inline:not([data-math-rendered])' ); elements.forEach((el) => { const latex = el.getAttribute('data-latex'); if (!latex) return; const display = el.getAttribute('data-display') === 'block'; try { el.innerHTML = katex.renderToString(latex, { throwOnError: false, displayMode: display, trust: true }); el.setAttribute('data-math-rendered', 'true'); } catch (error) { console.warn('LaTeX渲染失败:', error); el.setAttribute('data-math-rendered', 'error'); } }); } function renderMathBlocksForSelector(selector: string) { if (typeof document === 'undefined') return; const elements = document.querySelectorAll(selector); elements.forEach((el) => renderMathBlocks(el as HTMLElement)); } function escapeHtml(input: string) { return input .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function renderMarkdown(text: string, isStreaming = false) { if (!text) { return ''; } if (!isStreaming) { const cacheKey = buildCacheKey(text); if (markdownCache.has(cacheKey)) { return markdownCache.get(cacheKey) as string; } } const safeText = transformMathBlocks( transformShowFileBlocks( transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming)) ) ); let html = ''; try { html = renderMarkdownToHtml(safeText); } catch (error) { console.warn('Markdown 渲染失败,降级为纯文本渲染:', error); html = escapeHtml(safeText).replace(/\n/g, '
'); } html = wrapCodeBlocks(html, isStreaming); if (isStreaming) { scheduleStreamingCodeHighlight(); } if (!isStreaming && text.length < 10000) { const cacheKey = buildCacheKey(text); markdownCache.set(cacheKey, html); if (markdownCache.size > 20) { const firstKey = markdownCache.keys().next().value as string | undefined; if (firstKey) { markdownCache.delete(firstKey); } } } if (!isStreaming) { setTimeout(() => { if (typeof Prism !== 'undefined') { const codeBlocks = document.querySelectorAll( '.code-block-wrapper pre code:not([data-highlighted])' ); codeBlocks.forEach((block) => { try { Prism.highlightElement(block as HTMLElement); block.setAttribute('data-highlighted', 'true'); } catch (error) { console.warn('代码高亮失败:', error); } }); } renderMathBlocksForSelector('.text-output .text-content:not(.streaming-text)'); }, 100); } return html; } export function renderLatexInRealtime() { if (typeof window === 'undefined') { return; } if (latexRenderTimer) { cancelAnimationFrame(latexRenderTimer); } latexRenderTimer = requestAnimationFrame(() => { const elements = document.querySelectorAll('.text-output .streaming-text'); elements.forEach((element) => { renderMathBlocks(element as HTMLElement); }); }); }