PicoBot/web/src/components/Chat/MessageInput.tsx
ooodc 7d9355fd78 feat: WebSocket 媒体文件处理优化
- 后端 ws.rs: 处理前端上传的 base64 内容,保存到本地文件并更新路径
- 后端 ws.rs: 历史消息加载时从文件读取内容填充 base64,过滤 media_refs_json
- 前端 App.tsx: 传递 attachments 给 handleMessage 实现实时显示
- 前端 useChat.ts: handleMessage 支持 attachments 参数
- 前端 MessageInput.tsx: 支持剪贴板粘贴文件/图片
- 前端 MessageInput.tsx: 修复拖拽文件时闪烁问题
- 测试 test_request_format.rs: 补充缺失的 attachments 字段
2026-05-30 10:22:30 +08:00

390 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Send, Loader2, 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
disabled?: boolean
isLoading?: boolean
placeholder?: string
isReadOnly?: boolean
channelName?: string
}
interface FileAttachment {
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,
disabled = false,
isLoading = false,
placeholder = '输入消息...按 / 查看命令',
isReadOnly = false,
channelName,
}: MessageInputProps) {
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<FileAttachment[]>([])
const [isDragging, setIsDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(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 = {
file,
attachment,
preview: mediaType === 'image' ? base64 : undefined,
}
newAttachments.push(fileAttachment)
}
setAttachments(prev => [...prev, ...newAttachments])
}
// 读取文件为 base64
const readFileAsBase64 = (file: File): Promise<string> => {
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 = {
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 <ImageIcon className="h-4 w-4" />
case 'audio':
return <MusicIcon className="h-4 w-4" />
case 'video':
return <VideoIcon className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
// 只读模式:显示提示占位符
if (isReadOnly) {
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
<div className="rounded-xl border border-zinc-500/20 bg-[#1a1a25]/50 px-4 py-5 text-center">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-zinc-400">
<Eye className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-zinc-500">
{channelName ? (
<>{channelName} </>
) : (
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-zinc-600">
WebSocket
</p>
</div>
</div>
</div>
</div>
)
}
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
{/* 错误提示 */}
{error && (
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* 附件预览列表 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#1a1a25] px-2 py-1.5 text-sm"
>
{att.preview ? (
<img
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
alt={att.attachment.file_name}
className="h-8 w-8 rounded object-cover"
/>
) : (
<div className="h-8 w-8 rounded bg-zinc-700/50 flex items-center justify-center text-zinc-400">
{getAttachmentIcon(att.attachment.media_type)}
</div>
)}
<span className="text-zinc-300 max-w-[120px] truncate">
{att.attachment.file_name}
</span>
<button
onClick={() => handleRemoveAttachment(index)}
className="text-zinc-500 hover:text-red-400 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
{/* 输入区域 */}
<div
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[#00f0ff]/50 rounded-xl' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖拽提示 */}
{isDragging && (
<div className="absolute inset-0 rounded-xl bg-[#00f0ff]/10 border-2 border-[#00f0ff]/50 flex items-center justify-center z-10">
<div className="text-[#00f0ff] text-sm font-medium">
</div>
</div>
)}
{/* 附件按钮 */}
<button
onClick={handleAttachClick}
disabled={disabled}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-[#1a1a25] text-zinc-400 hover:text-white hover:border-white/20 disabled:opacity-50 transition-all"
>
<Paperclip className="h-5 w-5" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
{/* 输入框 */}
<div className="flex-1 relative flex items-center">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all self-center scrollbar-hide"
/>
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
</div>
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={disabled || (!content.trim() && attachments.length === 0)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
>
{disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
{/* 提示 */}
<div className="mt-2 text-center text-xs text-zinc-500">
Enter Shift+Enter · / · 50MB
</div>
</div>
</div>
)
}