Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64 inside the message JSON, so opening a chat downloaded all of them at once (66 MB for a chat with 120 screenshots). They now live in a `blobs` table, addressed by SHA-256; messages keep a reference (blob, size). The UI loads them from /api/blobs/<hash> lazily with an immutable cache header, the runner fills the bytes in before model requests, JSON exports stay self-contained. - Store::add_message moves inline images out; large opaque PNGs become JPEG (q85) when smaller. Older chats are converted once at start-up in the background, unused images are removed (also after deleting a chat) and the database is vacuumed when that frees 32 MB or more. - Responses are compressed with gzip/brotli (not images, SSE streams or binary downloads). Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli), database 90 MB -> 27 MB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d656a355d
commit
e8e455b779
27 files changed
+474
-31
No files matched your search
@@ -354,13 +354,13 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
|
||||
let mut notes = Vec::new();
|
||||
if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) {
|
||||
let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?;
|
||||
images.push(ImageData { media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
images.push(ImageData { blob: None, size: None, media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
}
|
||||
if let Some(doc) = m.get("document") {
|
||||
let mime = doc["mime_type"].as_str().unwrap_or("");
|
||||
let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?;
|
||||
if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 {
|
||||
images.push(ImageData { media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
images.push(ImageData { blob: None, size: None, media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
} else {
|
||||
let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string());
|
||||
let rel = save_upload(&session, &name, &bytes)?;
|
||||
@@ -876,7 +876,7 @@ async fn listen(app: Arc<App>) {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> {
|
||||
async fn on_event(app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> {
|
||||
let sid = session.id.clone();
|
||||
match ev {
|
||||
// Approvals and questions of every own session: the agent waits for them.
|
||||
@@ -947,7 +947,7 @@ async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session,
|
||||
for part in &message.parts {
|
||||
if let Part::ToolResult { images, name, .. } = part {
|
||||
for img in images {
|
||||
let _ = bot.send_photo(chat, img, Some(name)).await;
|
||||
let _ = bot.send_photo(chat, &app.store.resolve_image(img), Some(name)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user