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.8 KiB113 linesraw
1import { Signal } from "@char/aftercare";
2import type { InboxItem } from "@earendil-works/pi-durable";
3import type { UserMessage } from "@earendil-works/pi-ai";
4import { imageSummary } from "@berry/api/images.ts";
5import { storedDraft } from "./draft-storage.ts";
6import { keyed, type Scope } from "./ui.ts";
7
8export type StoppedInput = Extract<InboxItem, { content: unknown }>;
9
10class StoppedInputs {
11  readonly items = new Signal<readonly StoppedInput[]>([]);
12  readonly ready: Promise<void>;
13  #saving = Promise.resolve();
14
15  constructor(readonly tid: string) {
16    this.ready = typeof indexedDB === "undefined"
17      ? Promise.resolve()
18      : storedDraft<StoppedInput>(`stopped:${tid}`).then((items) => this.items.set(items));
19  }
20
21  save(items: readonly StoppedInput[]): Promise<void> {
22    this.items.set(items);
23    if (typeof indexedDB === "undefined") return Promise.resolve();
24    const saved = this.#saving.then(() => storedDraft(`stopped:${this.tid}`, items)).then(() => {});
25    this.#saving = saved.catch(() => {});
26    return saved;
27  }
28}
29
30const drafts = new Map<string, StoppedInputs>();
31
32/** Aborted inputs are drafts, never members of the conversation's live queue. */
33export function stoppedInputs(
34  scope: Scope,
35  tid: string,
36  restore: (content: UserMessage["content"]) => Promise<boolean>,
37  notify: (message: string, error?: boolean) => void,
38) {
39  if (!drafts.has(tid)) drafts.set(tid, new StoppedInputs(tid));
40  const draft = drafts.get(tid)!;
41  let disposed = false;
42  let pending = false;
43  const element = <div class="inbox stopped-inputs" aria-label="Stopped messages" /> as HTMLElement;
44  const restoreInput = async (item: StoppedInput) => {
45    if (pending || disposed) return;
46    pending = true;
47    try {
48      if (await restore(item.content)) {
49        await draft.save(draft.items.get().filter(({ id }) => id !== item.id));
50      } else {
51        notify("Send or clear your current draft before restoring a stopped message.");
52      }
53    } finally {
54      pending = false;
55    }
56  };
57  const report = (error: unknown) =>
58    notify(error instanceof Error ? error.message : String(error), true);
59  const rows = keyed(element, (item: StoppedInput) => item.id, (item) => ({
60    node: (
61      <div class="queued">
62        <div>
63          <span class="mode">stopped</span>{" "}
64          <span class="queued-content">
65            {typeof item.content === "string"
66              ? item.content
67              : item.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(
68                "\n",
69              )}
70          </span>{" "}
71          <span class="queued-images">{imageSummary(item.content)}</span>
72        </div>
73        <div class="queued-actions">
74          <button type="button" _onclick={() => void restoreInput(item).catch(report)}>
75            Restore
76          </button>
77          <button
78            type="button"
79            _onclick={() => {
80              if (!pending) {
81                void draft.save(draft.items.get().filter(({ id }) => id !== item.id)).catch(report);
82              }
83            }}
84          >
85            Dismiss
86          </button>
87        </div>
88      </div>
89    ),
90  }));
91  scope.add(
92    draft.items.subscribeImmediate((items) => {
93      element.hidden = items.length === 0;
94      rows.sync(items);
95    }).unsubscribe,
96  );
97  scope.add(rows.dispose);
98  scope.add(() => disposed = true);
99  void draft.ready.catch(report);
100  return {
101    element,
102    async recover(inputs: readonly StoppedInput[]) {
103      if (inputs.length === 0) return;
104      await draft.ready.catch(() => {});
105      const ordered = [...inputs].sort((a, b) =>
106        Number(a.mode === "followUp") - Number(b.mode === "followUp")
107      );
108      const ids = new Set(draft.items.get().map(({ id }) => id));
109      await draft.save([...draft.items.get(), ...ordered.filter(({ id }) => !ids.has(id))]);
110      await restoreInput(ordered[0]);
111    },
112  };
113}