feat: 前端支持文件附件输入
- 后端 WsInbound::Message 添加 attachments 字段 - ws.rs 将 attachments 转换为 MediaItem - 前端 MessageInput 支持点击选择和拖拽文件 - 附件预览列表,支持删除 - 文件大小限制 50MB - 支持所有文件类型
This commit is contained in:
parent
3d9c981c2a
commit
c2293238fc
@ -209,6 +209,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
InputEvent::Message(msg) => {
|
||||
let inbound = WsInbound::Message {
|
||||
content: msg.content,
|
||||
attachments: Vec::new(),
|
||||
channel: None,
|
||||
chat_id: current_session_id.clone(),
|
||||
sender_id: None,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::GatewayState;
|
||||
use crate::agent::{AgentError, CompositeSystemPromptProvider};
|
||||
use crate::bus::InboundMessage;
|
||||
use crate::bus::{InboundMessage, MediaItem};
|
||||
use crate::command::adapter::{InputAdapter, OutputAdapter};
|
||||
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
|
||||
use crate::command::context::CommandContext;
|
||||
@ -196,6 +196,7 @@ async fn handle_inbound(
|
||||
match inbound {
|
||||
WsInbound::Message {
|
||||
content,
|
||||
attachments,
|
||||
chat_id,
|
||||
sender_id,
|
||||
..
|
||||
@ -213,6 +214,19 @@ async fn handle_inbound(
|
||||
)
|
||||
.await;
|
||||
|
||||
// 将协议层 attachments 转换为内部 MediaItem
|
||||
let media: Vec<MediaItem> = attachments
|
||||
.iter()
|
||||
.map(|a| MediaItem {
|
||||
path: a.path.clone(),
|
||||
media_type: a.media_type.clone(),
|
||||
mime_type: a.mime_type.clone(),
|
||||
original_key: None,
|
||||
content_base64: a.content_base64.clone(),
|
||||
file_name: a.file_name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
state
|
||||
.bus
|
||||
.publish_inbound(InboundMessage {
|
||||
@ -221,7 +235,7 @@ async fn handle_inbound(
|
||||
chat_id,
|
||||
content,
|
||||
timestamp: current_timestamp(),
|
||||
media: Vec::new(),
|
||||
media,
|
||||
metadata: HashMap::new(),
|
||||
forwarded_metadata: HashMap::new(),
|
||||
})
|
||||
|
||||
@ -55,6 +55,8 @@ pub enum WsInbound {
|
||||
#[serde(rename = "message")]
|
||||
Message {
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<MediaSummary>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
channel: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel'
|
||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import type { ChatMessage, Command } from './types/protocol'
|
||||
import type { ChatMessage, Command, Attachment } from './types/protocol'
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||
|
||||
@ -89,7 +89,7 @@ function App() {
|
||||
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
(content: string, attachments: Attachment[] = []) => {
|
||||
if (isReadOnly || !sessionId) {
|
||||
return
|
||||
}
|
||||
@ -130,6 +130,7 @@ function App() {
|
||||
sendMessage({
|
||||
type: 'message',
|
||||
content,
|
||||
attachments,
|
||||
chat_id: chatId,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { MessageList } from './MessageList'
|
||||
import { MessageInput } from './MessageInput'
|
||||
import type { ChatMessage } from '../../types/protocol'
|
||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||
|
||||
interface ChatContainerProps {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
isReadOnly?: boolean
|
||||
channelName?: string
|
||||
onSendMessage: (content: string) => void
|
||||
onSendMessage: (content: string, attachments: Attachment[]) => void
|
||||
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { Send, Loader2, Sparkles, Eye } from 'lucide-react'
|
||||
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) => void
|
||||
onSend: (content: string, attachments: Attachment[]) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
placeholder?: string
|
||||
@ -10,6 +13,20 @@ interface MessageInputProps {
|
||||
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,
|
||||
@ -19,7 +36,11 @@ export function MessageInput({
|
||||
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(() => {
|
||||
@ -38,10 +59,108 @@ export function MessageInput({
|
||||
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 handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isReadOnly) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
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 = () => {
|
||||
if (content.trim() && !disabled && !isReadOnly) {
|
||||
onSend(content.trim())
|
||||
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'
|
||||
}
|
||||
@ -55,6 +174,20 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
// 获取附件图标
|
||||
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 (
|
||||
@ -85,7 +218,81 @@ export function MessageInput({
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
|
||||
<div className="flex gap-3 items-center max-w-5xl mx-auto">
|
||||
<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}
|
||||
@ -99,9 +306,11 @@ export function MessageInput({
|
||||
/>
|
||||
<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()}
|
||||
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 ? (
|
||||
@ -111,8 +320,11 @@ export function MessageInput({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="mt-2 text-center text-xs text-zinc-500">
|
||||
按 Enter 发送,Shift+Enter 换行
|
||||
按 Enter 发送,Shift+Enter 换行 · 支持拖拽文件 · 最大 50MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
export interface WsInboundMessage {
|
||||
type: 'message'
|
||||
content: string
|
||||
attachments?: Attachment[]
|
||||
channel?: string
|
||||
chat_id?: string
|
||||
sender_id?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user