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
@@ -1,6 +1,6 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
@@ -471,8 +471,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TemplatePicker } from "./TemplatePicker";
|
||||
import { Icon } from "./Icon";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { uploadFile } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch, useModels } from "../hooks";
|
||||
@@ -257,7 +257,7 @@ export function Composer({
|
||||
)}
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={i}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
import { readImage } from "./Composer";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { takePrefill } from "../share";
|
||||
|
||||
@@ -478,7 +478,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={`i${i}`}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
import { imageSrc } from "./FileFigure";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
const EMPTY: QueueItem[] = [];
|
||||
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{images.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<img key={i} src={imageSrc(p.image)} alt="" />
|
||||
))}
|
||||
{it.source !== "background" && (
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileFigure } from "./FileFigure";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => (
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
|
||||
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user