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

+16 -2
View File
@@ -10,11 +10,25 @@ pub enum Role {
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub media_type: String,
/// Base64-encoded bytes.
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub data: String,
/// Server only: SHA-256 of the bytes in the image store; the UI loads
/// /api/blobs/<blob> and the server fills `data` in before a model request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Size in bytes of a stored image (shown in the UI without fetching it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
impl ImageData {
pub fn new(media_type: impl Into<String>, data: String) -> Self {
Self { media_type: media_type.into(), data, blob: None, size: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]