agent-Specialization/static/src/composables/useMarkdownRenderer.ts

332 lines
9.8 KiB
TypeScript

import { marked } from 'marked';
import Prism from 'prismjs';
import renderMathInElement from 'katex/contrib/auto-render';
let latexRenderTimer: number | null = null;
const markdownCache = new Map<string, string>();
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<string, any> = {}) {
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<string, number> = {
'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('<show_html') === -1) return raw;
showHtmlDebugLog('md:transform:start', {
isStreaming,
rawLength: raw.length
});
let output = '';
let cursor = 0;
let blockCount = 0;
const closeTag = '</show_html>';
while (cursor < raw.length) {
const start = raw.indexOf('<show_html', cursor);
if (start === -1) {
output += raw.slice(cursor);
break;
}
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 ratioAttr = ratio ? ` ratio="${ratio}"` : '';
showHtmlDebugLog('md:transform:open-tag-partial', {
blockCount,
isStreaming,
ratio,
partialLength: partialOpen.length
});
output += `<show_html${ratioAttr} data-partial="1">${closeTag}`;
break;
}
const openTag = raw.slice(start, openEnd + 1);
const ratio = extractShowHtmlRatio(openTag);
const canonicalOpenTag = `<show_html ratio="${ratio}">`;
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(
/<pre><code([^>]*)>([\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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return `
<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-language">${language}</span>
<button class="copy-code-btn" data-code="${blockId}" title="复制代码" aria-label="复制代码"></button>
</div>
<pre><code${attributes} data-code-id="${blockId}" data-original-code="${escapedContent}">${content}</code></pre>
</div>`;
}
);
}
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
}
});
});
}