feat: 添加附件支持、自动选择话题及消息展示优化

- 消息协议新增 attachments 字段,支持图片/音频/视频/文件附件
- 文本和附件合并在一条消息中发送,不再拆分为多条
- Topics 加载后自动选中第一个话题并加载历史消息
- 用户消息现在通过 WebSocket 发送,可在前端展示
- 前端过滤 tool_result 消息,添加附件卡片展示组件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-28 11:51:48 +08:00
parent 542e11d0b3
commit 7898ca69e4
10 changed files with 180 additions and 54 deletions

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ output
.python-version .python-version
pyproject.toml pyproject.toml
uv.lock uv.lock
node_modules

View File

@ -77,6 +77,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
@ -96,6 +97,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
} }
} else if let Some(session_id) = response.metadata.get("session_id") { } 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(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
} }
} else if let Some(sessions_json) = response.metadata.get("sessions") { } else if let Some(sessions_json) = response.metadata.get("sessions") {
@ -151,6 +154,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
@ -169,6 +173,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
} }
} else { } else {
@ -177,6 +182,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
} }
} }
} }
@ -188,6 +194,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(),
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -33,6 +33,7 @@ impl SessionMessageSender for BusSessionMessageSender {
.ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?; .ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?;
let metadata = HashMap::new(); let metadata = HashMap::new();
let attachment_count = request.attachments.len();
let mut published_messages = 0; let mut published_messages = 0;
let text_sent = request let text_sent = request
.text .text
@ -43,47 +44,49 @@ impl SessionMessageSender for BusSessionMessageSender {
if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) { if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) {
let content_len = text.len(); 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( let mut outbound = OutboundMessage::assistant(
channel_name.to_string(), channel_name.to_string(),
chat_id.to_string(), chat_id.to_string(),
None, // session_id None, // session_id
String::new(), text,
None, None,
metadata.clone(), metadata.clone(),
); );
outbound.media = vec![attachment]; if attachment_count > 0 {
outbound.media = request.attachments.clone();
}
self.bus.publish_outbound(outbound).await?; self.bus.publish_outbound(outbound).await?;
published_messages += 1; published_messages += 1;
tracing::info!( tracing::info!(
channel = %channel_name, channel = %channel_name,
chat_id = %chat_id, chat_id = %chat_id,
media_type = %media_type, content_len = content_len,
media_path = %media_path, attachment_count = attachment_count,
"Published session attachment to outbound bus" "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 { Ok(SessionSendOutcome {
@ -129,19 +132,15 @@ mod tests {
assert_eq!( assert_eq!(
outcome, outcome,
SessionSendOutcome { SessionSendOutcome {
published_messages: 2, published_messages: 1,
text_sent: true, text_sent: true,
attachment_count: 1, attachment_count: 1,
} }
); );
let first = bus.consume_outbound().await; let msg = bus.consume_outbound().await;
assert_eq!(first.content, "hello"); assert_eq!(msg.content, "hello");
assert!(first.media.is_empty()); assert_eq!(msg.media.len(), 1);
assert_eq!(msg.media[0].media_type, "image");
let second = bus.consume_outbound().await;
assert_eq!(second.content, "");
assert_eq!(second.media.len(), 1);
assert_eq!(second.media[0].media_type, "image");
} }
} }

View File

@ -355,10 +355,28 @@ async fn handle_inbound(
*current_topic_id = Some(topic_id.clone()); *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"); 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 { } else if let Some(ref error) = response.error {
tracing::warn!( tracing::warn!(
error_code = %error.code, 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( async fn send_topic_history(
store: &Arc<crate::storage::SessionStore>, store: &Arc<crate::storage::SessionStore>,
session_id: &str,
topic_id: &str, topic_id: &str,
sender: &mpsc::Sender<WsOutbound>, sender: &mpsc::Sender<WsOutbound>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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"); 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(), id: msg.id.clone(),
content: msg.content.clone(), content: msg.content.clone(),
role: msg.role.clone(), role: msg.role.clone(),
attachments: Vec::new(),
}) })
} }
"tool" => { "tool" => {
@ -465,10 +488,12 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
}), }),
} }
} }
"user" => { "user" => Some(WsOutbound::AssistantResponse {
// 用户消息不通过 WsOutbound 发送,前端自己维护 id: msg.id.clone(),
None content: msg.content.clone(),
} role: msg.role.clone(),
attachments: Vec::new(),
}),
_ => None, _ => None,
} }
} }

View File

