import { Send, Loader2, Square, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon, } from 'lucide-react'; import { useState, useRef, useEffect } from 'react'; import type { Attachment } from '../../types/protocol'; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB interface MessageInputProps { onSend: (content: string, attachments: Attachment[]) => void; onStop?: () => void; disabled?: boolean; isLoading?: boolean; placeholder?: string; isReadOnly?: boolean; channelName?: string; selectedExpert?: { name: string; description: string } | null; } interface FileAttachment { id: string; file: File; attachment: Attachment; preview?: string; // 用于图片预览 } // 根据 MIME 类型判断 media_type function getMediaType(mimeType: string): string { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType.startsWith('video/')) return 'video'; return 'file'; } export function MessageInput({ onSend, onStop, disabled = false, isLoading = false, placeholder, isReadOnly = false, channelName, selectedExpert, }: MessageInputProps) { const effectivePlaceholder = placeholder ?? (selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令'); const [content, setContent] = useState(''); const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const wasLoadingRef = useRef(false); useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = 'auto'; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, [content]); // 当 isLoading 从 true 变为 false 时,自动聚焦输入框 useEffect(() => { if (wasLoadingRef.current && !isLoading && !isReadOnly) { textareaRef.current?.focus(); } wasLoadingRef.current = isLoading; }, [isLoading, isReadOnly]); // 处理文件选择 const handleFileSelect = async (files: FileList | null) => { if (!files) return; setError(null); const newAttachments: FileAttachment[] = []; for (const file of Array.from(files)) { // 检查文件大小 if (file.size > MAX_FILE_SIZE) { setError(`文件 "${file.name}" 超过 50MB 限制`); continue; } // 读取文件为 base64 const base64 = await readFileAsBase64(file); const mimeType = file.type || 'application/octet-stream'; const mediaType = getMediaType(mimeType); const attachment: Attachment = { path: file.name, media_type: mediaType, mime_type: mimeType, content_base64: base64, file_name: file.name, }; const fileAttachment: FileAttachment = { id: crypto.randomUUID(), file, attachment, preview: mediaType === 'image' ? base64 : undefined, }; newAttachments.push(fileAttachment); } setAttachments((prev) => [...prev, ...newAttachments]); }; // 读取文件为 base64 const readFileAsBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // 移除 data:xxx;base64, 前缀 const base64 = result.split(',')[1]; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(file); }); }; // 点击附件按钮 const handleAttachClick = () => { fileInputRef.current?.click(); }; // 删除附件 const handleRemoveAttachment = (index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)); }; // 粘贴事件处理 const handlePaste = async (e: React.ClipboardEvent) => { if (disabled || isReadOnly) return; const clipboardData = e.clipboardData; const items = clipboardData.items; // 检查是否有文件(图片或其他文件) const files: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } // 如果有文件,处理文件并阻止默认粘贴行为 if (files.length > 0) { e.preventDefault(); // 直接处理文件数组 setError(null); for (const file of files) { if (file.size > MAX_FILE_SIZE) { setError(`文件 "${file.name}" 超过 50MB 限制`); continue; } const base64 = await readFileAsBase64(file); const mimeType = file.type || 'application/octet-stream'; const mediaType = getMediaType(mimeType); const attachment: Attachment = { path: file.name, media_type: mediaType, mime_type: mimeType, content_base64: base64, file_name: file.name, }; const fileAttachment: FileAttachment = { id: crypto.randomUUID(), file, attachment, preview: mediaType === 'image' ? base64 : undefined, }; setAttachments((prev) => [...prev, fileAttachment]); } } // 否则让默认的文本粘贴行为继续 }; // 拖拽事件 const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!disabled && !isReadOnly) { setIsDragging(true); } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // 检查是否真的离开了拖拽区域(而不是进入子元素) const relatedTarget = e.relatedTarget as Node | null; const currentTarget = e.currentTarget; if (!relatedTarget || !currentTarget.contains(relatedTarget)) { setIsDragging(false); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!disabled && !isReadOnly) { handleFileSelect(e.dataTransfer.files); } }; const handleSend = () => { const hasContent = content.trim() || attachments.length > 0; if (hasContent && !disabled && !isReadOnly) { onSend( content.trim(), attachments.map((a) => a.attachment), ); setContent(''); setAttachments([]); setError(null); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; // 获取附件图标 const getAttachmentIcon = (mediaType: string) => { switch (mediaType) { case 'image': return ; case 'audio': return ; case 'video': return ; default: return ; } }; // 只读模式:显示提示占位符 if (isReadOnly) { return (
只读模式

{channelName ? ( <>{channelName} 通道仅支持查看历史消息 ) : ( '当前通道仅支持查看历史消息' )}

请切换至 WebSocket 通道进行输入

); } return (
{/* 错误提示 */} {error && (
{error}
)} {/* 附件预览列表 */} {attachments.length > 0 && (
{attachments.map((att, index) => (
{att.preview ? ( {att.attachment.file_name} ) : (
{getAttachmentIcon(att.attachment.media_type)}
)} {att.attachment.file_name}
))}
)} {/* 输入区域 */}
{/* 拖拽提示 */} {isDragging && (
拖放文件到这里
)} {/* 附件按钮 */} handleFileSelect(e.target.files)} /> {/* 输入框 */}