diff --git a/.gitignore b/.gitignore index 050e891..b67e665 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ output .python-version pyproject.toml uv.lock +node_modules diff --git a/src/command/adapters/websocket.rs b/src/command/adapters/websocket.rs index d6dd796..2b20022 100644 --- a/src/command/adapters/websocket.rs +++ b/src/command/adapters/websocket.rs @@ -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); diff --git a/src/gateway/session_message_sender.rs b/src/gateway/session_message_sender.rs index 805b905..50cb569 100644 --- a/src/gateway/session_message_sender.rs +++ b/src/gateway/session_message_sender.rs @@ -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"); } } \ No newline at end of file diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index f235751..8a06364 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -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::>(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, + session_id: &str, topic_id: &str, sender: &mpsc::Sender, ) -> Result<(), Box> { // 加载话题消息 - 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 { @@ -465,10 +488,12 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option { - // 用户消息不通过 WsOutbound 发送,前端自己维护 - None - } + "user" => Some(WsOutbound::AssistantResponse { + id: msg.id.clone(), + content: msg.content.clone(), + role: msg.role.clone(), + attachments: Vec::new(), + }), _ => None, } } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index cada396..d23b7eb 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -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, +} + #[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, }, #[serde(rename = "tool_call")] ToolCall { diff --git a/src/protocol/ws_adapter.rs b/src/protocol/ws_adapter.rs index 766829f..8513cf8 100644 --- a/src/protocol/ws_adapter.rs +++ b/src/protocol/ws_adapter.rs @@ -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 Vec Vec Vec { match message.event_kind { OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => { + let attachments: Vec = 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 { diff --git a/web/src/App.tsx b/web/src/App.tsx index c78488d..035963c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(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 */}
+ case 'audio': return + case 'video': return
)} + {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((att: Attachment, idx: number) => ( + + ))} +
+ )} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index c9c8caa..c04fdb4 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -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) => [ diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 63950c3..ed038fb 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -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 {