Files
codecraft/web/src/components/FileFigure.tsx
T
Richard ZinkandClaude Opus 5.5 e8e455b779 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>
2026-11-09 20:21:24 +01:00

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>
);
}