diff --git a/src/bus/message.rs b/src/bus/message.rs index 9f89faa..43f6e22 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -24,6 +24,8 @@ pub struct MediaItem { pub media_type: String, // "image", "audio", "file", "video" pub mime_type: Option, pub original_key: Option, // Feishu file_key for download + pub content_base64: Option, // Base64-encoded file content for web download + pub file_name: Option, // Display file name } impl MediaItem { @@ -33,6 +35,8 @@ impl MediaItem { media_type: media_type.into(), mime_type: None, original_key: None, + content_base64: None, + file_name: None, } } } diff --git a/src/command/handlers/session.rs b/src/command/handlers/session.rs index 4f0ef24..9872b12 100644 --- a/src/command/handlers/session.rs +++ b/src/command/handlers/session.rs @@ -1,5 +1,6 @@ use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::handlers::list_topics::TopicSummary; use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::Command; use crate::gateway::session::SessionManager; @@ -94,8 +95,30 @@ async fn handle_create_session( } } + // Query the full topic list so the frontend sidebar can update + let topics = handler + .store + .list_topics(session_id) + .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; + + let topic_summaries: Vec = topics + .into_iter() + .map(|t| TopicSummary { + topic_id: t.id, + session_id: t.session_id, + title: t.title, + message_count: t.message_count, + created_at: t.created_at, + last_active_at: t.last_active_at, + }) + .collect(); + + let topics_json = serde_json::to_string(&topic_summaries) + .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + Ok(CommandResponse::success(ctx.request_id) .with_message(MessageKind::Notification, &topic.title) + .with_metadata("topics", &topics_json) .with_metadata("topic_id", &topic.id) .with_metadata("session_id", &topic.session_id) .with_metadata("message_count", &topic.message_count.to_string())) diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 8a06364..937ff60 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -418,15 +418,12 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St /// 加载并发送话题历史消息 async fn send_topic_history( store: &Arc, - session_id: &str, + _session_id: &str, topic_id: &str, sender: &mpsc::Sender, ) -> Result<(), Box> { // 加载话题消息 - let mut messages = store.load_messages_for_topic(topic_id)?; - if messages.is_empty() { - messages = store.load_messages(session_id)?; - } + let messages = store.load_messages_for_topic(topic_id)?; tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history"); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index d23b7eb..975ee61 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -40,6 +40,10 @@ pub struct MediaSummary { pub media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_base64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/protocol/ws_adapter.rs b/src/protocol/ws_adapter.rs index 8513cf8..f2dcd5c 100644 --- a/src/protocol/ws_adapter.rs +++ b/src/protocol/ws_adapter.rs @@ -77,6 +77,8 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve path: m.path.clone(), media_type: m.media_type.clone(), mime_type: m.mime_type.clone(), + content_base64: m.content_base64.clone(), + file_name: m.file_name.clone(), }) .collect(); vec![WsOutbound::AssistantResponse { diff --git a/src/tools/session_send.rs b/src/tools/session_send.rs index a270185..1a22441 100644 --- a/src/tools/session_send.rs +++ b/src/tools/session_send.rs @@ -1,8 +1,10 @@ +use std::io::Read; use std::path::Path; use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; +use base64::Engine; use serde_json::json; use crate::bus::MediaItem; @@ -205,11 +207,27 @@ fn parse_attachments(value: &serde_json::Value) -> anyhow::Result return Err(anyhow!("attachment file is empty: {}", raw_path)); } + let content_base64 = (metadata.len() <= 50 * 1024 * 1024) + .then(|| { + let mut file = std::fs::File::open(&raw_path)?; + let mut buf = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut buf)?; + Ok::<_, anyhow::Error>(base64::engine::general_purpose::STANDARD.encode(&buf)) + }) + .transpose()?; + + let file_name = Path::new(&raw_path) + .file_name() + .and_then(|n| n.to_str()) + .map(ToOwned::to_owned); + let media_type = infer_media_type(&raw_path); let mut item = MediaItem::new(raw_path.to_string(), media_type); item.mime_type = mime_guess::from_path(&raw_path) .first_raw() .map(ToOwned::to_owned); + item.content_base64 = content_base64; + item.file_name = file_name; attachments.push(item); } diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index d93352b..f5dd187 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -23,25 +23,52 @@ function getFileName(path: string): string { } function AttachmentCard({ attachment }: { attachment: Attachment }) { - const fileName = getFileName(attachment.path) - const downloadUrl = `/download?path=${encodeURIComponent(attachment.path)}` + const fileName = attachment.file_name || getFileName(attachment.path) + + const handleDownload = (e: React.MouseEvent) => { + if (!attachment.content_base64) return + + e.preventDefault() + const mimeType = attachment.mime_type || 'application/octet-stream' + const byteChars = atob(attachment.content_base64) + const byteNums = new Array(byteChars.length) + for (let i = 0; i < byteChars.length; i++) { + byteNums[i] = byteChars.charCodeAt(i) + } + const byteArr = new Uint8Array(byteNums) + const blob = new Blob([byteArr], { type: mimeType }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + const canDownload = !!attachment.content_base64 return ( - - + {getAttachmentIcon(attachment.media_type)} - + {fileName} {attachment.media_type} - - + {canDownload && ( + + )} + ) } diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index ed038fb..d68df1f 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -31,6 +31,8 @@ export interface Attachment { path: string media_type: string mime_type?: string + content_base64?: string + file_name?: string } export interface AssistantResponse {