Files
PlantAI-rag-new/frontend/src/components/chat/markdown.js
T
jerryandClaude Fable 5 1e94de7464 PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:47:52 +08:00

99 lines
3.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;');
}