utils.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { marked, Renderer, walkTokens } from 'marked';
  2. import type { Tokens } from 'marked';
  3. import katex from 'katex';
  4. // [perf-base-v1] 卡6: DOMPurify 消毒实现迁到公共 util,本文件转调避免两处漂移
  5. import { sanitizeHtml } from '/@/utils/markdownSafe';
  6. import { getToken } from '/@/utils/auth';
  7. import type { AttachedFile, ThinkingStep } from './types';
  8. const mdRenderer = new Renderer();
  9. const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
  10. mdRenderer.link = (token: Tokens.Link): string => {
  11. const html = defaultLinkRenderer(token);
  12. return html.replace(/^<a\s/, '<a target="_blank" rel="noopener" ');
  13. };
  14. const renderLatexInHtml = (html: string): string => {
  15. // 匹配块级公式($$...$$)
  16. html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => {
  17. return katex.renderToString(formula.trim(), {
  18. displayMode: true,
  19. throwOnError: false,
  20. strict: false,
  21. trust: true,
  22. });
  23. });
  24. // 匹配行内公式($...$)
  25. html = html.replace(/\$(.*?)\$/g, (_match, formula) => {
  26. return katex.renderToString(formula.trim(), {
  27. displayMode: false,
  28. throwOnError: false,
  29. strict: false,
  30. });
  31. });
  32. return html;
  33. };
  34. const processFootnotes = (html: string, maxId?: number): string => {
  35. if (maxId === undefined) {
  36. // 无 citations 上下文,剥离所有 [^n] 标记
  37. return html.replace(/\[\^\d+\]/g, '');
  38. }
  39. // 有效编号范围 [1, maxId],范围外的剥离
  40. return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
  41. const id = parseInt(n, 10);
  42. if (id >= 1 && id <= maxId) {
  43. return `<sup class="footnote-ref" data-n="${id}">[${n}]</sup>`;
  44. }
  45. return '';
  46. });
  47. };
  48. export const renderMarkdown = (
  49. text: string,
  50. icons?: { copy: string; download: string; preview: string; copySuccess: string },
  51. maxCitationId?: number
  52. ): string => {
  53. if (!text) return '';
  54. const processed = text.replace(/<br\s*\/?>/gi, ' \n');
  55. let html = marked.parse(processed, { renderer: mdRenderer }) as string;
  56. html = processFootnotes(html, maxCitationId);
  57. html = renderLatexInHtml(html);
  58. // Sanitize HTML to prevent XSS attacks
  59. // [perf-base-v1] 卡6: 转调公共 sanitizeHtml(DOMPurify,ADD_TAGS:['img']),保留本模块额外交互属性白名单
  60. html = sanitizeHtml(html, ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n']);
  61. // 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel
  62. html = html.replace(/<a\b[^>]*>/gi, (tag) => {
  63. let attrs = '';
  64. if (!/\btarget\s*=/i.test(tag)) attrs += ' target="_blank"';
  65. if (!/\brel\s*=/i.test(tag)) attrs += ' rel="noopener"';
  66. return attrs ? tag.replace(/^<a\b/i, '<a' + attrs) : tag;
  67. });
  68. return icons ? wrapTables(html, icons) : html;
  69. };
  70. // 从 markdown 文本中提取链接(与 renderMarkdown 共用同一套 marked 解析,
  71. // 仅收集 link token,代码块/图片等不会被误收)
  72. export const extractMarkdownLinks = (text: string): Array<{ title: string; href: string }> => {
  73. if (!text) return [];
  74. const links: Array<{ title: string; href: string }> = [];
  75. try {
  76. const tokens = marked.lexer(text);
  77. walkTokens(tokens, (token) => {
  78. if (token.type === 'link') {
  79. const linkToken = token as Tokens.Link;
  80. const href = linkToken.href || '';
  81. if (href) {
  82. links.push({ title: linkToken.text || href, href });
  83. }
  84. }
  85. });
  86. } catch {
  87. // 文本解析异常时返回已收集到的部分链接
  88. }
  89. return links;
  90. };
  91. export interface AgentGroup {
  92. agent: string;
  93. agentId?: string;
  94. steps: ThinkingStep[];
  95. }
  96. // 按智能体聚合思考步骤(与思考卡片、右侧详情面板的分组语义一致:优先按 agentId 分组)
  97. export const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
  98. const groupMap = new Map<string, AgentGroup>();
  99. for (const step of steps) {
  100. const agentName = step.agent || '系统';
  101. const groupKey = step.agentId || agentName;
  102. if (!groupMap.has(groupKey)) {
  103. groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] });
  104. }
  105. groupMap.get(groupKey)!.steps.push(step);
  106. }
  107. return Array.from(groupMap.values());
  108. };
  109. export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
  110. return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
  111. const encodedTable = encodeURIComponent(match);
  112. return `<div class="markdown-table-wrapper">
  113. <div class="table-actions">
  114. <span class="table-action-btn" data-action="copy" data-table="${encodedTable}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制Markdown">
  115. <img src="${icons.copy}" width="14" height="14" />
  116. </span>
  117. <span class="table-action-btn" data-action="csv" data-table="${encodedTable}" title="下载">
  118. <img src="${icons.download}" width="14" height="14" />
  119. </span>
  120. <span class="table-action-btn" data-action="preview" data-table="${encodedTable}" title="预览">
  121. <img src="${icons.preview}" width="14" height="14" />
  122. </span>
  123. </div>
  124. ${match}
  125. </div>`;
  126. });
  127. };
  128. const decodeHtmlEntities = (escaped: string): string => {
  129. const textarea = document.createElement('textarea');
  130. textarea.innerHTML = escaped;
  131. return textarea.value;
  132. };
  133. // 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览)
  134. export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
  135. return html.replace(/<pre><code class="[^"]*language-(?:markdown|md)[^"]*">([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
  136. const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
  137. return `<div class="markdown-code-wrapper">
  138. <div class="table-actions">
  139. <span class="table-action-btn" data-action="copy-md" data-content="${encoded}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制内容">
  140. <img src="${icons.copy}" width="14" height="14" />
  141. </span>
  142. <span class="table-action-btn" data-action="download-md" data-content="${encoded}" title="下载.md文件">
  143. <img src="${icons.download}" width="14" height="14" />
  144. </span>
  145. <span class="table-action-btn" data-action="preview-md" data-content="${encoded}" title="预览渲染效果">
  146. <img src="${icons.preview}" width="14" height="14" />
  147. </span>
  148. </div>
  149. ${match}
  150. </div>`;
  151. });
  152. };
  153. export const tableHtmlToMarkdown = (tableHtml: string): string => {
  154. const parser = new DOMParser();
  155. const doc = parser.parseFromString(tableHtml, 'text/html');
  156. const table = doc.querySelector('table');
  157. if (!table) return tableHtml;
  158. const rows: string[][] = [];
  159. table.querySelectorAll('tr').forEach((tr) => {
  160. const cells: string[] = [];
  161. tr.querySelectorAll('th, td').forEach((cell) => {
  162. cells.push(cell.textContent?.trim() || '');
  163. });
  164. rows.push(cells);
  165. });
  166. if (rows.length === 0) return tableHtml;
  167. const colCount = Math.max(...rows.map((r) => r.length));
  168. const normalized = rows.map((r) => {
  169. while (r.length < colCount) r.push('');
  170. return r;
  171. });
  172. const lines: string[] = [];
  173. lines.push('| ' + normalized[0].join(' | ') + ' |');
  174. lines.push('| ' + normalized[0].map(() => '---').join(' | ') + ' |');
  175. for (let i = 1; i < normalized.length; i++) {
  176. lines.push('| ' + normalized[i].join(' | ') + ' |');
  177. }
  178. return lines.join('\n');
  179. };
  180. export const tableHtmlToCsv = (tableHtml: string): string => {
  181. const parser = new DOMParser();
  182. const doc = parser.parseFromString(tableHtml, 'text/html');
  183. const table = doc.querySelector('table');
  184. if (!table) return '';
  185. const rows: string[] = [];
  186. table.querySelectorAll('tr').forEach((tr) => {
  187. const cells: string[] = [];
  188. tr.querySelectorAll('th, td').forEach((cell) => {
  189. const text = (cell.textContent?.trim() || '').replace(/"/g, '""');
  190. cells.push(`"${text}"`);
  191. });
  192. rows.push(cells.join(','));
  193. });
  194. return '' + rows.join('\n');
  195. };
  196. export const formatFileSize = (bytes: number): string => {
  197. if (bytes < 1024) return bytes + ' B';
  198. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
  199. return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
  200. };
  201. export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename);
  202. export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);
  203. export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename);
  204. export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename);
  205. export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename);
  206. export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename);
  207. export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename);
  208. export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename);
  209. export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
  210. export const isTextFile = (filename: string): boolean =>
  211. /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
  212. filename
  213. );
  214. export const getFileExtension = (filename: string): string => {
  215. const match = filename.match(/\.([^.]+)$/);
  216. return match ? `.${match[1]}`.toUpperCase() : '';
  217. };
  218. export const getFileIconVar = (filename: string): string => {
  219. if (isMdFile(filename)) return 'var(--img-chat-file-md-icon)';
  220. if (isPdfFile(filename)) return 'var(--img-chat-file-pdf-icon)';
  221. if (isWordFile(filename)) return 'var(--img-chat-file-word-icon)';
  222. if (isExcelFile(filename) || isCsvFile(filename)) return 'var(--img-chat-file-excel-icon)';
  223. if (isImageFile(filename)) return 'var(--img-chat-file-img-icon)';
  224. if (isTextFile(filename)) return 'var(--img-chat-file-txt-icon)';
  225. return 'var(--img-chat-attach-icon)';
  226. };
  227. // 按文件类型拉取远端文件内容并构造右侧面板预览对象(消息内文件卡片与胶囊面板共用)。
  228. // 不支持的格式返回 null,由调用方回退为直接下载
  229. export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
  230. const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
  231. if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  232. const ext = filename.split('.').pop()?.toLowerCase() || '';
  233. const mimeMap: Record<string, string> = {
  234. md: 'text/markdown',
  235. txt: 'text/plain',
  236. json: 'application/json',
  237. xml: 'text/xml',
  238. yaml: 'text/yaml',
  239. yml: 'text/yaml',
  240. csv: 'text/csv',
  241. };
  242. const id = `report-${url}`;
  243. const uploadTime = new Date().toISOString();
  244. if (mimeMap[ext]) {
  245. const content = await resp.text();
  246. return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
  247. }
  248. if (isWordFile(filename) || isExcelFile(filename)) {
  249. const arrayBuffer = await resp.arrayBuffer();
  250. return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
  251. }
  252. if (isImageFile(filename) || isPdfFile(filename)) {
  253. const blob = await resp.blob();
  254. const dataUrl = await new Promise<string>((resolve, reject) => {
  255. const reader = new FileReader();
  256. reader.onload = () => resolve(reader.result as string);
  257. reader.onerror = () => reject(reader.error);
  258. reader.readAsDataURL(blob);
  259. });
  260. return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
  261. }
  262. return null;
  263. };