Files
PlantAI-rag-new/frontend/src/components/chat/markdown.js
T

99 lines
3.6 KiB
JavaScript
Raw Normal View History

// chat/markdown.js —— LLM 输出的 Markdown → 消毒 HTML(B2 归属)
// FR-CHAT-004:markdown-it 解析 → DOMPurify 消毒 → v-html。
// 自定义渲染规则挂 `md-table` / `code-block` 类(UI_SPEC §4.3)。
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
/** 表头为「说明」的列保持比例字体,其余列 mono(UI_SPEC §4.3) */
export const NON_MONO_HEADER = '说明';
let md = null;
function ensureRenderer() {
if (md) return md;
const inst = new MarkdownIt({
html: false, // LLM 输出先不解析内联 HTML,统一由 DOMPurify 兜底
linkify: true,
breaks: false,
});
inst.renderer.rules.table_open = () => '<table class="md-table">';
inst.renderer.rules.table_close = () => '</table>';
inst.renderer.rules.thead_open = () => '<thead>';
inst.renderer.rules.tbody_open = () => '<tbody>';
inst.renderer.rules.th_open = () => '<th>';
inst.renderer.rules.td_open = () => '<td>';
// 围栏代码块(```)走 fence 规则,缩进代码块走 code_block 规则,两者统一挂 code-block 类
const codeBlock = (tokens, idx) =>
`<div class="code-block">${inst.utils.escapeHtml(tokens[idx].content)}</div>`;
inst.renderer.rules.fence = codeBlock;
inst.renderer.rules.code_block = codeBlock;
// 数据格 mono:表头为「说明」的列除外。
// markdown-it 的 td 行内内容在 td_open 时尚不可用,但 thead 一定先于 tbody 渲染,
// 故在 th_open 时记录当前列表头文本即可(渲染为同步单线程,无并发交错)。
const cellText = (token) => {
let s = '';
for (const t of token.children || []) s += t.content || '';
return s;
};
const prevTh = { value: '' };
const _thOpen = inst.renderer.rules.th_open;
const _tdOpen = inst.renderer.rules.td_open;
inst.renderer.rules.th_open = (tokens, idx, options, env, self) => {
prevTh.value = cellText(tokens[idx]);
return _thOpen(tokens, idx, options, env, self);
};
inst.renderer.rules.td_open = (tokens, idx, options, env, self) => {
const open = _tdOpen(tokens, idx, options, env, self);
return prevTh.value !== NON_MONO_HEADER ? open.replace(/<td/, '<td class="mono"') : open;
};
md = inst;
return md;
}
/** DOMPurify 实例:浏览器/jsdoms 默认导出即实例;工厂形态(Node 裸环境)按需构造 */
function getPurify() {
if (DOMPurify && typeof DOMPurify.sanitize === 'function') return DOMPurify;
if (typeof DOMPurify === 'function' && typeof window !== 'undefined') {
try {
return DOMPurify(window);
} catch {
return null;
}
}
return null;
}
/** markdown 文本 → 消毒后的 HTML(失败或无消毒环境返回转义原文,不抛异常) */
export function renderMarkdown(text) {
const src = String(text == null ? '' : text);
try {
const html = ensureRenderer().render(src);
const purify = getPurify();
return purify ? purify.sanitize(html) : html;
} catch {
const purify = getPurify();
return purify ? purify.sanitize(escapeHtml(src)) : escapeHtml(src);
}
}
/**
* 已渲染 HTML(块联合 text.html:创建时已由 renderMarkdown 生成,
* 或后端落库的消毒 HTML)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
*/
export function sanitizeHtml(html) {
const purify = getPurify();
if (!purify) return String(html == null ? '' : html);
return purify.sanitize(String(html == null ? '' : html));
}
function escapeHtml(s) {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}