char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somrestore queued messages to the composer on abort3d23fd2

main
3.9 KiB116 linesraw
1import { Signal } from "@char/aftercare";
2import { MAX_IMAGES, validateImage } from "@berry/api/images.ts";
3import { storedDraft } from "./draft-storage.ts";
4
5export type DraftImage = {
6  id: string;
7  name: string;
8  blob: Blob;
9  status: "loading" | "ready" | "error";
10  error?: string;
11};
12type StoredImage = Pick<DraftImage, "id" | "name" | "blob">;
13
14const message = (error: unknown) => error instanceof Error ? error.message : String(error);
15
16/** A route's shared image draft, with serialized durable changes across mounted composers. */
17class ImageDraft {
18  readonly items = new Signal<readonly DraftImage[]>([]);
19  readonly error = new Signal<string | null>(null);
20  readonly loading = new Signal(typeof indexedDB !== "undefined");
21  readonly ready: Promise<void>;
22  submitting = false;
23  #queue = Promise.resolve();
24
25  constructor(readonly route: string) {
26    this.ready = this.loading.get()
27      ? storedDraft<StoredImage>(route).then((images) => {
28        this.items.set(images.map((image) => ({ ...image, status: "ready" })));
29      }).catch((error) => {
30        this.error.set(`Could not restore image drafts: ${message(error)}`);
31      }).finally(() => this.loading.set(false))
32      : Promise.resolve();
33  }
34
35  #enqueue(task: () => Promise<void>): Promise<void> {
36    const pending = this.#queue.then(task);
37    this.#queue = pending.catch(() => {});
38    return pending;
39  }
40
41  async #save(items = this.items.get()): Promise<void> {
42    await storedDraft(
43      this.route,
44      items.filter((image) => image.status === "ready").map(
45        ({ id, name, blob }) => ({ id, name, blob }),
46      ),
47    );
48    this.error.set(null);
49  }
50
51  async add(files: readonly File[]): Promise<void> {
52    await this.ready;
53    if (this.error.get()) throw new Error(this.error.get()!);
54    if (this.items.get().length + files.length > MAX_IMAGES) {
55      throw new Error(`A message can have at most ${MAX_IMAGES} images`);
56    }
57    const added: DraftImage[] = files.map((blob) => ({
58      id: Array.from(
59        crypto.getRandomValues(new Uint8Array(16)),
60        (byte) => byte.toString(16).padStart(2, "0"),
61      ).join(""),
62      name: blob.name || "Pasted image",
63      blob,
64      status: "loading",
65    }));
66    this.items.set([...this.items.get(), ...added]);
67    await Promise.all(added.map((image) =>
68      this.#enqueue(async () => {
69        try {
70          const bytes = new Uint8Array(await image.blob.slice(0, 12).arrayBuffer());
71          validateImage(bytes, image.blob.type, image.blob.size);
72          const bitmap = await createImageBitmap(image.blob);
73          try {
74            if (bitmap.width * bitmap.height > 40_000_000) {
75              throw new Error("Images must be no larger than 40 megapixels");
76            }
77          } finally {
78            bitmap.close();
79          }
80          if (!this.items.get().some((item) => item.id === image.id)) return;
81          const next = this.items.get().map((item) =>
82            item.id === image.id ? { ...item, status: "ready" as const } : item
83          );
84          await this.#save(next);
85          this.items.set(
86            this.items.get().map((item) =>
87              item.id === image.id ? { ...item, status: "ready" } : item
88            ),
89          );
90        } catch (error) {
91          this.items.set(
92            this.items.get().map((item) =>
93              item.id === image.id ? { ...item, status: "error", error: message(error) } : item
94            ),
95          );
96          throw error;
97        }
98      })
99    ));
100  }
101
102  remove(ids: readonly string[]): Promise<void> {
103    this.items.set(this.items.get().filter((image) => !ids.includes(image.id)));
104    return this.#enqueue(() => this.#save()).catch((error) => {
105      this.error.set(`Could not save image drafts: ${message(error)}`);
106      throw error;
107    });
108  }
109}
110
111const drafts = new Map<string, ImageDraft>();
112
113export function imageDraft(route: string): ImageDraft {
114  if (!drafts.has(route)) drafts.set(route, new ImageDraft(route));
115  return drafts.get(route)!;
116}