548 lines
17 KiB
TypeScript
548 lines
17 KiB
TypeScript
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<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 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('<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>';
|
|
const closeTagForMarkdown = '</show-html>';
|
|
|
|
while (cursor < raw.length) {
|
|
const start = raw.indexOf('<show_html', cursor);
|
|
if (start === -1) {
|
|
output += raw.slice(cursor);
|
|
break;
|
|
}
|
|
if (isShowHtmlTagInsideMarkdownCode(raw, start)) {
|
|
// 在 markdown 代码片段里的 <show_html> 仅作为文本展示,不参与渲染替换
|
|
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 += `<show-html${ratioAttr}${jsAttr} data-partial="1">${closeTagForMarkdown}`;
|
|
break;
|
|
}
|
|
|
|
const openTag = raw.slice(start, openEnd + 1);
|
|
const ratio = extractShowHtmlRatio(openTag);
|
|
const jsMode = extractShowHtmlJsMode(openTag);
|
|
const canonicalOpenTag = `<show-html ratio="${ratio}" js="${jsMode}">`;
|
|
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 = '<show_image';
|
|
const closeToken = '</show_image>';
|
|
|
|
while (cursor < raw.length) {
|
|
const openIdx = raw.indexOf(openToken, cursor);
|
|
const closeIdx = raw.indexOf(closeToken, cursor);
|
|
if (openIdx === -1 && closeIdx === -1) {
|
|
output += raw.slice(cursor);
|
|
break;
|
|
}
|
|
const hasOpen = openIdx !== -1;
|
|
const hasClose = closeIdx !== -1;
|
|
const useOpen = hasOpen && (!hasClose || openIdx < closeIdx);
|
|
const hitIdx = useOpen ? openIdx : closeIdx;
|
|
if (hitIdx < 0) {
|
|
output += raw.slice(cursor);
|
|
break;
|
|
}
|
|
if (isShowHtmlTagInsideMarkdownCode(raw, hitIdx)) {
|
|
output += raw.slice(cursor, hitIdx + 1);
|
|
cursor = hitIdx + 1;
|
|
continue;
|
|
}
|
|
output += raw.slice(cursor, hitIdx);
|
|
if (useOpen) {
|
|
output += '<show-image';
|
|
cursor = hitIdx + openToken.length;
|
|
continue;
|
|
}
|
|
output += '</show-image>';
|
|
cursor = hitIdx + closeToken.length;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
type RehypeProps = Record<string, unknown>;
|
|
|
|
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<string, any> = {
|
|
...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(
|
|
/<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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
|
|
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>`;
|
|
}
|
|
);
|
|
}
|
|
|
|
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, '"')
|
|
.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, '<br>');
|
|
}
|
|
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
|
|
}
|
|
});
|
|
});
|
|
}
|