import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api'; import { formatChatTime } from '@/utils/datetime'; import type { Conversation, Message } from '@/types'; import './ChatPage.css'; // ===== 增强渲染依赖 ===== import hljs from 'highlight.js'; import 'highlight.js/styles/github.css'; import katex from 'katex'; import 'katex/dist/katex.min.css'; import mermaid from 'mermaid'; mermaid.initialize({ startOnLoad: false, theme: 'default' }); // ===== Enhanced Markdown Renderer ===== // 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项 interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; } // 全屏灯箱预览 const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => { useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.stopPropagation()}> {type === 'video' ?
); }; // 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比) function computeSizes(natural: { w: number; h: number }) { if (natural.w && natural.h) { const ratio = natural.w / natural.h; const fit = (maxDim: number) => ratio >= 1 ? { w: maxDim, h: Math.round(maxDim / ratio) } : { w: Math.round(maxDim * ratio), h: maxDim }; return [ { label: '小图', ...fit(512) }, { label: '中图', ...fit(1024) }, { label: '原始', w: natural.w, h: natural.h }, ]; } // 未知原始尺寸时按生成默认 4:3 给出三档 return [ { label: '小图', w: 512, h: 384 }, { label: '中图', w: 1024, h: 768 }, { label: '大图', w: 2048, h: 1536 }, ]; } // 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图 function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) { const fallback = () => window.open(src, '_blank'); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) { fallback(); return; } if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); } ctx.drawImage(img, 0, 0, w, h); const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'; canvas.toBlob((blob) => { if (!blob) { fallback(); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, mime, 0.92); } catch (e) { fallback(); } }; img.onerror = fallback; img.src = src; } // 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸) const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => { const [open, setOpen] = useState(false); const [dlOpen, setDlOpen] = useState(false); const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png'); const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); if (info.type === 'video') { return (
); } const sizes = computeSizes(natural); return (
{info.alt setOpen(true)} onLoad={(e) => { const im = e.currentTarget; if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight }); }} /> {dlOpen && (
e.stopPropagation()}>
下载图片
格式
{(['png', 'jpg', 'webp'] as const).map((f) => ( ))}
尺寸
{sizes.map((s, i) => ( ))}
)} {open && setOpen(false)} />}
); }; const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { const nodes = useMemo(() => { let text = content; // 1. Extract code blocks first (protect from other replacements) const codeBlocks: string[] = []; const mermaidBlocks: string[] = []; text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => { if (_lang === 'mermaid') { const idx = mermaidBlocks.length; mermaidBlocks.push('
'); return '\x00MERMAID' + idx + '\x00'; } const idx = codeBlocks.length; codeBlocks.push('
' + escapeHtml(code.trim()) + '
'); return '\x00CODEBLOCK' + idx + '\x00'; }); // 2. Extract inline code const inlineCodes: string[] = []; text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => { const idx = inlineCodes.length; inlineCodes.push('' + escapeHtml(code) + ''); return '\x00INLINE' + idx + '\x00'; }); // 2.5 Extract media blocks (/