- 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>
99 lines
3.6 KiB
JavaScript
99 lines
3.6 KiB
JavaScript
// 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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|