feat: 添加附件支持、自动选择话题及消息展示优化
- 消息协议新增 attachments 字段,支持图片/音频/视频/文件附件 - 文本和附件合并在一条消息中发送,不再拆分为多条 - Topics 加载后自动选中第一个话题并加载历史消息 - 用户消息现在通过 WebSocket 发送,可在前端展示 - 前端过滤 tool_result 消息,添加附件卡片展示组件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
542e11d0b3
commit
7898ca69e4
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,3 +30,4 @@ output
|
||||
.python-version
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
node_modules
|
||||
|
||||
@ -77,6 +77,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
MessageKind::Notification => {
|
||||
// 根据元数据判断具体类型
|
||||
@ -96,6 +97,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
}
|
||||
} else if let Some(session_id) = response.metadata.get("session_id") {
|
||||
@ -134,6 +136,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
}
|
||||
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
||||
@ -151,6 +154,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
}
|
||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||
@ -169,6 +173,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@ -177,6 +182,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -188,6 +194,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
};
|
||||
outbounds.push(outbound);
|
||||
|
||||
@ -33,6 +33,7 @@ impl SessionMessageSender for BusSessionMessageSender {
|
||||
.ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?;
|
||||
|
||||
let metadata = HashMap::new();
|
||||
let attachment_count = request.attachments.len();
|
||||
let mut published_messages = 0;
|
||||
let text_sent = request
|
||||
.text
|
||||
@ -43,47 +44,49 @@ impl SessionMessageSender for BusSessionMessageSender {
|
||||
|
||||
if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) {
|
||||
let content_len = text.len();
|
||||
self.bus
|
||||
.publish_outbound(OutboundMessage::assistant(
|
||||
channel_name.to_string(),
|
||||
chat_id.to_string(),
|
||||
None, // session_id
|
||||
text,
|
||||
None,
|
||||
metadata.clone(),
|
||||
))
|
||||
.await?;
|
||||
published_messages += 1;
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
chat_id = %chat_id,
|
||||
content_len = content_len,
|
||||
"Published session text message to outbound bus"
|
||||
);
|
||||
}
|
||||
|
||||
let attachment_count = request.attachments.len();
|
||||
for attachment in request.attachments {
|
||||
let media_path = attachment.path.clone();
|
||||
let media_type = attachment.media_type.clone();
|
||||
let mut outbound = OutboundMessage::assistant(
|
||||
channel_name.to_string(),
|
||||
chat_id.to_string(),
|
||||
None, // session_id
|
||||
String::new(),
|
||||
text,
|
||||
None,
|
||||
metadata.clone(),
|
||||
);
|
||||
outbound.media = vec![attachment];
|
||||
if attachment_count > 0 {
|
||||
outbound.media = request.attachments.clone();
|
||||
}
|
||||
self.bus.publish_outbound(outbound).await?;
|
||||
published_messages += 1;
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
chat_id = %chat_id,
|
||||
media_type = %media_type,
|
||||
media_path = %media_path,
|
||||
"Published session attachment to outbound bus"
|
||||
content_len = content_len,
|
||||
attachment_count = attachment_count,
|
||||
"Published session text message to outbound bus"
|
||||
);
|
||||
} else {
|
||||
for attachment in request.attachments {
|
||||
let media_path = attachment.path.clone();
|
||||
let media_type = attachment.media_type.clone();
|
||||
let mut outbound = OutboundMessage::assistant(
|
||||
channel_name.to_string(),
|
||||
chat_id.to_string(),
|
||||
None, // session_id
|
||||
String::new(),
|
||||
None,
|
||||
metadata.clone(),
|
||||
);
|
||||
outbound.media = vec![attachment];
|
||||
self.bus.publish_outbound(outbound).await?;
|
||||
published_messages += 1;
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
chat_id = %chat_id,
|
||||
media_type = %media_type,
|
||||
media_path = %media_path,
|
||||
"Published session attachment to outbound bus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SessionSendOutcome {
|
||||
@ -129,19 +132,15 @@ mod tests {
|
||||
assert_eq!(
|
||||
outcome,
|
||||
SessionSendOutcome {
|
||||
published_messages: 2,
|
||||
published_messages: 1,
|
||||
text_sent: true,
|
||||
attachment_count: 1,
|
||||
}
|
||||
);
|
||||
|
||||
let first = bus.consume_outbound().await;
|
||||
assert_eq!(first.content, "hello");
|
||||
assert!(first.media.is_empty());
|
||||
|
||||
let second = bus.consume_outbound().await;
|
||||
assert_eq!(second.content, "");
|
||||
assert_eq!(second.media.len(), 1);
|
||||
assert_eq!(second.media[0].media_type, "image");
|
||||
let msg = bus.consume_outbound().await;
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert_eq!(msg.media.len(), 1);
|
||||
assert_eq!(msg.media[0].media_type, "image");
|
||||
}
|
||||
}
|
||||
@ -355,10 +355,28 @@ async fn handle_inbound(
|
||||
*current_topic_id = Some(topic_id.clone());
|
||||
|
||||
// 加载并发送该话题的历史消息
|
||||
if let Err(e) = send_topic_history(&store, topic_id, sender).await {
|
||||
if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender).await {
|
||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
||||
}
|
||||
}
|
||||
if current_topic_id.is_none() {
|
||||
if let Some(topics_json) = response.metadata.get("topics") {
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
Ok(topics) => {
|
||||
if let Some(first_topic) = topics.first() {
|
||||
let topic_id = first_topic.topic_id.clone();
|
||||
*current_topic_id = Some(topic_id.clone());
|
||||
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender).await {
|
||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to parse topics metadata for initial history");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(ref error) = response.error {
|
||||
tracing::warn!(
|
||||
error_code = %error.code,
|
||||
@ -400,11 +418,15 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
|
||||
/// 加载并发送话题历史消息
|
||||
async fn send_topic_history(
|
||||
store: &Arc<crate::storage::SessionStore>,
|
||||
session_id: &str,
|
||||
topic_id: &str,
|
||||
sender: &mpsc::Sender<WsOutbound>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 加载话题消息
|
||||
let messages = store.load_messages_for_topic(topic_id)?;
|
||||
let mut messages = store.load_messages_for_topic(topic_id)?;
|
||||
if messages.is_empty() {
|
||||
messages = store.load_messages(session_id)?;
|
||||
}
|
||||
|
||||
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
|
||||
|
||||
@ -443,6 +465,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
||||
id: msg.id.clone(),
|
||||
content: msg.content.clone(),
|
||||
role: msg.role.clone(),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
"tool" => {
|
||||
@ -465,10 +488,12 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
||||
}),
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
// 用户消息不通过 WsOutbound 发送,前端自己维护
|
||||
None
|
||||
}
|
||||
"user" => Some(WsOutbound::AssistantResponse {
|
||||
id: msg.id.clone(),
|
||||
content: msg.content.clone(),
|
||||
role: msg.role.clone(),
|
||||
attachments: Vec::new(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,14 @@ pub struct TopicSummary {
|
||||
pub last_active_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaSummary {
|
||||
pub path: String,
|
||||
pub media_type: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum WsInbound {
|
||||
@ -63,6 +71,8 @@ pub enum WsOutbound {
|
||||
id: String,
|
||||
content: String,
|
||||
role: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<MediaSummary>,
|
||||
},
|
||||
#[serde(rename = "tool_call")]
|
||||
ToolCall {
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::bus::message::OutboundEventKind;
|
||||
#[cfg(test)]
|
||||
use crate::bus::message::{ToolMessageState, format_tool_call_content};
|
||||
|
||||
use super::WsOutbound;
|
||||
use super::{MediaSummary, WsOutbound};
|
||||
|
||||
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。";
|
||||
|
||||
@ -20,6 +20,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
id: message.id.clone(),
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -37,6 +38,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
id: message.id.clone(),
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
attachments: Vec::new(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
@ -68,10 +70,20 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
|
||||
match message.event_kind {
|
||||
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => {
|
||||
let attachments: Vec<MediaSummary> = message
|
||||
.media
|
||||
.iter()
|
||||
.map(|m| MediaSummary {
|
||||
path: m.path.clone(),
|
||||
media_type: m.media_type.clone(),
|
||||
mime_type: m.mime_type.clone(),
|
||||
})
|
||||
.collect();
|
||||
vec![WsOutbound::AssistantResponse {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
attachments,
|
||||
}]
|
||||
}
|
||||
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Zap, Cpu, MessageSquare } from 'lucide-react'
|
||||
import { ChatContainer } from './components/Chat/ChatContainer'
|
||||
import { TopicList } from './components/Sidebar/TopicList'
|
||||
@ -12,6 +12,8 @@ import type { Command } from './types/protocol'
|
||||
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||
|
||||
function App() {
|
||||
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
|
||||
|
||||
const {
|
||||
// 连接状态
|
||||
connectionId,
|
||||
@ -64,6 +66,24 @@ function App() {
|
||||
}
|
||||
}, [sessionId, status, handleCommand, sendMessage, requestTopicList])
|
||||
|
||||
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息
|
||||
useEffect(() => {
|
||||
if (topics.length === 0 || status !== 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
const firstTopic = topics[0]
|
||||
if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
|
||||
return
|
||||
}
|
||||
|
||||
lastAutoSwitchedTopicRef.current = firstTopic.id
|
||||
selectTopic(firstTopic.id)
|
||||
const cmd = switchTopic(firstTopic.id)
|
||||
handleCommand(cmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (isReadOnly || !sessionId) {
|
||||
@ -136,6 +156,7 @@ function App() {
|
||||
[sendMessage, handleCommand, switchTopic, selectTopic]
|
||||
)
|
||||
|
||||
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
|
||||
const toolMessages = messages
|
||||
|
||||
return (
|
||||
@ -199,7 +220,7 @@ function App() {
|
||||
{/* Center - Chat */}
|
||||
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
messages={chatMessages}
|
||||
isLoading={isLoading}
|
||||
isReadOnly={isReadOnly}
|
||||
channelName={session?.title ?? 'PicoBot'}
|
||||
|
||||
@ -1,12 +1,50 @@
|
||||
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } from 'lucide-react'
|
||||
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { ChatMessage } from '../../types/protocol'
|
||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage
|
||||
}
|
||||
|
||||
function getAttachmentIcon(mediaType: string) {
|
||||
switch (mediaType) {
|
||||
case 'image': return <Image className="h-4 w-4" />
|
||||
case 'audio': return <Music className="h-4 w-4" />
|
||||
case 'video': return <Video className="h-4 w-4" />
|
||||
case 'file': return <FileText className="h-4 w-4" />
|
||||
default: return <File className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
const parts = path.replace(/\\/g, '/').split('/')
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||
const fileName = getFileName(attachment.path)
|
||||
const downloadUrl = `/download?path=${encodeURIComponent(attachment.path)}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={fileName}
|
||||
className="flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-xs hover:bg-white/10 hover:border-[#00f0ff]/30 transition-colors cursor-pointer group"
|
||||
title={`下载 ${fileName}`}
|
||||
>
|
||||
<span className="text-zinc-400 group-hover:text-[#00f0ff] transition-colors">
|
||||
{getAttachmentIcon(attachment.media_type)}
|
||||
</span>
|
||||
<span className="text-zinc-300 truncate max-w-[200px] group-hover:text-white transition-colors" title={attachment.path}>
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</span>
|
||||
<Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const isTool = message.role === 'tool'
|
||||
@ -160,6 +198,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{message.attachments.map((att: Attachment, idx: number) => (
|
||||
<AttachmentCard key={idx} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -113,7 +113,6 @@ export function useChat(): UseChatReturn {
|
||||
|
||||
case 'session_loaded': {
|
||||
setIsLoading(false)
|
||||
setMessages([])
|
||||
break
|
||||
}
|
||||
|
||||
@ -133,23 +132,22 @@ export function useChat(): UseChatReturn {
|
||||
setTopics(newTopics)
|
||||
|
||||
// 默认选中第一个 Topic(如果没有选中)
|
||||
if (newTopics.length > 0 && !selectedTopic) {
|
||||
setSelectedTopic(newTopics[0].id)
|
||||
}
|
||||
setIsLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
case 'assistant_response': {
|
||||
const msg = message as AssistantResponse
|
||||
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: msg.id,
|
||||
role: 'assistant',
|
||||
role,
|
||||
content: msg.content,
|
||||
timestamp: Date.now(),
|
||||
type: 'message',
|
||||
attachments: msg.attachments,
|
||||
},
|
||||
])
|
||||
setIsLoading(false)
|
||||
@ -225,7 +223,7 @@ export function useChat(): UseChatReturn {
|
||||
// 忽略这些消息
|
||||
break
|
||||
}
|
||||
}, [selectedTopic])
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback((content: string) => {
|
||||
setMessages((prev) => [
|
||||
|
||||
@ -27,11 +27,18 @@ export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
|
||||
// Outbound Messages (Server -> Client)
|
||||
// ============================================================================
|
||||
|
||||
export interface Attachment {
|
||||
path: string
|
||||
media_type: string
|
||||
mime_type?: string
|
||||
}
|
||||
|
||||
export interface AssistantResponse {
|
||||
type: 'assistant_response'
|
||||
id: string
|
||||
content: string
|
||||
role: string
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
@ -241,6 +248,7 @@ export interface ChatMessage {
|
||||
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
|
||||
toolName?: string
|
||||
arguments?: unknown
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user