import { marked } from 'marked'; import Prism from 'prismjs'; import renderMathInElement from 'katex/contrib/auto-render'; 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 transformShowHtmlBlocks(raw: string, isStreaming: boolean) { if (!raw || raw.indexOf('', start); if (openEnd === -1) { if (!isStreaming) { output += raw.slice(start); break; } const partialOpen = raw.slice(start); const ratio = extractShowHtmlRatio(partialOpen); const ratioAttr = ratio ? ` ratio="${ratio}"` : ''; showHtmlDebugLog('md:transform:open-tag-partial', { blockCount, isStreaming, ratio, partialLength: partialOpen.length }); output += `${closeTag}`; break; } const openTag = raw.slice(start, openEnd + 1); const ratio = extractShowHtmlRatio(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, innerPartialLength: innerPartial.length, encodedLength: encoded.length }); const safeOpen = canonicalOpenTag.replace( />$/, encoded ? ` data-encoded="${encoded}" data-partial="1">` : ' data-partial="1">' ); output += `${safeOpen}${closeTag}`; break; } const inner = raw.slice(openEnd + 1, closeStart); const encoded = encodeBase64Utf8(inner); showHtmlDebugLog('md:transform:block-complete', { blockCount, isStreaming, ratio, innerLength: inner.length, encodedLength: encoded.length }); const safeOpen = canonicalOpenTag.replace(/>$/, encoded ? ` data-encoded="${encoded}">` : '>'); output += `${safeOpen}${closeTag}`; cursor = closeStart + closeTag.length; } showHtmlDebugLog('md:transform:end', { isStreaming, blocks: blockCount, outLength: output.length }); return output; } 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}
`; } ); } export function renderMarkdown(text: string, isStreaming = false) { if (!text) { return ''; } if (typeof marked === 'undefined') { return text; } marked.setOptions({ breaks: true, gfm: true }); if (!isStreaming) { const cacheKey = buildCacheKey(text); if (markdownCache.has(cacheKey)) { return markdownCache.get(cacheKey) as string; } } const safeText = transformShowHtmlBlocks(text, isStreaming); const parsed = marked.parse(safeText) as string; let html = parsed; 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 } }); }); }