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

+39 -1
View File
@@ -80,6 +80,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session))
.route("/blobs/{hash}", get(get_blob))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
@@ -181,9 +182,21 @@ pub fn router(app: AppState) -> Router {
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
.layer(compression())
.with_state(app)
}
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
/// predicate), and so are binary downloads (already compressed or large).
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
let predicate = DefaultPredicate::new()
.and(NotForContentType::const_new("application/octet-stream"))
.and(NotForContentType::const_new("application/zip"))
.and(NotForContentType::const_new("application/gzip"));
tower_http::compression::CompressionLayer::new().compress_when(predicate)
}
fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
}
@@ -744,7 +757,8 @@ struct ExportQ {
/// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
// JSON exports are self-contained: images inline instead of store references.
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" {
@@ -879,9 +893,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
crate::shells::kill_session(&app, &id);
app.terminals.kill_session(&id);
app.store.delete_session(&id)?;
// Images only this session used.
let a = app.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = a.store.gc_blobs() {
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true})))
}
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
/// so it never changes and the browser may keep it.
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
if !crate::blobs::is_hash(&hash) {
return Err(not_found("image"));
}
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
Ok((
[
(axum::http::header::CONTENT_TYPE, mime),
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
],
bytes,
)
.into_response())
}
#[derive(Deserialize)]
struct PromptBody {
text: String,