- 添加 generatedImages 和 isGeneratingImage 属性 - 实现图片生成加载动画 - 添加图片操作按钮(复制、重新生成、下载、放大) - 生成图片独立显示,不包含在白色卡片内 - 优化空内容时的卡片条件渲染
624 lines
25 KiB
TypeScript
624 lines
25 KiB
TypeScript
'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<string, string> = {
|
||
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<string[]>([]);
|
||
const [lightboxInitialIndex, setLightboxInitialIndex] = useState(0);
|
||
|
||
// 文档预览状态
|
||
const [documentPreviewOpen, setDocumentPreviewOpen] = useState(false);
|
||
const [documentPreviewData, setDocumentPreviewData] = useState<DocumentData | null>(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 (
|
||
<div
|
||
data-message-id={message.id}
|
||
className={cn(
|
||
"flex justify-end items-start gap-3 mb-8 animate-fade-in group",
|
||
isHighlighted && "message-highlight"
|
||
)}
|
||
>
|
||
<div className="max-w-[70%] relative">
|
||
{/* 用户上传的图片 */}
|
||
{uploadedImages && uploadedImages.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2 justify-end">
|
||
{uploadedImages.map((img, index) => (
|
||
<div
|
||
key={index}
|
||
className="relative rounded-lg overflow-hidden cursor-pointer group/img"
|
||
onClick={() => openLightbox(uploadedImages, index)}
|
||
>
|
||
<img
|
||
src={img}
|
||
alt={`上传的图片 ${index + 1}`}
|
||
className="max-w-[200px] max-h-[200px] object-cover rounded-lg border border-[var(--color-border)] transition-transform duration-150 group-hover/img:scale-[1.02]"
|
||
/>
|
||
<div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/10 transition-colors duration-150 rounded-lg" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* 用户上传的文档 */}
|
||
{uploadedDocuments && uploadedDocuments.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2 justify-end">
|
||
{uploadedDocuments.map((doc, index) => {
|
||
const Icon = getDocumentIcon(doc.type);
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="flex items-center gap-2 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-primary)] transition-colors duration-150"
|
||
onClick={() => openDocumentPreview(doc)}
|
||
title="点击预览"
|
||
>
|
||
<Icon size={18} className="text-[var(--color-primary)]" />
|
||
<div className="flex flex-col">
|
||
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate max-w-[150px]">
|
||
{doc.name}
|
||
</span>
|
||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||
{formatFileSize(doc.size)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-md leading-relaxed">
|
||
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
|
||
</div>
|
||
{/* 悬停显示复制按钮 */}
|
||
<button
|
||
onClick={handleCopy}
|
||
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md cursor-pointer"
|
||
>
|
||
{copied ? (
|
||
<>
|
||
<Check size={14} className="text-green-500" />
|
||
<span className="text-green-500">已复制</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Copy size={14} />
|
||
<span>复制</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
{user && <Avatar name={user.name} size="md" />}
|
||
|
||
{/* 图片 Lightbox */}
|
||
<ImageLightbox
|
||
images={lightboxImages}
|
||
initialIndex={lightboxInitialIndex}
|
||
isOpen={lightboxOpen}
|
||
onClose={closeLightbox}
|
||
/>
|
||
|
||
{/* 文档预览 */}
|
||
<DocumentPreview
|
||
documentData={documentPreviewData}
|
||
isOpen={documentPreviewOpen}
|
||
onClose={closeDocumentPreview}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
data-message-id={message.id}
|
||
className={cn(
|
||
"flex items-start gap-4 mb-8 animate-fade-in",
|
||
isHighlighted && "message-highlight"
|
||
)}
|
||
>
|
||
{/* AI 图标 */}
|
||
<div className="flex-shrink-0 mt-4">
|
||
<AILogo size={28} />
|
||
</div>
|
||
|
||
{/* 消息内容 */}
|
||
<div className="flex-1 max-w-full">
|
||
{/* 思考内容 */}
|
||
{thinkingContent && (
|
||
<div className="mb-3">
|
||
<button
|
||
onClick={() => setThinkingExpanded(!thinkingExpanded)}
|
||
className="inline-flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-700 rounded-lg text-sm hover:bg-purple-100 transition-colors"
|
||
>
|
||
<Brain size={16} />
|
||
<span>思考过程</span>
|
||
{thinkingExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||
</button>
|
||
{thinkingExpanded && (
|
||
<div className="mt-2 p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
||
<pre className="text-purple-800 whitespace-pre-wrap font-mono">
|
||
{thinkingContent}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mb-3 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||
<AlertCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||
<div className="text-red-700">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 使用的工具提示 */}
|
||
{usedTools && usedTools.length > 0 && (
|
||
<div className="mb-3 flex items-center gap-2 flex-wrap">
|
||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md text-sm border border-blue-100">
|
||
<Wrench size={14} />
|
||
<span>本次使用工具:</span>
|
||
</div>
|
||
{usedTools.map((tool, index) => (
|
||
<span
|
||
key={index}
|
||
className="inline-flex items-center px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-sm border border-gray-200"
|
||
>
|
||
{TOOL_DISPLAY_NAMES[tool] || tool}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 检查是否有需要显示在白色卡片内的内容 */}
|
||
{(() => {
|
||
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 (
|
||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
||
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */}
|
||
{searchImages && searchImages.length > 0 && (
|
||
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||
)}
|
||
|
||
{/* 搜索到的视频(视频搜索工具结果)- 显示在图片下方 */}
|
||
{searchVideos && searchVideos.length > 0 && (
|
||
<SearchVideosGrid videos={searchVideos} className="mt-0 mb-4" />
|
||
)}
|
||
|
||
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
||
{message.content ? (
|
||
<MarkdownRenderer
|
||
content={message.content}
|
||
onImageLinkClick={handleImageLinkClick}
|
||
onLinkClick={onLinkClick}
|
||
/>
|
||
) : isStreaming ? (
|
||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
<span>正在思考...</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 工具调用结果 - 代码执行图片展示 */}
|
||
{message.toolResults && message.toolResults.length > 0 && (
|
||
<div className="mt-4">
|
||
{message.toolResults.map((result, index) => (
|
||
<ToolResultDisplay key={index} result={result} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pyodide 加载状态 */}
|
||
{pyodideStatus && (
|
||
<div className="mt-4">
|
||
<PyodideLoading
|
||
stage={pyodideStatus.stage}
|
||
message={pyodideStatus.message}
|
||
progress={pyodideStatus.progress}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 代码执行图片(从 props 传入) */}
|
||
{images && images.length > 0 && (() => {
|
||
// 规范化所有图片 URL 用于 lightbox
|
||
const normalizedImages = images.map(i => i.startsWith('data:') ? i : `data:image/png;base64,${i}`);
|
||
|
||
return (
|
||
<div className="mt-4">
|
||
<div className="flex flex-wrap gap-3">
|
||
{normalizedImages.map((img, index) => (
|
||
<div
|
||
key={index}
|
||
className="relative cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={() => openLightbox(normalizedImages, index)}
|
||
>
|
||
<img
|
||
src={img}
|
||
alt={`图表 ${index + 1}`}
|
||
className="rounded-lg shadow-md max-w-full h-auto"
|
||
style={{ maxWidth: '400px', maxHeight: '300px', objectFit: 'contain' }}
|
||
/>
|
||
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/50 text-white text-xs rounded">
|
||
图表 {index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* AI 生成的图片加载状态 */}
|
||
{isGeneratingImage && (
|
||
<div className="mt-4">
|
||
<div className="relative w-full max-w-[400px] aspect-video bg-[var(--color-bg-tertiary)] rounded-lg overflow-hidden">
|
||
{/* 闪光动画背景 */}
|
||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--color-primary)]/10 to-transparent animate-shimmer" />
|
||
|
||
{/* 加载内容 */}
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4">
|
||
{/* 旋转加载器 */}
|
||
<div className="w-12 h-12 border-3 border-[var(--color-border-light)] border-t-[var(--color-primary)] rounded-full animate-spin" />
|
||
|
||
{/* 加载文字 */}
|
||
<div className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
||
<span>图片生成中</span>
|
||
<div className="flex gap-1">
|
||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0s' }} />
|
||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.4s' }} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
<div className="w-48 h-1 bg-[var(--color-border)] rounded-full overflow-hidden">
|
||
<div className="h-full bg-[var(--color-primary)] rounded-full animate-progress" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* 流式状态指示器 */}
|
||
{isStreaming && message.content && (
|
||
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
||
<Loader2 size={14} className="animate-spin" />
|
||
<span>生成中...</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮(非流式状态下显示) */}
|
||
{!isStreaming && message.content && (
|
||
<>
|
||
<div className="flex items-center gap-1 mt-4 pt-3">
|
||
<Tooltip content={copied ? '已复制' : '复制'}>
|
||
<button
|
||
onClick={handleCopy}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||
>
|
||
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||
</button>
|
||
</Tooltip>
|
||
{/* 重新生成按钮 */}
|
||
{onRegenerate && (
|
||
<Tooltip content="重新生成">
|
||
<button
|
||
onClick={() => onRegenerate(message.id)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||
>
|
||
<RefreshCw size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
)}
|
||
{/* 保存到笔记按钮 */}
|
||
{onSaveToNote && (
|
||
<Tooltip content="保存到笔记">
|
||
<button
|
||
onClick={() => onSaveToNote(message.content)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||
>
|
||
<Bookmark size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
|
||
{/* 免责声明 */}
|
||
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
|
||
LionCode can make mistakes
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 (
|
||
<div className="mt-4">
|
||
{generatedImageUrls.map((imgUrl, index) => (
|
||
<div
|
||
key={index}
|
||
className="group"
|
||
>
|
||
{/* 图片 */}
|
||
<div
|
||
className="relative cursor-pointer rounded overflow-hidden transition-all duration-300 hover:shadow-lg inline-block"
|
||
onClick={() => openLightbox(generatedImageUrls, index)}
|
||
>
|
||
<img
|
||
src={imgUrl}
|
||
alt={`生成的图片 ${index + 1}`}
|
||
className="rounded max-w-full h-auto"
|
||
style={{ maxWidth: '450px', maxHeight: '400px', objectFit: 'contain' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 底部操作按钮栏 - 悬停时显示,横向排列 */}
|
||
<div className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<Tooltip content="复制图片">
|
||
<button
|
||
onClick={() => handleCopyImage(imgUrl)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||
>
|
||
<Copy size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
{onRegenerate && (
|
||
<Tooltip content="重新生成">
|
||
<button
|
||
onClick={() => onRegenerate(message.id)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||
>
|
||
<RefreshCw size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip content="下载">
|
||
<button
|
||
onClick={() => handleDownloadImage(imgUrl, index)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||
<polyline points="7 10 12 15 17 10" />
|
||
<line x1="12" y1="15" x2="12" y2="3" />
|
||
</svg>
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip content="放大">
|
||
<button
|
||
onClick={() => openLightbox(generatedImageUrls, index)}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||
</svg>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* 图片 Lightbox */}
|
||
<ImageLightbox
|
||
images={lightboxImages}
|
||
initialIndex={lightboxInitialIndex}
|
||
isOpen={lightboxOpen}
|
||
onClose={closeLightbox}
|
||
/>
|
||
|
||
{/* 文档预览 */}
|
||
<DocumentPreview
|
||
documentData={documentPreviewData}
|
||
isOpen={documentPreviewOpen}
|
||
onClose={closeDocumentPreview}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 工具调用结果显示组件
|
||
* 专门处理代码执行结果和图片显示
|
||
*/
|
||
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 (
|
||
<CodeExecutionResult
|
||
images={result.images}
|
||
engine={result.engine}
|
||
executionTime={result.executionTime}
|
||
success={!result.isError}
|
||
/>
|
||
);
|
||
}
|