- 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 自动识别为原生公式
330 lines
9.6 KiB
TypeScript
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;
|
|
}
|