Files
Agent-System/web/src/engine/replyDocument/parseReplyMarkdown.ts
T
WeiCHEN 1fc7d0d01f feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API
- Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制
- Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动
- 专项修复:
  1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表
  2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply)
  3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
2026-09-13 02:50:21 +00:00

330 lines
9.6 KiB
TypeScript

import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
import { sanitizeLinkHref, stripUnsafeHtml } from './sanitize';
/**
* 将规范化 Markdown 解析为 Reply Document IR
* 不依赖 DOM,可在浏览器、Web Worker 以及 Node/SSR 环境下运行
*/
export function parseReplyMarkdown(markdown: string, defaultTitle?: string): ReplyDocumentIR {
const cleanMd = stripUnsafeHtml(markdown || '').replace(/\r\n/g, '\n');
const lines = cleanMd.split('\n');
const nodes: ReplyNode[] = [];
let title = defaultTitle || '';
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 空行跳过
if (trimmed === '') {
i++;
continue
}
// 1. 代码块与 Mermaid 围栏
if (trimmed.startsWith('```')) {
const info = trimmed.slice(3).trim();
const isMermaid = info === 'mermaid';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // 跳过闭合 ```
const rawCode = codeLines.join('\n');
if (isMermaid) {
nodes.push({ type: 'mermaid', source: rawCode });
} else {
nodes.push({ type: 'code', language: info || undefined, value: rawCode });
}
continue;
}
// 2. 独立块级数学公式 $$ ... $$
if (trimmed.startsWith('$$')) {
const mathLines: string[] = [];
if (trimmed.length > 2 && trimmed.endsWith('$$')) {
// 单行 $$公式$$
mathLines.push(trimmed.slice(2, -2).trim());
i++;
} else {
i++;
while (i < lines.length && !lines[i].trim().endsWith('$$')) {
mathLines.push(lines[i]);
i++;
}
if (i < lines.length) {
const last = lines[i].trim();
mathLines.push(last.slice(0, -2).trim());
i++;
}
}
nodes.push({ type: 'math', display: true, latex: mathLines.join('\n').trim() });
continue;
}
// 3. 分割线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) {
nodes.push({ type: 'divider' });
i++;
continue;
}
// 4. 标题 (# ~ ######)
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
const headingText = headingMatch[2].trim();
if (!title && (level === 1 || level === 2)) {
title = headingText.replace(/[*_`~]/g, '');
}
nodes.push({
type: 'heading',
level,
children: parseInlineContent(headingText),
});
i++;
continue;
}
// 5. 引用块 (> ...)
if (trimmed.startsWith('>')) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
// 递归解析引用块内子节点
const subIR = parseReplyMarkdown(quoteLines.join('\n'));
nodes.push({
type: 'blockquote',
children: subIR.nodes,
});
continue;
}
// 6. 表格 (| ... |)
if (trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.includes('|')) {
const tableLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) {
tableLines.push(lines[i].trim());
i++;
}
if (tableLines.length >= 2) {
const headerCols = splitTableRow(tableLines[0]);
// 第二行通常是分隔线 |---|---|
let startIndex = 1;
if (/^\|(\s*:?-+:?\s*\|)+$/.test(tableLines[1])) {
startIndex = 2;
}
const rows: ReplyInline[][][] = [];
for (let r = startIndex; r < tableLines.length; r++) {
const cells = splitTableRow(tableLines[r]);
rows.push(cells.map((c) => parseInlineContent(c)));
}
nodes.push({
type: 'table',
header: headerCols.map((c) => parseInlineContent(c)),
rows,
});
continue;
}
}
// 7. 列表项 (有序 1. 或 无序 - / * )
const listOrderedMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
const listUnorderedMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (listOrderedMatch || listUnorderedMatch) {
const isOrdered = !!listOrderedMatch;
const items: ReplyInline[][] = [];
while (i < lines.length) {
const curTrim = lines[i].trim();
const mOrder = curTrim.match(/^(\d+)\.\s+(.+)$/);
const mUnorder = curTrim.match(/^[-*+]\s+(.+)$/);
if (isOrdered && mOrder) {
items.push(parseInlineContent(mOrder[2]));
i++;
} else if (!isOrdered && mUnorder) {
items.push(parseInlineContent(mUnorder[1]));
i++;
} else if (curTrim === '') {
// 列表中的空行探测后续是否还是同类列表项
if (i + 1 < lines.length) {
const nextTrim = lines[i + 1].trim();
if ((isOrdered && nextTrim.match(/^(\d+)\.\s+/)) || (!isOrdered && nextTrim.match(/^[-*+]\s+/))) {
i++;
continue;
}
}
break;
} else {
break;
}
}
nodes.push({
type: 'list',
ordered: isOrdered,
items,
});
continue;
}
// 8. 普通段落 (汇聚连续非空普通文本行)
const paraLines: string[] = [];
while (i < lines.length) {
const cur = lines[i];
const curTrim = cur.trim();
if (
curTrim === '' ||
curTrim.startsWith('#') ||
curTrim.startsWith('```') ||
curTrim.startsWith('$$') ||
curTrim.startsWith('>') ||
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
curTrim.match(/^(\d+)\.\s+/) ||
curTrim.match(/^[-*+]\s+/) ||
/^(\*{3,}|-{3,}|_{3,})$/.test(curTrim)
) {
break;
}
paraLines.push(curTrim);
i++;
}
if (paraLines.length > 0) {
const paraText = paraLines.join(' ');
if (!title) {
const plain = paraText.replace(/[*_`~]/g, '');
title = plain.length > 30 ? plain.slice(0, 30) : plain;
}
nodes.push({
type: 'paragraph',
children: parseInlineContent(paraText),
});
}
}
if (!title) {
title = '智能体回复摘录';
}
return { title, nodes };
}
/**
* 分割 Markdown 表格行 (| cell1 | cell2 |)
*/
function splitTableRow(rowStr: string): string[] {
let inner = rowStr.trim();
if (inner.startsWith('|')) inner = inner.slice(1);
if (inner.endsWith('|')) inner = inner.slice(0, -1);
return inner.split('|').map((s) => s.trim());
}
/**
* 解析行内内容:粗体、斜体、删除线、行内代码、行内公式、超链接
*/
export function parseInlineContent(text: string): ReplyInline[] {
const inlines: ReplyInline[] = [];
let remaining = text;
while (remaining.length > 0) {
// 1. 行内公式 $...$
const mathMatch = remaining.match(/^\$([^$]+)\$/);
if (mathMatch) {
inlines.push({ type: 'math', latex: mathMatch[1] });
remaining = remaining.slice(mathMatch[0].length);
continue;
}
// 2. 行内代码 `...`
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
inlines.push({ type: 'code', value: codeMatch[1] });
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// 3. 超链接 [text](url)
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
const safeHref = sanitizeLinkHref(linkMatch[2]);
inlines.push({
type: 'link',
href: safeHref,
children: [{ type: 'text', value: linkMatch[1] }],
});
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// 4. 粗斜体 ***text*** 或 ___text___
const boldItalicMatch = remaining.match(/^(\*\*\*|___)(.+?)\1/);
if (boldItalicMatch) {
inlines.push({
type: 'text',
value: boldItalicMatch[2],
bold: true,
italic: true,
});
remaining = remaining.slice(boldItalicMatch[0].length);
continue;
}
// 5. 粗体 **text** 或 __text__
const boldMatch = remaining.match(/^(\*\*|__)(.+?)\1/);
if (boldMatch) {
inlines.push({
type: 'text',
value: boldMatch[2],
bold: true,
});
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// 6. 斜体 *text* 或 _text_
const italicMatch = remaining.match(/^(\*|_)([^*_]+?)\1/);
if (italicMatch) {
inlines.push({
type: 'text',
value: italicMatch[2],
italic: true,
});
remaining = remaining.slice(italicMatch[0].length);
continue;
}
// 7. 删除线 ~~text~~
const strikeMatch = remaining.match(/^~~(.+?)~~/);
if (strikeMatch) {
inlines.push({
type: 'text',
value: strikeMatch[1],
strike: true,
});
remaining = remaining.slice(strikeMatch[0].length);
continue;
}
// 8. 普通文本(直到遇到下一个特殊标记符号)
const nextSpecialIndex = remaining.search(/[\$`\[*_~]/);
if (nextSpecialIndex === -1) {
inlines.push({ type: 'text', value: remaining });
break;
} else if (nextSpecialIndex > 0) {
inlines.push({ type: 'text', value: remaining.slice(0, nextSpecialIndex) });
remaining = remaining.slice(nextSpecialIndex);
} else {
// 当前第一个字符是未匹配成功的标记符号(如单个 * 或 $),作为普通文本吃掉
inlines.push({ type: 'text', value: remaining[0] });
remaining = remaining.slice(1);
}
}
return inlines;
}