diff --git a/src/components/features/ChatInput.tsx b/src/components/features/ChatInput.tsx index 6c5172d..2002cb8 100644 --- a/src/components/features/ChatInput.tsx +++ b/src/components/features/ChatInput.tsx @@ -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(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) => { + 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 (
+ {/* 拖拽覆盖层 */} + {isDragging && ( +
+ +

+ 释放以添加文件 +

+

+ 支持图片、PDF、文档等格式 +

+
+ )} + + {/* 文件预览区域 */} + {files.length > 0 && ( +
+ +
+ )} + {/* 第一行:输入区域 */}
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)]" />
@@ -75,12 +137,27 @@ export function ChatInput({
{/* 添加附件 */} + {/* 隐藏的文件输入 */} + + {/* 工具下拉 */} 0 ? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5' : 'opacity-50 cursor-not-allowed' )} @@ -115,6 +192,11 @@ export function ChatInput({
+ + {/* 提示文字 */} +

+ 可拖拽文件到此处或使用 Ctrl+V 粘贴图片 +

); } diff --git a/src/components/features/MessageBubble.tsx b/src/components/features/MessageBubble.tsx index 8c9f3d9..24ecac1 100644 --- a/src/components/features/MessageBubble.tsx +++ b/src/components/features/MessageBubble.tsx @@ -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([]); + const [lightboxInitialIndex, setLightboxInitialIndex] = useState(0); + + // 文档预览状态 + const [documentPreviewOpen, setDocumentPreviewOpen] = useState(false); + const [documentPreviewData, setDocumentPreviewData] = useState(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 (
+ {/* 用户上传的图片 */} + {uploadedImages && uploadedImages.length > 0 && ( +
+ {uploadedImages.map((img, index) => ( +
openLightbox(uploadedImages, index)} + > + {`上传的图片 +
+
+ ))} +
+ )} + {/* 用户上传的文档 */} + {uploadedDocuments && uploadedDocuments.length > 0 && ( +
+ {uploadedDocuments.map((doc, index) => { + const Icon = getDocumentIcon(doc.type); + return ( +
openDocumentPreview(doc)} + title="点击预览" + > + +
+ + {doc.name} + + + {formatFileSize(doc.size)} + +
+
+ ); + })} +
+ )}
- {message.content} + {message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
{/* 悬停显示复制按钮 */}
{user && } + + {/* 图片 Lightbox */} + + + {/* 文档预览 */} +
); } @@ -144,12 +266,34 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err )} {/* 代码执行图片(从 props 传入) */} - {images && images.length > 0 && ( - - )} + {images && images.length > 0 && (() => { + // 规范化所有图片 URL 用于 lightbox + const normalizedImages = images.map(i => i.startsWith('data:') ? i : `data:image/png;base64,${i}`); + + return ( +
+
+ {normalizedImages.map((img, index) => ( +
openLightbox(normalizedImages, index)} + > + {`图表 +
+ 图表 {index + 1} +
+
+ ))} +
+
+ ); + })()} {/* 流式状态指示器 */} {isStreaming && message.content && ( @@ -183,6 +327,21 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err )}
+ + {/* 图片 Lightbox */} + + + {/* 文档预览 */} + ); }