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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 20:21:24 +01:00
1 parent 6d656a355d
commit e8e455b779
27 files changed
+474 -31

No files matched your search

+4 -4
View File
@@ -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;
}
}
}