markdown.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. // lib/markdown.ts:由 lib/utils.ts 按职责拆分(实现未改)
  2. import { marked, Renderer, walkTokens } from 'marked';
  3. import type { Tokens } from 'marked';
  4. import katex from 'katex';
  5. import { sanitizeHtml } from '/@/utils/markdownSafe';
  6. const mdRenderer = new Renderer();
  7. const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
  8. mdRenderer.link = (token: Tokens.Link): string => {
  9. const html = defaultLinkRenderer(token);
  10. return html.replace(/^<a\s/, '<a target="_blank" rel="noopener" ');
  11. };
  12. const renderLatexInHtml = (html: string): string => {
  13. // 匹配块级公式($$...$$)
  14. html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => {
  15. return katex.renderToString(formula.trim(), {
  16. displayMode: true,
  17. throwOnError: false,
  18. strict: false,
  19. trust: true,
  20. });
  21. });
  22. // 匹配行内公式($...$)
  23. html = html.replace(/\$(.*?)\$/g, (_match, formula) => {
  24. return katex.renderToString(formula.trim(), {
  25. displayMode: false,
  26. throwOnError: false,
  27. strict: false,
  28. });
  29. });
  30. return html;
  31. };
  32. const processFootnotes = (html: string, maxId?: number): string => {
  33. if (maxId === undefined) {
  34. // 无 citations 上下文,剥离所有 [^n] 标记
  35. return html.replace(/\[\^\d+\]/g, '');
  36. }
  37. // 有效编号范围 [1, maxId],范围外的剥离
  38. return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
  39. const id = parseInt(n, 10);
  40. if (id >= 1 && id <= maxId) {
  41. return `<sup class="footnote-ref" data-n="${id}">[${n}]</sup>`;
  42. }
  43. return '';
  44. });
  45. };
  46. export const renderMarkdown = (
  47. text: string,
  48. icons?: { copy: string; download: string; preview: string; copySuccess: string },
  49. maxCitationId?: number
  50. ): string => {
  51. if (!text) return '';
  52. const processed = text.replace(/<br\s*\/?>/gi, ' \n');
  53. let html = marked.parse(processed, { renderer: mdRenderer }) as string;
  54. html = processFootnotes(html, maxCitationId);
  55. html = renderLatexInHtml(html);
  56. // Sanitize HTML to prevent XSS attacks
  57. // [perf-base-v1] 卡6: 转调公共 sanitizeHtml(DOMPurify,ADD_TAGS:['img']),保留本模块额外交互属性白名单
  58. html = sanitizeHtml(html, ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n']);
  59. // 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel
  60. html = html.replace(/<a\b[^>]*>/gi, (tag) => {
  61. let attrs = '';
  62. if (!/\btarget\s*=/i.test(tag)) attrs += ' target="_blank"';
  63. if (!/\brel\s*=/i.test(tag)) attrs += ' rel="noopener"';
  64. return attrs ? tag.replace(/^<a\b/i, '<a' + attrs) : tag;
  65. });
  66. return icons ? wrapTables(html, icons) : html;
  67. };
  68. // renderMarkdown 是纯函数(入参相同则输出相同),但 marked 解析 + KaTeX + DOMPurify 全流程开销大。
  69. // 流式回答时每个 token 都会让消息列表重渲染,此时未变化的历史消息也会被重复解析,
  70. // 这里按「引用上限 + 图标 + 原文」记忆化:命中缓存的直接返回上次结果。
  71. // 内容每帧都在变的流式消息由调用方传 cacheable=false 排除,避免缓存被中间态刷满。
  72. const MD_CACHE_MAX = 200;
  73. const mdCache = new Map<string, string>();
  74. export const renderMarkdownCached = (
  75. text: string,
  76. icons?: { copy: string; download: string; preview: string; copySuccess: string },
  77. maxCitationId?: number,
  78. cacheable = true
  79. ): string => {
  80. if (!cacheable || !text) return renderMarkdown(text, icons, maxCitationId);
  81. // \u0001 作分隔符:不会出现在正文与图标地址里,避免不同入参拼出同一个 key
  82. const iconKey = icons ? `${icons.copy}\u0001${icons.download}\u0001${icons.preview}\u0001${icons.copySuccess}` : '';
  83. const key = `${maxCitationId ?? -1}\u0001${iconKey}\u0001${text}`;
  84. const cached = mdCache.get(key);
  85. if (cached !== undefined) return cached;
  86. const html = renderMarkdown(text, icons, maxCitationId);
  87. // 简单 FIFO 淘汰,长会话下缓存条目数有上限
  88. if (mdCache.size >= MD_CACHE_MAX) {
  89. const oldestKey = mdCache.keys().next().value as string | undefined;
  90. if (oldestKey !== undefined) mdCache.delete(oldestKey);
  91. }
  92. mdCache.set(key, html);
  93. return html;
  94. };
  95. // 从 markdown 文本中提取链接(与 renderMarkdown 共用同一套 marked 解析,
  96. // 仅收集 link token,代码块/图片等不会被误收)
  97. export const extractMarkdownLinks = (text: string): Array<{ title: string; href: string }> => {
  98. if (!text) return [];
  99. const links: Array<{ title: string; href: string }> = [];
  100. try {
  101. const tokens = marked.lexer(text);
  102. walkTokens(tokens, (token) => {
  103. if (token.type === 'link') {
  104. const linkToken = token as Tokens.Link;
  105. const href = linkToken.href || '';
  106. if (href) {
  107. links.push({ title: linkToken.text || href, href });
  108. }
  109. }
  110. });
  111. } catch {
  112. // 文本解析异常时返回已收集到的部分链接
  113. }
  114. return links;
  115. };
  116. export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
  117. return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
  118. const encodedTable = encodeURIComponent(match);
  119. return `<div class="markdown-table-wrapper">
  120. <div class="table-actions">
  121. <span class="table-action-btn" data-action="copy" data-table="${encodedTable}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制Markdown">
  122. <img src="${icons.copy}" width="14" height="14" />
  123. </span>
  124. <span class="table-action-btn" data-action="csv" data-table="${encodedTable}" title="下载">
  125. <img src="${icons.download}" width="14" height="14" />
  126. </span>
  127. <span class="table-action-btn" data-action="preview" data-table="${encodedTable}" title="预览">
  128. <img src="${icons.preview}" width="14" height="14" />
  129. </span>
  130. </div>
  131. ${match}
  132. </div>`;
  133. });
  134. };
  135. const decodeHtmlEntities = (escaped: string): string => {
  136. const textarea = document.createElement('textarea');
  137. textarea.innerHTML = escaped;
  138. return textarea.value;
  139. };
  140. // 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览)
  141. export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
  142. return html.replace(/<pre><code class="[^"]*language-(?:markdown|md)[^"]*">([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
  143. const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
  144. return `<div class="markdown-code-wrapper">
  145. <div class="table-actions">
  146. <span class="table-action-btn" data-action="copy-md" data-content="${encoded}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制内容">
  147. <img src="${icons.copy}" width="14" height="14" />
  148. </span>
  149. <span class="table-action-btn" data-action="download-md" data-content="${encoded}" title="下载.md文件">
  150. <img src="${icons.download}" width="14" height="14" />
  151. </span>
  152. <span class="table-action-btn" data-action="preview-md" data-content="${encoded}" title="预览渲染效果">
  153. <img src="${icons.preview}" width="14" height="14" />
  154. </span>
  155. </div>
  156. ${match}
  157. </div>`;
  158. });
  159. };
  160. export const tableHtmlToMarkdown = (tableHtml: string): string => {
  161. const parser = new DOMParser();
  162. const doc = parser.parseFromString(tableHtml, 'text/html');
  163. const table = doc.querySelector('table');
  164. if (!table) return tableHtml;
  165. const rows: string[][] = [];
  166. table.querySelectorAll('tr').forEach((tr) => {
  167. const cells: string[] = [];
  168. tr.querySelectorAll('th, td').forEach((cell) => {
  169. cells.push(cell.textContent?.trim() || '');
  170. });
  171. rows.push(cells);
  172. });
  173. if (rows.length === 0) return tableHtml;
  174. const colCount = Math.max(...rows.map((r) => r.length));
  175. const normalized = rows.map((r) => {
  176. while (r.length < colCount) r.push('');
  177. return r;
  178. });
  179. const lines: string[] = [];
  180. lines.push('| ' + normalized[0].join(' | ') + ' |');
  181. lines.push('| ' + normalized[0].map(() => '---').join(' | ') + ' |');
  182. for (let i = 1; i < normalized.length; i++) {
  183. lines.push('| ' + normalized[i].join(' | ') + ' |');
  184. }
  185. return lines.join('\n');
  186. };
  187. export const tableHtmlToCsv = (tableHtml: string): string => {
  188. const parser = new DOMParser();
  189. const doc = parser.parseFromString(tableHtml, 'text/html');
  190. const table = doc.querySelector('table');
  191. if (!table) return '';
  192. const rows: string[] = [];
  193. table.querySelectorAll('tr').forEach((tr) => {
  194. const cells: string[] = [];
  195. tr.querySelectorAll('th, td').forEach((cell) => {
  196. const text = (cell.textContent?.trim() || '').replace(/"/g, '""');
  197. cells.push(`"${text}"`);
  198. });
  199. rows.push(cells.join(','));
  200. });
  201. return '' + rows.join('\n');
  202. };
  203. // 大数字按「万」展示(上下文容量与今日用量共用)