feat(组件): 消息气泡支持 AI 生成图片展示
- 添加 generatedImages 和 isGeneratingImage 属性 - 实现图片生成加载动画 - 添加图片操作按钮(复制、重新生成、下载、放大) - 生成图片独立显示,不包含在白色卡片内 - 优化空内容时的卡片条件渲染
This commit is contained in:
parent
6a70b236b6
commit
8b558fb780
@ -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 */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user