claude-code-cchui/src/components/features/MessageBubble.tsx
gaoziman 8b558fb780 feat(组件): 消息气泡支持 AI 生成图片展示
- 添加 generatedImages 和 isGeneratingImage 属性
- 实现图片生成加载动画
- 添加图片操作按钮(复制、重新生成、下载、放大)
- 生成图片独立显示,不包含在白色卡片内
- 优化空内容时的卡片条件渲染
2025-12-27 15:02:12 +08:00

624 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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}
/>
);
}