claude-code-cchui/src/components/features/MessageBubble.tsx
gaoziman 4cb3f162e3 feat(组件): 聊天输入框和消息气泡支持文件上传
ChatInput 组件:
- 集成 useFileUpload Hook 实现文件管理
- 支持拖拽文件到输入框上传
- 支持 Ctrl+V 粘贴图片
- 添加文件选择按钮和隐藏的 file input
- 拖拽时显示覆盖层提示
- 输入框上方显示已选文件预览

MessageBubble 组件:
- 显示用户上传的图片缩略图
- 点击图片打开 Lightbox 大图预览
- 显示用户上传的文档卡片
- 点击文档打开预览弹窗
- 代码执行图片也支持点击放大
2025-12-20 12:14:41 +08:00

395 lines
14 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 } from 'react';
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AILogo } from '@/components/ui/AILogo';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
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 } from '@/hooks/useStreamChat';
interface MessageBubbleProps {
message: Message;
user?: User;
thinkingContent?: string;
isStreaming?: boolean;
error?: string;
/** 代码执行产生的图片Base64 */
images?: string[];
/** 用户上传的图片Base64 或 URL */
uploadedImages?: string[];
/** 用户上传的文档 */
uploadedDocuments?: UploadedDocument[];
/** Pyodide 加载状态 */
pyodideStatus?: {
stage: 'loading' | 'ready' | 'error';
message: string;
progress?: number;
};
}
// 格式化文件大小
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, uploadedImages, uploadedDocuments, pyodideStatus }: 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);
};
// 复制消息内容
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 className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
<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-[18px] text-base 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"
title={copied ? '已复制' : '复制'}
>
{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 className="flex items-start gap-4 mb-8 animate-fade-in">
{/* 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-sm 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-sm text-red-700">{error}</div>
</div>
)}
{/* 主要内容 */}
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-5 py-4 shadow-sm">
<div className="text-sm text-[var(--color-text-primary)] leading-[1.75]">
{message.content ? (
<MarkdownRenderer content={message.content} />
) : 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>
);
})()}
{/* 流式状态指示器 */}
{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">
<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"
title={copied ? '已复制' : '复制'}
>
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
<ActionButton icon={ThumbsUp} title="好的回答" />
<ActionButton icon={ThumbsDown} title="不好的回答" />
<ActionButton icon={RefreshCw} title="重新生成" />
</div>
{/* 免责声明 */}
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
LionCode can make mistakes
</div>
</>
)}
</div>
</div>
{/* 图片 Lightbox */}
<ImageLightbox
images={lightboxImages}
initialIndex={lightboxInitialIndex}
isOpen={lightboxOpen}
onClose={closeLightbox}
/>
{/* 文档预览 */}
<DocumentPreview
documentData={documentPreviewData}
isOpen={documentPreviewOpen}
onClose={closeDocumentPreview}
/>
</div>
);
}
interface ActionButtonProps {
icon: React.ComponentType<{ size?: number }>;
title: string;
onClick?: () => void;
}
function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
return (
<button
onClick={onClick}
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"
title={title}
>
<Icon size={16} />
</button>
);
}
/**
* 工具调用结果显示组件
* 专门处理代码执行结果和图片显示
*/
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}
/>
);
}