feat: 附件通过 base64 内容实现前端直接下载

- MediaItem/MediaSummary 新增 content_base64 和 file_name 字段
- 解析附件时读取文件内容并 base64 编码(限 50MB),前端 Blob 下载
- 创建 Session 后返回完整 topics 列表,前端侧边栏实时同步
- 简化话题历史加载逻辑,不再回退到 session 消息

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-28 14:01:37 +08:00
parent 7898ca69e4
commit 44e82e8473
8 changed files with 93 additions and 16 deletions

View File

@ -24,6 +24,8 @@ pub struct MediaItem {
pub media_type: String, // "image", "audio", "file", "video" pub media_type: String, // "image", "audio", "file", "video"
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub original_key: Option<String>, // Feishu file_key for download pub original_key: Option<String>, // Feishu file_key for download
pub content_base64: Option<String>, // Base64-encoded file content for web download
pub file_name: Option<String>, // Display file name
} }
impl MediaItem { impl MediaItem {
@ -33,6 +35,8 @@ impl MediaItem {
media_type: media_type.into(), media_type: media_type.into(),
mime_type: None, mime_type: None,
original_key: None, original_key: None,
content_base64: None,
file_name: None,
} }
} }
} }

View File

@ -1,5 +1,6 @@
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command; use crate::command::Command;
use crate::gateway::session::SessionManager; 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<TopicSummary> = 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) Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &topic.title) .with_message(MessageKind::Notification, &topic.title)
.with_metadata("topics", &topics_json)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("session_id", &topic.session_id) .with_metadata("session_id", &topic.session_id)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", &topic.message_count.to_string()))

View File

@ -418,15 +418,12 @@ 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, _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 mut messages = store.load_messages_for_topic(topic_id)?; let 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");

View File

@ -40,6 +40,10 @@ pub struct MediaSummary {
pub media_type: String, pub media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>, pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -77,6 +77,8 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
path: m.path.clone(), path: m.path.clone(),
media_type: m.media_type.clone(), media_type: m.media_type.clone(),
mime_type: m.mime_type.clone(), mime_type: m.mime_type.clone(),
content_base64: m.content_base64.clone(),
file_name: m.file_name.clone(),
}) })
.collect(); .collect();
vec![WsOutbound::AssistantResponse { vec![WsOutbound::AssistantResponse {

View File

@ -1,8 +1,10 @@
use std::io::Read;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine;
use serde_json::json; use serde_json::json;
use crate::bus::MediaItem; use crate::bus::MediaItem;
@ -205,11 +207,27 @@ fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>
return Err(anyhow!("attachment file is empty: {}", raw_path)); 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 media_type = infer_media_type(&raw_path);
let mut item = MediaItem::new(raw_path.to_string(), media_type); let mut item = MediaItem::new(raw_path.to_string(), media_type);
item.mime_type = mime_guess::from_path(&raw_path) item.mime_type = mime_guess::from_path(&raw_path)
.first_raw() .first_raw()
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
item.content_base64 = content_base64;
item.file_name = file_name;
attachments.push(item); attachments.push(item);
} }

View File

@ -23,25 +23,52 @@ function getFileName(path: string): string {
} }
function AttachmentCard({ attachment }: { attachment: Attachment }) { function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = getFileName(attachment.path) const fileName = attachment.file_name || getFileName(attachment.path)
const downloadUrl = `/download?path=${encodeURIComponent(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 ( return (
<a <div
href={downloadUrl} onClick={canDownload ? handleDownload : undefined}
download={fileName} className={`flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-xs transition-colors group ${
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" canDownload ? 'hover:bg-white/10 hover:border-[#00f0ff]/30 cursor-pointer' : ''
title={`下载 ${fileName}`} }`}
title={canDownload ? `下载 ${fileName}` : attachment.path}
> >
<span className="text-zinc-400 group-hover:text-[#00f0ff] transition-colors"> <span className={`text-zinc-400 transition-colors ${canDownload ? 'group-hover:text-[#00f0ff]' : ''}`}>
{getAttachmentIcon(attachment.media_type)} {getAttachmentIcon(attachment.media_type)}
</span> </span>
<span className="text-zinc-300 truncate max-w-[200px] group-hover:text-white transition-colors" title={attachment.path}> <span className={`text-zinc-300 truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-white' : ''}`} title={attachment.path}>
{fileName} {fileName}
</span> </span>
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</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" /> {canDownload && (
</a> <Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
)}
</div>
) )
} }

View File

@ -31,6 +31,8 @@ export interface Attachment {
path: string path: string
media_type: string media_type: string
mime_type?: string mime_type?: string
content_base64?: string
file_name?: string
} }
export interface AssistantResponse { export interface AssistantResponse {