import { marked, Renderer, walkTokens } from 'marked'; import type { Tokens } from 'marked'; import katex from 'katex'; // [perf-base-v1] 卡6: DOMPurify 消毒实现迁到公共 util,本文件转调避免两处漂移 import { sanitizeHtml } from '/@/utils/markdownSafe'; import { getToken } from '/@/utils/auth'; import type { AttachedFile, ThinkingStep } from './types'; const mdRenderer = new Renderer(); const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer); mdRenderer.link = (token: Tokens.Link): string => { const html = defaultLinkRenderer(token); return html.replace(/^ { // 匹配块级公式($$...$$) html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => { return katex.renderToString(formula.trim(), { displayMode: true, throwOnError: false, strict: false, trust: true, }); }); // 匹配行内公式($...$) html = html.replace(/\$(.*?)\$/g, (_match, formula) => { return katex.renderToString(formula.trim(), { displayMode: false, throwOnError: false, strict: false, }); }); return html; }; const processFootnotes = (html: string, maxId?: number): string => { if (maxId === undefined) { // 无 citations 上下文,剥离所有 [^n] 标记 return html.replace(/\[\^\d+\]/g, ''); } // 有效编号范围 [1, maxId],范围外的剥离 return html.replace(/\[\^(\d+)\]/g, (_match, n) => { const id = parseInt(n, 10); if (id >= 1 && id <= maxId) { return `[${n}]`; } return ''; }); }; export const renderMarkdown = ( text: string, icons?: { copy: string; download: string; preview: string; copySuccess: string }, maxCitationId?: number ): string => { if (!text) return ''; const processed = text.replace(//gi, ' \n'); let html = marked.parse(processed, { renderer: mdRenderer }) as string; html = processFootnotes(html, maxCitationId); html = renderLatexInHtml(html); // Sanitize HTML to prevent XSS attacks // [perf-base-v1] 卡6: 转调公共 sanitizeHtml(DOMPurify,ADD_TAGS:['img']),保留本模块额外交互属性白名单 html = sanitizeHtml(html, ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n']); // 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel html = html.replace(/]*>/gi, (tag) => { let attrs = ''; if (!/\btarget\s*=/i.test(tag)) attrs += ' target="_blank"'; if (!/\brel\s*=/i.test(tag)) attrs += ' rel="noopener"'; return attrs ? tag.replace(/^ => { if (!text) return []; const links: Array<{ title: string; href: string }> = []; try { const tokens = marked.lexer(text); walkTokens(tokens, (token) => { if (token.type === 'link') { const linkToken = token as Tokens.Link; const href = linkToken.href || ''; if (href) { links.push({ title: linkToken.text || href, href }); } } }); } catch { // 文本解析异常时返回已收集到的部分链接 } return links; }; export interface AgentGroup { agent: string; agentId?: string; steps: ThinkingStep[]; } // 按智能体聚合思考步骤(与思考卡片、右侧详情面板的分组语义一致:优先按 agentId 分组) export const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => { const groupMap = new Map(); for (const step of steps) { const agentName = step.agent || '系统'; const groupKey = step.agentId || agentName; if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] }); } groupMap.get(groupKey)!.steps.push(step); } return Array.from(groupMap.values()); }; export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => { return html.replace(/([\s\S]*?)<\/table>/g, (match) => { const encodedTable = encodeURIComponent(match); return `
${match}
`; }); }; const decodeHtmlEntities = (escaped: string): string => { const textarea = document.createElement('textarea'); textarea.innerHTML = escaped; return textarea.value; }; // 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览) export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => { return html.replace(/
([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
    const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
    return `
${match}
`; }); }; export const tableHtmlToMarkdown = (tableHtml: string): string => { const parser = new DOMParser(); const doc = parser.parseFromString(tableHtml, 'text/html'); const table = doc.querySelector('table'); if (!table) return tableHtml; const rows: string[][] = []; table.querySelectorAll('tr').forEach((tr) => { const cells: string[] = []; tr.querySelectorAll('th, td').forEach((cell) => { cells.push(cell.textContent?.trim() || ''); }); rows.push(cells); }); if (rows.length === 0) return tableHtml; const colCount = Math.max(...rows.map((r) => r.length)); const normalized = rows.map((r) => { while (r.length < colCount) r.push(''); return r; }); const lines: string[] = []; lines.push('| ' + normalized[0].join(' | ') + ' |'); lines.push('| ' + normalized[0].map(() => '---').join(' | ') + ' |'); for (let i = 1; i < normalized.length; i++) { lines.push('| ' + normalized[i].join(' | ') + ' |'); } return lines.join('\n'); }; export const tableHtmlToCsv = (tableHtml: string): string => { const parser = new DOMParser(); const doc = parser.parseFromString(tableHtml, 'text/html'); const table = doc.querySelector('table'); if (!table) return ''; const rows: string[] = []; table.querySelectorAll('tr').forEach((tr) => { const cells: string[] = []; tr.querySelectorAll('th, td').forEach((cell) => { const text = (cell.textContent?.trim() || '').replace(/"/g, '""'); cells.push(`"${text}"`); }); rows.push(cells.join(',')); }); return '' + rows.join('\n'); }; export const formatFileSize = (bytes: number): string => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; }; export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename); export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename); export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename); export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename); export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename); export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename); export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename); export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename); export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename); export const isTextFile = (filename: string): boolean => /\.(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( filename ); export const getFileExtension = (filename: string): string => { const match = filename.match(/\.([^.]+)$/); return match ? `.${match[1]}`.toUpperCase() : ''; }; export const getFileIconVar = (filename: string): string => { if (isMdFile(filename)) return 'var(--img-chat-file-md-icon)'; if (isPdfFile(filename)) return 'var(--img-chat-file-pdf-icon)'; if (isWordFile(filename)) return 'var(--img-chat-file-word-icon)'; if (isExcelFile(filename) || isCsvFile(filename)) return 'var(--img-chat-file-excel-icon)'; if (isImageFile(filename)) return 'var(--img-chat-file-img-icon)'; if (isTextFile(filename)) return 'var(--img-chat-file-txt-icon)'; return 'var(--img-chat-attach-icon)'; }; // 按文件类型拉取远端文件内容并构造右侧面板预览对象(消息内文件卡片与胶囊面板共用)。 // 不支持的格式返回 null,由调用方回退为直接下载 export const fetchPreviewFile = async (url: string, filename: string): Promise => { const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const ext = filename.split('.').pop()?.toLowerCase() || ''; const mimeMap: Record = { md: 'text/markdown', txt: 'text/plain', json: 'application/json', xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml', csv: 'text/csv', }; const id = `report-${url}`; const uploadTime = new Date().toISOString(); if (mimeMap[ext]) { const content = await resp.text(); return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime }; } if (isWordFile(filename) || isExcelFile(filename)) { const arrayBuffer = await resp.arrayBuffer(); return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime }; } if (isImageFile(filename) || isPdfFile(filename)) { const blob = await resp.blob(); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime }; } return null; };