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

+11
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react";
import type { ImageData } from "../types";
/** Human-readable file size. */
export function formatSize(n: number) {
@@ -32,6 +33,16 @@ export function basename(p: string) {
}
/** Bytes of a base64 payload. */
/** URL of an image of a message: inline (data URL) or from the image store. */
export function imageSrc(img: ImageData) {
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
}
/** Size in bytes of an image of a message. */
export function imageSize(img: ImageData) {
return img.data ? base64Size(img.data) : img.size;
}
export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad;