feat(组件): 消息气泡支持 AI 生成图片展示

- 添加 generatedImages 和 isGeneratingImage 属性
- 实现图片生成加载动画
- 添加图片操作按钮(复制、重新生成、下载、放大)
- 生成图片独立显示,不包含在白色卡片内
- 优化空内容时的卡片条件渲染
This commit is contained in:
gaoziman 2025-12-27 15:02:12 +08:00
parent 6a70b236b6
commit 8b558fb780

View File

@ -13,7 +13,7 @@ import { ImageLightbox } from '@/components/ui/ImageLightbox';
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview'; import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Message, User, ToolResult } from '@/types'; import type { Message, User, ToolResult } from '@/types';
import type { UploadedDocument } from '@/hooks/useStreamChat'; import type { UploadedDocument, GeneratedImageData } from '@/hooks/useStreamChat';
// 工具名称中文映射 // 工具名称中文映射
const TOOL_DISPLAY_NAMES: Record<string, string> = { const TOOL_DISPLAY_NAMES: Record<string, string> = {
@ -48,6 +48,10 @@ interface MessageBubbleProps {
message: string; message: string;
progress?: number; progress?: number;
}; };
/** AI 生成的图片Gemini 等图片生成模型) */
generatedImages?: GeneratedImageData[];
/** 是否正在生成图片 */
isGeneratingImage?: boolean;
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */ /** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
onRegenerate?: (messageId: string) => void; onRegenerate?: (messageId: string) => void;
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */ /** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
@ -77,7 +81,7 @@ function getDocumentIcon(type: string) {
return FileText; return FileText;
} }
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) { 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 isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -296,7 +300,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
</div> </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"> <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 && ( {searchImages && searchImages.length > 0 && (
@ -373,6 +391,37 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
); );
})()} })()}
{/* 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 && ( {isStreaming && message.content && (
@ -425,6 +474,105 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
</> </>
)} )}
</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> </div>
{/* 图片 Lightbox */} {/* 图片 Lightbox */}