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:
parent
7898ca69e4
commit
44e82e8473
@ -24,6 +24,8 @@ pub struct MediaItem {
|
||||
pub media_type: String, // "image", "audio", "file", "video"
|
||||
pub mime_type: Option<String>,
|
||||
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 {
|
||||
@ -33,6 +35,8 @@ impl MediaItem {
|
||||
media_type: media_type.into(),
|
||||
mime_type: None,
|
||||
original_key: None,
|
||||
content_base64: None,
|
||||
file_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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)
|
||||
.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()))
|
||||
|
||||
@ -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<crate::storage::SessionStore>,
|
||||
session_id: &str,
|
||||
_session_id: &str,
|
||||
topic_id: &str,
|
||||
sender: &mpsc::Sender<WsOutbound>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 加载话题消息
|
||||
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");
|
||||
|
||||
|
||||
@ -40,6 +40,10 @@ pub struct MediaSummary {
|
||||
pub media_type: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Vec<MediaItem>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<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}`}
|
||||
<div
|
||||
onClick={canDownload ? handleDownload : undefined}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-xs transition-colors group ${
|
||||
canDownload ? 'hover:bg-white/10 hover:border-[#00f0ff]/30 cursor-pointer' : ''
|
||||
}`}
|
||||
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)}
|
||||
</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}
|
||||
</span>
|
||||
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</span>
|
||||
{canDownload && (
|
||||
<Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,8 @@ export interface Attachment {
|
||||
path: string
|
||||
media_type: string
|
||||
mime_type?: string
|
||||
content_base64?: string
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
export interface AssistantResponse {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user