feat(组件): 聊天输入框和消息气泡支持文件上传

ChatInput 组件:
- 集成 useFileUpload Hook 实现文件管理
- 支持拖拽文件到输入框上传
- 支持 Ctrl+V 粘贴图片
- 添加文件选择按钮和隐藏的 file input
- 拖拽时显示覆盖层提示
- 输入框上方显示已选文件预览

MessageBubble 组件:
- 显示用户上传的图片缩略图
- 点击图片打开 Lightbox 大图预览
- 显示用户上传的文档卡片
- 点击文档打开预览弹窗
- 代码执行图片也支持点击放大
This commit is contained in:
gaoziman 2025-12-20 12:14:41 +08:00
parent 00b8589e03
commit 4cb3f162e3
2 changed files with 262 additions and 21 deletions

View File

@ -1,11 +1,14 @@
'use client';
import { useState } from 'react';
import { Plus, ArrowUp } from 'lucide-react';
import { useState, useRef } from 'react';
import { Plus, ArrowUp, Upload } from 'lucide-react';
import { ModelSelector } from './ModelSelector';
import { ToolsDropdown } from './ToolsDropdown';
import { FilePreviewList } from './FilePreviewList';
import { useFileUpload } from '@/hooks/useFileUpload';
import { cn } from '@/lib/utils';
import type { Model, Tool } from '@/types';
import type { UploadFile } from '@/types/file-upload';
interface ChatInputProps {
models: Model[];
@ -14,7 +17,7 @@ interface ChatInputProps {
tools: Tool[];
onToolToggle: (toolId: string) => void;
onEnableAllTools: (enabled: boolean) => void;
onSend: (message: string) => void;
onSend: (message: string, files?: UploadFile[]) => void;
placeholder?: string;
className?: string;
}
@ -31,11 +34,27 @@ export function ChatInput({
className,
}: ChatInputProps) {
const [message, setMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// 使用文件上传 Hook
const {
files,
isDragging,
addFiles,
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
} = useFileUpload();
const handleSend = () => {
if (message.trim()) {
onSend(message);
if (message.trim() || files.length > 0) {
onSend(message, files.length > 0 ? files : undefined);
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 (
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
<div
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',
'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">
<input
@ -64,7 +125,8 @@ export function ChatInput({
value={message}
onChange={(e) => setMessage(e.target.value)}
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)]"
/>
</div>
@ -75,12 +137,27 @@ export function ChatInput({
<div className="flex items-center gap-1">
{/* 添加附件 */}
<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"
title="Add attachment"
onClick={openFileDialog}
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} />
</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
tools={tools}
@ -101,10 +178,10 @@ export function ChatInput({
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={!message.trim()}
disabled={!message.trim() && files.length === 0}
className={cn(
'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'
: 'opacity-50 cursor-not-allowed'
)}
@ -115,6 +192,11 @@ export function ChatInput({
</div>
</div>
</div>
{/* 提示文字 */}
<p className="text-xs text-[var(--color-text-tertiary)] text-center mt-2">
使 Ctrl+V
</p>
</div>
);
}

View File

@ -1,13 +1,16 @@
'use client';
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 { 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;
@ -17,6 +20,10 @@ interface MessageBubbleProps {
error?: string;
/** 代码执行产生的图片Base64 */
images?: string[];
/** 用户上传的图片Base64 或 URL */
uploadedImages?: string[];
/** 用户上传的文档 */
uploadedDocuments?: UploadedDocument[];
/** Pyodide 加载状态 */
pyodideStatus?: {
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 [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 {
@ -45,8 +107,53 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
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}
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
</div>
{/* 悬停显示复制按钮 */}
<button
@ -68,6 +175,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
</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>
);
}
@ -144,12 +266,34 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
)}
{/* 代码执行图片(从 props 传入) */}
{images && images.length > 0 && (
<CodeExecutionResult
images={images}
success={true}
/>
)}
{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 && (
@ -183,6 +327,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
)}
</div>
</div>
{/* 图片 Lightbox */}
<ImageLightbox
images={lightboxImages}
initialIndex={lightboxInitialIndex}
isOpen={lightboxOpen}
onClose={closeLightbox}
/>
{/* 文档预览 */}
<DocumentPreview
documentData={documentPreviewData}
isOpen={documentPreviewOpen}
onClose={closeDocumentPreview}
/>
</div>
);
}