feat: 前端支持文件附件输入

- 后端 WsInbound::Message 添加 attachments 字段
- ws.rs 将 attachments 转换为 MediaItem
- 前端 MessageInput 支持点击选择和拖拽文件
- 附件预览列表,支持删除
- 文件大小限制 50MB
- 支持所有文件类型
This commit is contained in:
ooodc 2026-05-30 08:07:02 +08:00
parent 3d9c981c2a
commit c2293238fc
7 changed files with 268 additions and 37 deletions

View File

@ -209,6 +209,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
InputEvent::Message(msg) => { InputEvent::Message(msg) => {
let inbound = WsInbound::Message { let inbound = WsInbound::Message {
content: msg.content, content: msg.content,
attachments: Vec::new(),
channel: None, channel: None,
chat_id: current_session_id.clone(), chat_id: current_session_id.clone(),
sender_id: None, sender_id: None,

View File

@ -1,6 +1,6 @@
use super::GatewayState; use super::GatewayState;
use crate::agent::{AgentError, CompositeSystemPromptProvider}; use crate::agent::{AgentError, CompositeSystemPromptProvider};
use crate::bus::InboundMessage; use crate::bus::{InboundMessage, MediaItem};
use crate::command::adapter::{InputAdapter, OutputAdapter}; use crate::command::adapter::{InputAdapter, OutputAdapter};
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter}; use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
@ -196,6 +196,7 @@ async fn handle_inbound(
match inbound { match inbound {
WsInbound::Message { WsInbound::Message {
content, content,
attachments,
chat_id, chat_id,
sender_id, sender_id,
.. ..
@ -213,6 +214,19 @@ async fn handle_inbound(
) )
.await; .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 state
.bus .bus
.publish_inbound(InboundMessage { .publish_inbound(InboundMessage {
@ -221,7 +235,7 @@ async fn handle_inbound(
chat_id, chat_id,
content, content,
timestamp: current_timestamp(), timestamp: current_timestamp(),
media: Vec::new(), media,
metadata: HashMap::new(), metadata: HashMap::new(),
forwarded_metadata: HashMap::new(), forwarded_metadata: HashMap::new(),
}) })

View File

@ -55,6 +55,8 @@ pub enum WsInbound {
#[serde(rename = "message")] #[serde(rename = "message")]
Message { Message {
content: String, content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<MediaSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
channel: Option<String>, channel: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]

View File

@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { useWebSocket } from './hooks/useWebSocket' import { useWebSocket } from './hooks/useWebSocket'
import { useChat } from './hooks/useChat' 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' const WS_URL = 'ws://127.0.0.1:19876/ws'
@ -89,7 +89,7 @@ function App() {
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]); }, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
(content: string) => { (content: string, attachments: Attachment[] = []) => {
if (isReadOnly || !sessionId) { if (isReadOnly || !sessionId) {
return return
} }
@ -130,6 +130,7 @@ function App() {
sendMessage({ sendMessage({
type: 'message', type: 'message',
content, content,
attachments,
chat_id: chatId, chat_id: chatId,
}) })
} }

View File

@ -1,13 +1,13 @@
import { MessageList } from './MessageList' import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput' import { MessageInput } from './MessageInput'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage, Attachment } from '../../types/protocol'
interface ChatContainerProps { interface ChatContainerProps {
messages: ChatMessage[] messages: ChatMessage[]
isLoading: boolean isLoading: boolean
isReadOnly?: boolean isReadOnly?: boolean
channelName?: string channelName?: string
onSendMessage: (content: string) => void onSendMessage: (content: string, attachments: Attachment[]) => void
onNavigateToSubAgent?: (taskId: string, description: string) => void onNavigateToSubAgent?: (taskId: string, description: string) => void
} }

View File

@ -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 { useState, useRef, useEffect } from 'react'
import type { Attachment } from '../../types/protocol'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
interface MessageInputProps { interface MessageInputProps {
onSend: (content: string) => void onSend: (content: string, attachments: Attachment[]) => void
disabled?: boolean disabled?: boolean
isLoading?: boolean isLoading?: boolean
placeholder?: string placeholder?: string
@ -10,6 +13,20 @@ interface MessageInputProps {
channelName?: string 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({ export function MessageInput({
onSend, onSend,
disabled = false, disabled = false,
@ -19,7 +36,11 @@ export function MessageInput({
channelName, channelName,
}: MessageInputProps) { }: MessageInputProps) {
const [content, setContent] = useState('') 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 textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const wasLoadingRef = useRef(false) const wasLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
@ -38,10 +59,108 @@ export function MessageInput({
wasLoadingRef.current = isLoading wasLoadingRef.current = isLoading
}, [isLoading, isReadOnly]) }, [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 = () => { const handleSend = () => {
if (content.trim() && !disabled && !isReadOnly) { const hasContent = content.trim() || attachments.length > 0
onSend(content.trim()) if (hasContent && !disabled && !isReadOnly) {
onSend(
content.trim(),
attachments.map(a => a.attachment)
)
setContent('') setContent('')
setAttachments([])
setError(null)
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto' 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) { if (isReadOnly) {
return ( return (
@ -85,7 +218,81 @@ export function MessageInput({
return ( return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4"> <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"> <div className="flex-1 relative flex items-center">
<textarea <textarea
ref={textareaRef} 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" /> <Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
</div> </div>
{/* 发送按钮 */}
<button <button
onClick={handleSend} 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" 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 ? ( {disabled ? (
@ -111,8 +320,11 @@ export function MessageInput({
)} )}
</button> </button>
</div> </div>
{/* 提示 */}
<div className="mt-2 text-center text-xs text-zinc-500"> <div className="mt-2 text-center text-xs text-zinc-500">
Enter Shift+Enter Enter Shift+Enter · · 50MB
</div>
</div> </div>
</div> </div>
) )

View File

@ -7,6 +7,7 @@
export interface WsInboundMessage { export interface WsInboundMessage {
type: 'message' type: 'message'
content: string content: string
attachments?: Attachment[]
channel?: string channel?: string
chat_id?: string chat_id?: string
sender_id?: string sender_id?: string