import Prism from 'prismjs'; import renderMathInElement from 'katex/contrib/auto-render'; 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; const markdownCache = new Map(); let showHtmlDebugCount = 0; const SHOW_HTML_DEBUG_MAX = 500; function isShowHtmlDebugEnabled() { if (typeof window === 'undefined') return true; try { const flag = (window as any).__SHOW_HTML_DEBUG__; if (flag === false || flag === '0') return false; if (flag === true || flag === '1') return true; const localFlag = window.localStorage?.getItem('showHtmlDebug'); if (localFlag === '0' || localFlag === 'false') return false; if (localFlag === '1' || localFlag === 'true') return true; return true; } catch { return true; } } 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 ''; } } 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 = '; 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; } 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; } }); }; } 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-html', 'show-image'], attributes: { ...(defaultSchema.attributes || {}), div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'], show_html: ['ratio', 'js', 'data-encoded', 'data-partial'], show_image: ['src', 'alt', 'title', 'width', 'height'], 'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'], 'show-image': ['src', 'alt', 'title', 'width', 'height'] } }; const markdownProcessor = unified() .use(remarkParse) .use(remarkGfm) .use(remarkBreaks) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(normalizeShowTagsPlugin) .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) { if (isStreaming) { return html; } let counter = 0; 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}|${counter++}`)}`;
      const escapedContent = content
        .replace(/&/g, '&')
        .replace(//g, '>')
        .replace(/"/g, '"');

      return `
            
${language}
${content}
`; } ); } function renderMarkdownToHtml(text: string): string { const file = markdownProcessor.processSync(text); return String(file); } 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 = 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 && 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); } }); } if (typeof renderMathInElement !== 'undefined') { const elements = document.querySelectorAll( '.text-output .text-content:not(.streaming-text)' ); elements.forEach((element) => { if (element.hasAttribute('data-math-rendered')) return; try { renderMathInElement(element as HTMLElement, { delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\[', right: '\\]', display: true }, { left: '\\(', right: '\\)', display: false } ], throwOnError: false, trust: true }); element.setAttribute('data-math-rendered', 'true'); } catch (error) { console.warn('LaTeX渲染失败:', error); } }); } }, 100); } return html; } export function renderLatexInRealtime() { if (typeof renderMathInElement === 'undefined') { return; } if (latexRenderTimer) { cancelAnimationFrame(latexRenderTimer); } latexRenderTimer = requestAnimationFrame(() => { const elements = document.querySelectorAll('.text-output .streaming-text'); elements.forEach((element) => { try { renderMathInElement(element as HTMLElement, { delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\[', right: '\\]', display: true }, { left: '\\(', right: '\\)', display: false } ], throwOnError: false, trust: true }); } catch { // ignore } }); }); }