本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

242 lines
8.7 KiB

/**
* Markdown 渲染模块 - 基于 marked 外部脚本
*
* marked v15.0.12 以静态文件方式由 SDK 自动加载,宿主无需手动引入。
* 加载失败或未就绪时降级为纯文本渲染,不丢消息。
*
* XSS 安全策略:
* 1. 自定义 Renderer 全面接管输出,所有类名/标签由 SDK 生成
* 2. 链接/图片 URL 白名单校验(仅 http/https)
* 3. 禁止 raw HTML 渲染
* 4. 代码内容使用 escapeHtml 转义
*/
import { escapeHtml } from './utils';
/** marked 是否已加载并完成配置 */
let markedReady = false;
/** 加载 Promise,用于等待就绪 */
let markedLoadPromise: Promise<boolean> | null = null;
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */
let _markedInstance: any = null;
// ==================== 工具函数 ====================
/** 从当前页面的 chatbot-sdk 脚本 src 推断 SDK 基础路径 */
function getSdkBasePath(): string {
if (typeof document === 'undefined') return '/sdk/';
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts[i].src;
if (src && /\/chatbot-sdk(\.min)?\.js([?#].*)?$/.test(src)) {
return src.replace(/chatbot-sdk(\.min)?\.js.*$/, '');
}
}
return '/sdk/';
}
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */
function isSafeUrl(url: string): boolean {
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim());
}
// ==================== marked 配置 ====================
/**
* 配置 marked 实例
* - 输出统一使用 csk-md-* 类名前缀(兼容现有 styles.ts)
* - 链接/图片仅允许 http/https
* - 禁止 raw HTML
*/
function setupMarked(m: any): void {
// 保存到闭包变量,后续 renderMarkdown 只使用此引用,防止 window.marked 被篡改
_markedInstance = m;
// 全局选项:聊天场景保留换行,启用 GFM
m.setOptions({ breaks: true, gfm: true });
m.use({
renderer: {
/** 标题 → <hN class="csk-md-hN"> */
heading(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<h${token.depth} class="csk-md-h${token.depth}">${text}</h${token.depth}>\n`;
},
/** 段落 → <p class="csk-md-p"> */
paragraph(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<p class="csk-md-p">${text}</p>\n`;
},
/** 代码块 → <pre class="csk-md-code-block"><code> */
code(token: any): string {
const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
return `<pre class="csk-md-code-block"><code${langClass}>${escapeHtml(token.text)}</code></pre>\n`;
},
/** 行内代码 → <code class="csk-md-inline-code"> */
codespan(token: any): string {
return `<code class="csk-md-inline-code">${escapeHtml(token.text)}</code>`;
},
/** 斜体(补充 csk-md-* 类名体系) */
em(this: any, token: any): string {
return `<em class="csk-md-em">${this.parser.parseInline(token.tokens)}</em>`;
},
/** 粗体(补充 csk-md-* 类名体系) */
strong(this: any, token: any): string {
return `<strong class="csk-md-strong">${this.parser.parseInline(token.tokens)}</strong>`;
},
/** 删除线(补充 csk-md-* 类名体系) */
del(this: any, token: any): string {
return `<del class="csk-md-del">${this.parser.parseInline(token.tokens)}</del>`;
},
/** 引用块 → <blockquote class="csk-md-blockquote"> */
blockquote(this: any, token: any): string {
const body = this.parser.parse(token.tokens);
return `<blockquote class="csk-md-blockquote">\n${body}</blockquote>\n`;
},
/** 列表 → <ul class="csk-md-ul"> / <ol class="csk-md-ol"> */
list(this: any, token: any): string {
const tag = token.ordered ? 'ol' : 'ul';
const cls = token.ordered ? 'csk-md-ol' : 'csk-md-ul';
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
// 列表项须用 this.listitem(item) 渲染,不可 this.parser.parse([item]):
// marked v15 的 Parser.parse 按 token.type 做 switch 分发,无 "list_item" 分支,直接 parse 会抛异常
const body = token.items.map((item: any) => this.listitem(item)).join('');
return `<${tag} class="${cls}"${startAttr}>\n${body}</${tag}>\n`;
},
/** 列表项(支持任务列表 checkbox) */
listitem(this: any, token: any): string {
let checkbox = '';
if (token.task) {
checkbox = token.checked
? '<input type="checkbox" checked disabled> '
: '<input type="checkbox" disabled> ';
}
const body = this.parser.parse(token.tokens);
return `<li>${checkbox}${body}</li>\n`;
},
/** 表格(包裹在可滚动容器中) */
table(this: any, token: any): string {
const headerCells = token.header.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<th${align}>${this.parser.parseInline(cell.tokens)}</th>`;
}).join('');
const bodyRows = token.rows.map((row: any) => {
const cells = row.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<td${align}>${this.parser.parseInline(cell.tokens)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<div class="csk-md-table-wrap"><table class="csk-md-table">\n<thead><tr>${headerCells}</tr></thead>\n<tbody>${bodyRows}</tbody>\n</table></div>\n`;
},
/** 链接 → <a class="csk-md-link">(仅允许 http/https) */
link(this: any, token: any): string {
const safeHref = isSafeUrl(token.href) ? token.href : '#';
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
const text = this.parser.parseInline(token.tokens);
return `<a class="csk-md-link" href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
/** 图片 → <img class="csk-md-img">(仅允许 http/https,非法降级为 alt 文本) */
image(token: any): string {
if (!isSafeUrl(token.href)) return escapeHtml(token.text);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
return `<img class="csk-md-img" src="${token.href}" alt="${escapeHtml(token.text)}"${titleAttr}>`;
},
/** 水平线 → <hr class="csk-md-hr"> */
hr(): string {
return '<hr class="csk-md-hr">\n';
},
/** 禁止 raw HTML(关键 XSS 防线) */
html(): string {
return '';
},
},
});
}
// ==================== 公开 API ====================
/**
* 预加载 marked 外部脚本
* 在 SDK init 时调用,尽早开始加载,减少首次渲染时的等待
*/
export function preloadMarked(): void {
if (markedLoadPromise) return;
markedLoadPromise = new Promise<boolean>((resolve) => {
// 1. 检查是否已存在(宿主手动引入或相同域名已加载)
const existing = (window as any).marked;
if (existing && typeof existing.parse === 'function') {
setupMarked(existing);
markedReady = true;
resolve(true);
return;
}
// 2. 动态创建 <script> 加载 marked
const basePath = getSdkBasePath();
const url = basePath + 'marked.min.js';
const el = document.createElement('script');
el.src = url;
el.onload = () => {
const m = (window as any).marked;
if (m && typeof m.parse === 'function') {
setupMarked(m);
markedReady = true;
}
resolve(markedReady);
};
el.onerror = () => {
resolve(false);
};
document.head.appendChild(el);
});
}
/**
* 渲染 Markdown 文本为安全 HTML
*
* @param text Markdown 源文本
* @returns 安全 HTML 字符串
*
* marked 已就绪 → 完整 GFM 渲染
* 未就绪/失败 → 纯文本降级包装在 <p> 中
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
// marked 未就绪 → 降级纯文本
if (!markedReady || !_markedInstance) {
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
}
try {
return _markedInstance.parse(text) as string;
} catch {
// 解析异常降级
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
}
}
/** 导出复位函数,供 destroy() 调用,允许重新 init 时重试加载 marked */
export function resetMarked(): void {
markedReady = false;
_markedInstance = null;
markedLoadPromise = null;
}