From c2293238fc7828c27eb73f80aa359b1497b55532 Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Sat, 30 May 2026 08:07:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=99=84=E4=BB=B6=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 WsInbound::Message 添加 attachments 字段 - ws.rs 将 attachments 转换为 MediaItem - 前端 MessageInput 支持点击选择和拖拽文件 - 附件预览列表,支持删除 - 文件大小限制 50MB - 支持所有文件类型 --- src/client/mod.rs | 1 + src/gateway/ws.rs | 18 +- src/protocol/mod.rs | 2 + web/src/App.tsx | 5 +- web/src/components/Chat/ChatContainer.tsx | 4 +- web/src/components/Chat/MessageInput.tsx | 274 +++++++++++++++++++--- web/src/types/protocol.ts | 1 + 7 files changed, 268 insertions(+), 37 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 8ae48af..2d7498c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -209,6 +209,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { InputEvent::Message(msg) => { let inbound = WsInbound::Message { content: msg.content, + attachments: Vec::new(), channel: None, chat_id: current_session_id.clone(), sender_id: None, diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 351150c..5a1520f 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -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 = 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(), }) diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index a96c13a..4e89d50 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -55,6 +55,8 @@ pub enum WsInbound { #[serde(rename = "message")] Message { content: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] channel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/web/src/App.tsx b/web/src/App.tsx index 3032825..caf9c71 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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, }) } diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index 45bc846..6e71d4e 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -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 } diff --git a/web/src/components/Chat/MessageInput.tsx b/web/src/components/Chat/MessageInput.tsx index 68755be..568f5d7 100644 --- a/web/src/components/Chat/MessageInput.tsx +++ b/web/src/components/Chat/MessageInput.tsx @@ -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([]) + const [isDragging, setIsDragging] = useState(false) + const [error, setError] = useState(null) const textareaRef = useRef(null) + const fileInputRef = useRef(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 => { + 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 + case 'audio': + return + case 'video': + return + default: + return + } + } + // 只读模式:显示提示占位符 if (isReadOnly) { return ( @@ -85,35 +218,114 @@ export function MessageInput({ return (
-
-
-