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) => {
|
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,
|
||||||
|
|||||||
@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,34 +218,113 @@ 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">
|
||||||
<div className="flex-1 relative flex items-center">
|
{/* 错误提示 */}
|
||||||
<textarea
|
{error && (
|
||||||
ref={textareaRef}
|
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||||||
value={content}
|
{error}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
</div>
|
||||||
onKeyDown={handleKeyDown}
|
)}
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
{/* 附件预览列表 */}
|
||||||
rows={1}
|
{attachments.length > 0 && (
|
||||||
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"
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
/>
|
{attachments.map((att, index) => (
|
||||||
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
|
<div
|
||||||
</div>
|
key={index}
|
||||||
<button
|
className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#1a1a25] px-2 py-1.5 text-sm"
|
||||||
onClick={handleSend}
|
>
|
||||||
disabled={disabled || !content.trim()}
|
{att.preview ? (
|
||||||
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"
|
<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}
|
||||||
>
|
>
|
||||||
{disabled ? (
|
{/* 拖拽提示 */}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{isDragging && (
|
||||||
) : (
|
<div className="absolute inset-0 rounded-xl bg-[#00f0ff]/10 border-2 border-[#00f0ff]/50 flex items-center justify-center z-10">
|
||||||
<Send className="h-4 w-4" />
|
<div className="text-[#00f0ff] text-sm font-medium">
|
||||||
|
拖放文件到这里
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
{/* 附件按钮 */}
|
||||||
<div className="mt-2 text-center text-xs text-zinc-500">
|
<button
|
||||||
按 Enter 发送,Shift+Enter 换行
|
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}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user