feat(组件): 聊天输入框和消息气泡支持文件上传
ChatInput 组件: - 集成 useFileUpload Hook 实现文件管理 - 支持拖拽文件到输入框上传 - 支持 Ctrl+V 粘贴图片 - 添加文件选择按钮和隐藏的 file input - 拖拽时显示覆盖层提示 - 输入框上方显示已选文件预览 MessageBubble 组件: - 显示用户上传的图片缩略图 - 点击图片打开 Lightbox 大图预览 - 显示用户上传的文档卡片 - 点击文档打开预览弹窗 - 代码执行图片也支持点击放大
This commit is contained in:
parent
00b8589e03
commit
4cb3f162e3
@ -1,11 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Plus, ArrowUp } from 'lucide-react';
|
import { Plus, ArrowUp, Upload } from 'lucide-react';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { ToolsDropdown } from './ToolsDropdown';
|
import { ToolsDropdown } from './ToolsDropdown';
|
||||||
|
import { FilePreviewList } from './FilePreviewList';
|
||||||
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Model, Tool } from '@/types';
|
import type { Model, Tool } from '@/types';
|
||||||
|
import type { UploadFile } from '@/types/file-upload';
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
models: Model[];
|
models: Model[];
|
||||||
@ -14,7 +17,7 @@ interface ChatInputProps {
|
|||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
onToolToggle: (toolId: string) => void;
|
onToolToggle: (toolId: string) => void;
|
||||||
onEnableAllTools: (enabled: boolean) => void;
|
onEnableAllTools: (enabled: boolean) => void;
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string, files?: UploadFile[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@ -31,11 +34,27 @@ export function ChatInput({
|
|||||||
className,
|
className,
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 使用文件上传 Hook
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
isDragging,
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
clearFiles,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handlePaste,
|
||||||
|
} = useFileUpload();
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (message.trim()) {
|
if (message.trim() || files.length > 0) {
|
||||||
onSend(message);
|
onSend(message, files.length > 0 ? files : undefined);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
clearFiles();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -48,15 +67,57 @@ export function ChatInput({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = e.target.files;
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
addFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
// 重置 input 以允许重复选择同一文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开文件选择对话框
|
||||||
|
const openFileDialog = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
|
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
|
'relative flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
|
||||||
'transition-all duration-150',
|
'transition-all duration-150',
|
||||||
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]'
|
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]',
|
||||||
|
isDragging && 'border-[var(--color-primary)] border-2 bg-[var(--color-primary)]/5'
|
||||||
)}
|
)}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
{/* 拖拽覆盖层 */}
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[var(--color-bg-primary)]/90 rounded-[18px] border-2 border-dashed border-[var(--color-primary)]">
|
||||||
|
<Upload className="w-8 h-8 text-[var(--color-primary)] mb-2" />
|
||||||
|
<p className="text-sm font-medium text-[var(--color-primary)]">
|
||||||
|
释放以添加文件
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||||
|
支持图片、PDF、文档等格式
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文件预览区域 */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<FilePreviewList files={files} onRemove={removeFile} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 第一行:输入区域 */}
|
{/* 第一行:输入区域 */}
|
||||||
<div className="w-full mb-3">
|
<div className="w-full mb-3">
|
||||||
<input
|
<input
|
||||||
@ -64,7 +125,8 @@ export function ChatInput({
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
onPaste={handlePaste}
|
||||||
|
placeholder={files.length > 0 ? '添加描述(可选)...' : placeholder}
|
||||||
className="w-full border-none outline-none text-base text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
|
className="w-full border-none outline-none text-base text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -75,12 +137,27 @@ export function ChatInput({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* 添加附件 */}
|
{/* 添加附件 */}
|
||||||
<button
|
<button
|
||||||
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"
|
onClick={openFileDialog}
|
||||||
title="Add attachment"
|
className={cn(
|
||||||
|
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors',
|
||||||
|
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]',
|
||||||
|
files.length > 0 && 'text-[var(--color-primary)]'
|
||||||
|
)}
|
||||||
|
title="添加附件"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 隐藏的文件输入 */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.md,.json,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.go,.rs,.html,.css"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 工具下拉 */}
|
{/* 工具下拉 */}
|
||||||
<ToolsDropdown
|
<ToolsDropdown
|
||||||
tools={tools}
|
tools={tools}
|
||||||
@ -101,10 +178,10 @@ export function ChatInput({
|
|||||||
{/* 发送按钮 */}
|
{/* 发送按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!message.trim()}
|
disabled={!message.trim() && files.length === 0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150',
|
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150',
|
||||||
message.trim()
|
message.trim() || files.length > 0
|
||||||
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
|
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
|
||||||
: 'opacity-50 cursor-not-allowed'
|
: 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
@ -115,6 +192,11 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 提示文字 */}
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] text-center mt-2">
|
||||||
|
可拖拽文件到此处或使用 Ctrl+V 粘贴图片
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check } from 'lucide-react';
|
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { AILogo } from '@/components/ui/AILogo';
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
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 { cn } from '@/lib/utils';
|
||||||
import type { Message, User, ToolResult } from '@/types';
|
import type { Message, User, ToolResult } from '@/types';
|
||||||
|
import type { UploadedDocument } from '@/hooks/useStreamChat';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@ -17,6 +20,10 @@ interface MessageBubbleProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** 代码执行产生的图片(Base64) */
|
/** 代码执行产生的图片(Base64) */
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
/** 用户上传的图片(Base64 或 URL) */
|
||||||
|
uploadedImages?: string[];
|
||||||
|
/** 用户上传的文档 */
|
||||||
|
uploadedDocuments?: UploadedDocument[];
|
||||||
/** Pyodide 加载状态 */
|
/** Pyodide 加载状态 */
|
||||||
pyodideStatus?: {
|
pyodideStatus?: {
|
||||||
stage: 'loading' | 'ready' | 'error';
|
stage: 'loading' | 'ready' | 'error';
|
||||||
@ -25,11 +32,66 @@ interface MessageBubbleProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, pyodideStatus }: MessageBubbleProps) {
|
// 格式化文件大小
|
||||||
|
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 isUser = message.role === 'user';
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
const [copied, setCopied] = 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 () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
@ -45,8 +107,53 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
|
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
|
||||||
<div className="max-w-[70%] relative">
|
<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">
|
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed">
|
||||||
{message.content}
|
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
|
||||||
</div>
|
</div>
|
||||||
{/* 悬停显示复制按钮 */}
|
{/* 悬停显示复制按钮 */}
|
||||||
<button
|
<button
|
||||||
@ -68,6 +175,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{user && <Avatar name={user.name} size="md" />}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -144,12 +266,34 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 代码执行图片(从 props 传入) */}
|
{/* 代码执行图片(从 props 传入) */}
|
||||||
{images && images.length > 0 && (
|
{images && images.length > 0 && (() => {
|
||||||
<CodeExecutionResult
|
// 规范化所有图片 URL 用于 lightbox
|
||||||
images={images}
|
const normalizedImages = images.map(i => i.startsWith('data:') ? i : `data:image/png;base64,${i}`);
|
||||||
success={true}
|
|
||||||
/>
|
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 && (
|
{isStreaming && message.content && (
|
||||||
@ -183,6 +327,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 图片 Lightbox */}
|
||||||
|
<ImageLightbox
|
||||||
|
images={lightboxImages}
|
||||||
|
initialIndex={lightboxInitialIndex}
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
onClose={closeLightbox}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文档预览 */}
|
||||||
|
<DocumentPreview
|
||||||
|
documentData={documentPreviewData}
|
||||||
|
isOpen={documentPreviewOpen}
|
||||||
|
onClose={closeDocumentPreview}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user