'use client'; import { useState, useCallback } from 'react'; import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark, Wrench } from 'lucide-react'; import { Avatar } from '@/components/ui/Avatar'; import { AILogo } from '@/components/ui/AILogo'; import { Tooltip } from '@/components/ui/Tooltip'; import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'; import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult'; import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid'; import { SearchVideosGrid, type SearchVideoItem } from '@/components/features/SearchVideosGrid'; import { ImageLightbox } from '@/components/ui/ImageLightbox'; import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview'; import { cn } from '@/lib/utils'; import type { Message, User, ToolResult } from '@/types'; import type { UploadedDocument, GeneratedImageData } from '@/hooks/useStreamChat'; // 工具名称中文映射 const TOOL_DISPLAY_NAMES: Record = { web_search: '网络搜索', web_fetch: '网页读取', mita_search: '秘塔搜索', mita_reader: '秘塔阅读', code_execution: '代码执行', }; interface MessageBubbleProps { message: Message; user?: User; thinkingContent?: string; isStreaming?: boolean; error?: string; /** 代码执行产生的图片(Base64) */ images?: string[]; /** 搜索到的图片(来自图片搜索工具) */ searchImages?: SearchImageItem[]; /** 搜索到的视频(来自视频搜索工具) */ searchVideos?: SearchVideoItem[]; /** 用户上传的图片(Base64 或 URL) */ uploadedImages?: string[]; /** 用户上传的文档 */ uploadedDocuments?: UploadedDocument[]; /** 使用的工具列表 */ usedTools?: string[]; /** Pyodide 加载状态 */ pyodideStatus?: { stage: 'loading' | 'ready' | 'error'; message: string; progress?: number; }; /** AI 生成的图片(Gemini 等图片生成模型) */ generatedImages?: GeneratedImageData[]; /** 是否正在生成图片 */ isGeneratingImage?: boolean; /** 重新生成回调(仅对 AI 消息有效),传入消息 ID */ onRegenerate?: (messageId: string) => void; /** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */ onSaveToNote?: (content: string) => void; /** 链接点击回调,用于在预览窗口中打开链接 */ onLinkClick?: (url: string) => void; /** 对话 ID(用于关联笔记来源) */ conversationId?: string; /** 是否高亮显示(搜索跳转时使用) */ isHighlighted?: boolean; } // 格式化文件大小 function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } // 根据文件类型获取图标 function getDocumentIcon(type: string) { if (type === 'code' || type === 'markdown') { return FileCode; } return FileText; } export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, generatedImages, isGeneratingImage, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) { const isUser = message.role === 'user'; const [thinkingExpanded, setThinkingExpanded] = useState(false); const [copied, setCopied] = useState(false); // 图片 Lightbox 状态 const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxImages, setLightboxImages] = useState([]); const [lightboxInitialIndex, setLightboxInitialIndex] = useState(0); // 文档预览状态 const [documentPreviewOpen, setDocumentPreviewOpen] = useState(false); const [documentPreviewData, setDocumentPreviewData] = useState(null); // 打开图片 Lightbox const openLightbox = (imageArray: string[], index: number) => { setLightboxImages(imageArray); setLightboxInitialIndex(index); setLightboxOpen(true); }; // 关闭图片 Lightbox const closeLightbox = () => { setLightboxOpen(false); }; // 打开文档预览 const openDocumentPreview = (doc: UploadedDocument) => { setDocumentPreviewData({ name: doc.name, content: doc.content, size: doc.size, type: doc.type, }); setDocumentPreviewOpen(true); }; // 关闭文档预览 const closeDocumentPreview = () => { setDocumentPreviewOpen(false); setDocumentPreviewData(null); }; // 处理 Markdown 中图片链接点击(在灯箱中打开) const handleImageLinkClick = useCallback((url: string) => { setLightboxImages([url]); setLightboxInitialIndex(0); setLightboxOpen(true); }, []); // 复制消息内容 const handleCopy = async () => { try { await navigator.clipboard.writeText(message.content); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { console.error('Failed to copy:', error); } }; if (isUser) { return (
{/* 用户上传的图片 */} {uploadedImages && uploadedImages.length > 0 && (
{uploadedImages.map((img, index) => (
openLightbox(uploadedImages, index)} > {`上传的图片
))}
)} {/* 用户上传的文档 */} {uploadedDocuments && uploadedDocuments.length > 0 && (
{uploadedDocuments.map((doc, index) => { const Icon = getDocumentIcon(doc.type); return (
openDocumentPreview(doc)} title="点击预览" >
{doc.name} {formatFileSize(doc.size)}
); })}
)}
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
{/* 悬停显示复制按钮 */}
{user && } {/* 图片 Lightbox */} {/* 文档预览 */}
); } return (
{/* AI 图标 */}
{/* 消息内容 */}
{/* 思考内容 */} {thinkingContent && (
{thinkingExpanded && (
                  {thinkingContent}
                
)}
)} {/* 错误提示 */} {error && (
{error}
)} {/* 使用的工具提示 */} {usedTools && usedTools.length > 0 && (
本次使用工具:
{usedTools.map((tool, index) => ( {TOOL_DISPLAY_NAMES[tool] || tool} ))}
)} {/* 检查是否有需要显示在白色卡片内的内容 */} {(() => { const hasCardContent = message.content || isStreaming || (searchImages && searchImages.length > 0) || (searchVideos && searchVideos.length > 0) || (message.toolResults && message.toolResults.length > 0) || pyodideStatus || (images && images.length > 0) || isGeneratingImage; if (!hasCardContent) return null; return (
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */} {searchImages && searchImages.length > 0 && ( )} {/* 搜索到的视频(视频搜索工具结果)- 显示在图片下方 */} {searchVideos && searchVideos.length > 0 && ( )}
{message.content ? ( ) : isStreaming ? (
正在思考...
) : null}
{/* 工具调用结果 - 代码执行图片展示 */} {message.toolResults && message.toolResults.length > 0 && (
{message.toolResults.map((result, index) => ( ))}
)} {/* Pyodide 加载状态 */} {pyodideStatus && (
)} {/* 代码执行图片(从 props 传入) */} {images && images.length > 0 && (() => { // 规范化所有图片 URL 用于 lightbox const normalizedImages = images.map(i => i.startsWith('data:') ? i : `data:image/png;base64,${i}`); return (
{normalizedImages.map((img, index) => (
openLightbox(normalizedImages, index)} > {`图表
图表 {index + 1}
))}
); })()} {/* AI 生成的图片加载状态 */} {isGeneratingImage && (
{/* 闪光动画背景 */}
{/* 加载内容 */}
{/* 旋转加载器 */}
{/* 加载文字 */}
图片生成中
{/* 进度条 */}
)} {/* 流式状态指示器 */} {isStreaming && message.content && (
生成中...
)} {/* 操作按钮(非流式状态下显示) */} {!isStreaming && message.content && ( <>
{/* 重新生成按钮 */} {onRegenerate && ( )} {/* 保存到笔记按钮 */} {onSaveToNote && ( )}
{/* 免责声明 */}
LionCode can make mistakes
)}
); })()} {/* AI 生成的图片(Gemini 等模型)- 单独显示,不包在白色卡片内 */} {generatedImages && generatedImages.length > 0 && (() => { // 将 GeneratedImageData 转换为 data URL const generatedImageUrls = generatedImages.map( (img) => `data:${img.mimeType};base64,${img.data}` ); // 复制图片到剪贴板 const handleCopyImage = async (imgUrl: string) => { try { const response = await fetch(imgUrl); const blob = await response.blob(); await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob }) ]); } catch (error) { console.error('Failed to copy image:', error); } }; // 下载图片 const handleDownloadImage = (imgUrl: string, index: number) => { const link = document.createElement('a'); link.href = imgUrl; link.download = `generated-image-${index + 1}.png`; link.click(); }; return (
{generatedImageUrls.map((imgUrl, index) => (
{/* 图片 */}
openLightbox(generatedImageUrls, index)} > {`生成的图片
{/* 底部操作按钮栏 - 悬停时显示,横向排列 */}
{onRegenerate && ( )}
))}
); })()}
{/* 图片 Lightbox */} {/* 文档预览 */}
); } /** * 工具调用结果显示组件 * 专门处理代码执行结果和图片显示 */ interface ToolResultDisplayProps { result: ToolResult; } function ToolResultDisplay({ result }: ToolResultDisplayProps) { // 只有代码执行工具才显示图片 if (result.toolName !== 'code_execution') { return null; } // 如果没有图片,不显示 if (!result.images || result.images.length === 0) { return null; } return ( ); }