ChatInput 组件: - 集成 useFileUpload Hook 实现文件管理 - 支持拖拽文件到输入框上传 - 支持 Ctrl+V 粘贴图片 - 添加文件选择按钮和隐藏的 file input - 拖拽时显示覆盖层提示 - 输入框上方显示已选文件预览 MessageBubble 组件: - 显示用户上传的图片缩略图 - 点击图片打开 Lightbox 大图预览 - 显示用户上传的文档卡片 - 点击文档打开预览弹窗 - 代码执行图片也支持点击放大
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
'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}
|
||
/>
|
||
);
|
||
}
|