@ -34,6 +34,14 @@ pub struct TopicSummary {
pub last_active_at: i64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -63,6 +71,8 @@ pub enum WsOutbound {
id: String, id: String,
content: String, content: String,
role: String, role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<MediaSummary>,
}, },
#[serde(rename = "tool_call")] #[serde(rename = "tool_call")]
ToolCall { ToolCall {

View File

@ -5,7 +5,7 @@ use crate::bus::message::OutboundEventKind;
#[cfg(test)] #[cfg(test)]
use crate::bus::message::{ToolMessageState, format_tool_call_content}; use crate::bus::message::{ToolMessageState, format_tool_call_content};
use super::WsOutbound; use super::{MediaSummary, WsOutbound};
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。"; 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(), id: message.id.clone(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.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(), id: message.id.clone(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.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> { pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
match message.event_kind { match message.event_kind {
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => { 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 { vec![WsOutbound::AssistantResponse {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
attachments,
}] }]
} }
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall { OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Zap, Cpu, MessageSquare } from 'lucide-react' import { Zap, Cpu, MessageSquare } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' 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' const WS_URL = 'ws://127.0.0.1:19876/ws'
function App() { function App() {
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
const { const {
// 连接状态 // 连接状态
connectionId, connectionId,
@ -64,6 +66,24 @@ function App() {
} }
}, [sessionId, status, handleCommand, sendMessage, requestTopicList]) }, [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( const handleSendMessage = useCallback(
(content: string) => { (content: string) => {
if (isReadOnly || !sessionId) { if (isReadOnly || !sessionId) {
@ -136,6 +156,7 @@ function App() {
[sendMessage, handleCommand, switchTopic, selectTopic] [sendMessage, handleCommand, switchTopic, selectTopic]
) )
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
const toolMessages = messages const toolMessages = messages
return ( return (
@ -199,7 +220,7 @@ function App() {
{/* Center - Chat */} {/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f]"> <div className="flex-1 min-w-0 bg-[#0a0a0f]">
<ChatContainer <ChatContainer
messages={messages} messages={chatMessages}
isLoading={isLoading} isLoading={isLoading}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
channelName={session?.title ?? 'PicoBot'} channelName={session?.title ?? 'PicoBot'}

View File

@ -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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage, Attachment } from '../../types/protocol'
interface MessageBubbleProps { interface MessageBubbleProps {
message: ChatMessage 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) { export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === 'user' const isUser = message.role === 'user'
const isTool = message.role === 'tool' const isTool = message.role === 'tool'
@ -160,6 +198,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</ReactMarkdown> </ReactMarkdown>
</div> </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> </div>
</div> </div>

View File

@ -113,7 +113,6 @@ export function useChat(): UseChatReturn {
case 'session_loaded': { case 'session_loaded': {
setIsLoading(false) setIsLoading(false)
setMessages([])
break break
} }
@ -133,23 +132,22 @@ export function useChat(): UseChatReturn {
setTopics(newTopics) setTopics(newTopics)
// 默认选中第一个 Topic如果没有选中 // 默认选中第一个 Topic如果没有选中
if (newTopics.length > 0 && !selectedTopic) {
setSelectedTopic(newTopics[0].id)
}
setIsLoading(false) setIsLoading(false)
break break
} }
case 'assistant_response': { case 'assistant_response': {
const msg = message as AssistantResponse const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
id: msg.id, id: msg.id,
role: 'assistant', role,
content: msg.content, content: msg.content,
timestamp: Date.now(), timestamp: Date.now(),
type: 'message', type: 'message',
attachments: msg.attachments,
}, },
]) ])
setIsLoading(false) setIsLoading(false)
@ -225,7 +223,7 @@ export function useChat(): UseChatReturn {
// 忽略这些消息 // 忽略这些消息
break break
} }
}, [selectedTopic]) }, [])
const handleMessage = useCallback((content: string) => { const handleMessage = useCallback((content: string) => {
setMessages((prev) => [ setMessages((prev) => [

View File

@ -27,11 +27,18 @@ export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
// Outbound Messages (Server -> Client) // Outbound Messages (Server -> Client)
// ============================================================================ // ============================================================================
export interface Attachment {
path: string
media_type: string
mime_type?: string
}
export interface AssistantResponse { export interface AssistantResponse {
type: 'assistant_response' type: 'assistant_response'
id: string id: string
content: string content: string
role: string role: string
attachments?: Attachment[]
} }
export interface ToolCall { export interface ToolCall {
@ -241,6 +248,7 @@ export interface ChatMessage {
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
toolName?: string toolName?: string
arguments?: unknown arguments?: unknown
attachments?: Attachment[]
} }
export interface Topic { export interface Topic {