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
pyproject.toml
uv.lock
node_modules

View File

@ -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);

View File

@ -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,26 +44,27 @@ 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(
let mut outbound = OutboundMessage::assistant(
channel_name.to_string(),
chat_id.to_string(),
None, // session_id
text,
None,
metadata.clone(),
))
.await?;
);
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,
content_len = content_len,
attachment_count = attachment_count,
"Published session text message to outbound bus"
);
}
let attachment_count = request.attachments.len();
} else {
for attachment in request.attachments {
let media_path = attachment.path.clone();
let media_type = attachment.media_type.clone();
@ -85,6 +87,7 @@ impl SessionMessageSender for BusSessionMessageSender {
"Published session attachment to outbound bus"
);
}
}
Ok(SessionSendOutcome {
published_messages,
@ -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");
}
}

View File

@ -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,
}
}

View File

@ -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 {

View File

@ -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 {

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 { 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'}

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 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>

View File

@ -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) => [

View File

@ -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 {