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>
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
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) {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
const MIME_EXT: Record<string, string> = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/gif": "gif",
|
|
"image/webp": "webp",
|
|
"image/bmp": "bmp",
|
|
"image/svg+xml": "svg",
|
|
"application/pdf": "pdf",
|
|
"model/gltf-binary": "glb",
|
|
};
|
|
|
|
/** File type label (extension) from a name or MIME type. */
|
|
export function fileType(name?: string, mime?: string): string {
|
|
const ext = name?.match(/\.([a-z0-9]{1,6})$/i)?.[1];
|
|
if (ext) return ext.toUpperCase();
|
|
if (mime) return (MIME_EXT[mime] ?? mime.split("/").pop() ?? "").toUpperCase();
|
|
return "";
|
|
}
|
|
|
|
export function basename(p: string) {
|
|
return p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
interface Meta {
|
|
size?: number;
|
|
mime?: string;
|
|
name?: string;
|
|
}
|
|
|
|
const cache = new Map<string, Promise<Meta>>();
|
|
|
|
/** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */
|
|
function loadMeta(url: string): Promise<Meta> {
|
|
let p = cache.get(url);
|
|
if (!p) {
|
|
const project = url.match(/^\/api\/files\/raw\?path=([^&]+)/);
|
|
p = project
|
|
? fetch(`/api/files/stat?path=${project[1]}`, { credentials: "same-origin" })
|
|
.then((r) => (r.ok ? r.json() : {}))
|
|
.then((j: any) => ({ size: j.size, mime: j.mime }))
|
|
: fetch(url, { method: "HEAD", credentials: "same-origin" }).then((r) => {
|
|
if (!r.ok) return {};
|
|
const len = r.headers.get("content-length");
|
|
const name = r.headers.get("content-disposition")?.match(/filename="?([^";]+)"?/)?.[1];
|
|
return { size: len ? Number(len) : undefined, mime: r.headers.get("content-type")?.split(";")[0], name };
|
|
});
|
|
p = p.catch(() => ({}));
|
|
cache.set(url, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
export function useFileMeta(url: string | undefined, known?: Meta): Meta {
|
|
const [meta, setMeta] = useState<Meta>(known ?? {});
|
|
useEffect(() => {
|
|
if (!url || url.startsWith("data:") || known?.size !== undefined) return;
|
|
let alive = true;
|
|
// Bust the cache key per version param so rewritten files show the new size.
|
|
loadMeta(url).then((m) => alive && setMeta((old) => ({ ...m, ...old, size: m.size ?? old.size })));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [url, known?.size]);
|
|
return meta;
|
|
}
|
|
|
|
/**
|
|
* A file shown in the chat: preview with type (top left) and size (top right)
|
|
* badges, file name below.
|
|
*/
|
|
export function FileFigure({
|
|
url,
|
|
name,
|
|
size,
|
|
mime,
|
|
children,
|
|
className,
|
|
}: {
|
|
url?: string;
|
|
name?: string;
|
|
size?: number;
|
|
mime?: string;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const meta = useFileMeta(url, size !== undefined ? { size, mime, name } : undefined);
|
|
const shownName = name ?? meta.name;
|
|
const type = fileType(shownName, mime ?? meta.mime);
|
|
const sz = size ?? meta.size;
|
|
return (
|
|
<figure className={`file-figure ${className ?? ""}`}>
|
|
<div className="file-preview">
|
|
{url ? (
|
|
<a href={url} target="_blank" rel="noreferrer">
|
|
{children}
|
|
</a>
|
|
) : (
|
|
children
|
|
)}
|
|
{type && <span className="file-badge left">{type}</span>}
|
|
{sz !== undefined && <span className="file-badge right">{formatSize(sz)}</span>}
|
|
</div>
|
|
{shownName && (
|
|
<figcaption title={shownName}>
|
|
{url ? (
|
|
<a href={url} target="_blank" rel="noreferrer">
|
|
{basename(shownName)}
|
|
</a>
|
|
) : (
|
|
basename(shownName)
|
|
)}
|
|
</figcaption>
|
|
)}
|
|
</figure>
|
|
);
|
|
}
|
|
|
|
/** A non-previewable file: name with type and size as text. */
|
|
export function FileChip({ url, name, size, sizeText }: { url?: string; name: string; size?: number; sizeText?: string }) {
|
|
const meta = useFileMeta(url, size !== undefined ? { size } : undefined);
|
|
const sz = size ?? meta.size;
|
|
const type = fileType(name, meta.mime);
|
|
return (
|
|
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
|
|
<Icon name="clip" className="file-chip-icon" />
|
|
<span className="file-chip-name">{basename(name)}</span>
|
|
{type && <span className="file-badge">{type}</span>}
|
|
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
|
|
</a>
|
|
);
|
|
}
|