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>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// 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("'", ''');
|
||||
}
|
||||
Reference in New Issue
Block a user