char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd defaultEnabled to extensions and enable code+web32b51b2

main
13.2 KiB376 linesraw
1import { Signal } from "@char/aftercare";
2import type { JsonValue } from "@berry/api";
3import { userContent } from "@berry/api/images.ts";
4import { api } from "./client.ts";
5import { composer, go } from "./composer.tsx";
6import {
7  configurationControls,
8  configurationField,
9  type ConfigurationLocation,
10  liveChoices,
11} from "./configuration.tsx";
12import {
13  type CommandInfo,
14  type ExtensionInfo,
15  notifier,
16  type Shared,
17  thinkingSelect,
18} from "./conversation.tsx";
19import { projectPicker } from "./projects.tsx";
20import { modelKey, modelSelect } from "./settings.tsx";
21import { computed, type Scope, swap } from "./ui.ts";
22
23type Values = Record<string, JsonValue>;
24
25const EMPTY: readonly never[] = [];
26const EXTENSIONS = "berry.new.extensions";
27const MODEL = "berry.new.model";
28const THINKING = "berry.new.thinking";
29const SEEDS = "berry.new.seeds";
30
31/**
32 * The page a conversation starts from: where it works, how it thinks, and with which extensions, above a composer
33 * whose first message starts it. It starts in `project`, or in none for `""`, or where the last one did for `null`.
34 */
35export function newConversation(scope: Scope, shared: Shared, project: string | null): Element[] {
36  const { extensions, models, enabled } = shared;
37  const { element: notice, notify } = notifier();
38  const optional = extensions.derive((list) =>
39    list.filter((extension) => extension.running && !extension.alwaysOn)
40  );
41  const remembered = JSON.parse(localStorage.getItem(EXTENSIONS) ?? "null") as string[] | null;
42  const chosen = new Signal<ReadonlySet<string> | null>(remembered && new Set(remembered));
43  // what the extensions ask for, rather than every one installed: delegation, the browser, and mcp are opt-in
44  const defaults = extensions.derive((list) =>
45    new Set(list.filter((extension) => extension.defaultEnabled).map((extension) => extension.name))
46  );
47  const isChosen = (name: string) => chosen.get()?.has(name) ?? defaults.get().has(name);
48  // each extension's configuration, as last given; what is not answered takes its default on the server
49  const answers = JSON.parse(localStorage.getItem(SEEDS) ?? "{}") as Record<string, Values>;
50  const model = new Signal(localStorage.getItem(MODEL) ?? "");
51  const thinking = new Signal(localStorage.getItem(THINKING) ?? "high");
52  // the last model used, while it stays enabled; otherwise the first enabled one
53  const settle = () => {
54    const keys = enabled.get().map(modelKey);
55    if (!keys.includes(model.get())) model.set(keys[0] ?? "");
56  };
57  scope.add(enabled.subscribe(settle).unsubscribe);
58  settle();
59  const location = computed<ConfigurationLocation>(scope, [model], () => {
60    const key = model.get();
61    const slash = key.indexOf("/");
62    return {
63      tid: null,
64      model: slash < 0 ? null : { provider: key.slice(0, slash), modelId: key.slice(slash + 1) },
65    };
66  });
67
68  // where it works: a project's workspace, or a machine and folder chosen by hand, which show their project
69  const environment = new Signal(String(answers.environment?.environment ?? "local"));
70  const environmentValues = new Signal<Values | null>(null);
71  const picker = projectPicker(scope, shared.projects, (root) => {
72    answers.environment = { environment: root?.environment ?? "local", cwd: root?.cwd ?? "" };
73    environment.set(String(answers.environment.environment));
74    environmentValues.set({ ...environmentValues.get(), ...answers.environment });
75  });
76  const showProject = () =>
77    picker.show({ environment: environment.get(), cwd: String(answers.environment?.cwd ?? "") });
78
79  /** An extension's questions, with its answers so far and its defaults; `fields` draws them when it has no view. */
80  const controls = (
81    own: Scope,
82    extension: ExtensionInfo,
83    values: Signal<Values | null>,
84    fields: (save: (patch: Values) => Promise<Values>) => HTMLElement,
85    answered: (patch: Values) => void = () => {},
86  ) => {
87    const defaults = Object.fromEntries(
88      Object.entries(extension.configuration!.properties)
89        .filter(([, field]) => field.default !== undefined)
90        .map(([key, field]) => [key, field.default!]),
91    );
92    values.set({ ...defaults, ...answers[extension.name] });
93    return configurationControls(
94      own,
95      extension.name,
96      extension.configuration!,
97      location,
98      values,
99      (patch) => {
100        answers[extension.name] = { ...answers[extension.name], ...patch };
101        answered(patch);
102        return { ...defaults, ...answers[extension.name] };
103      },
104      fields,
105    );
106  };
107  /** The generated form of an extension's questions, each answered as it changes. */
108  const generated = (
109    own: Scope,
110    extension: ExtensionInfo,
111    values: Signal<Values | null>,
112    save: (patch: Values) => Promise<Values>,
113    folders?: (key: string) => Parameters<typeof configurationField>[5],
114  ) => {
115    const live = liveChoices(own, extension.name, shared);
116    const fields = Object.entries(extension.configuration!.properties).map(([key, schema]) => ({
117      key,
118      field: configurationField(
119        key,
120        schema,
121        values.get()?.[key],
122        (value) => void save({ [key]: value }).catch(() => {}),
123        live[key] && { scope: own, choices: live[key] },
124        folders?.(key),
125      ),
126    }));
127    own.add(
128      values.subscribe((next) => {
129        for (const { key, field } of fields) {
130          if (!field.input.contains(document.activeElement)) field.update(next?.[key]);
131        }
132      }).unsubscribe,
133    );
134    return fields;
135  };
136
137  const where = <div class="start-environment" />;
138  swap(
139    scope,
140    where,
141    extensions.derive((list) =>
142      list.find((extension) =>
143        extension.name === "environment" && extension.running && extension.configuration
144      ) ?? null
145    ),
146    (extension, own) => {
147      if (!extension) return [];
148      const folders = { scope: own, environment, projects: shared.projects, base: () => null };
149      const fields = (save: (patch: Values) => Promise<Values>) =>
150        (
151          <fieldset class="seed">
152            {generated(
153              own,
154              extension,
155              environmentValues,
156              save,
157              (key) => key === "cwd" ? folders : undefined,
158            ).map(({ field }) => field.element)}
159          </fieldset>
160        ) as HTMLElement;
161      return [controls(own, extension, environmentValues, fields, (patch) => {
162        // another machine has other folders
163        if (Object.hasOwn(patch, "environment")) {
164          environment.set(String(patch.environment));
165          if (!Object.hasOwn(patch, "cwd")) answers.environment.cwd = "";
166        }
167        showProject();
168      })];
169    },
170  );
171
172  const toggles = <div class="toggles" role="group" aria-label="extensions" />;
173  swap(
174    scope,
175    toggles,
176    optional,
177    (list) =>
178      list.map((extension) => (
179        <label title={extension.description ?? ""}>
180          <input
181            type="checkbox"
182            checked={isChosen(extension.name)}
183            _onchange={function () {
184              const next = new Set(list.map((e) => e.name).filter(isChosen));
185              if (this.checked) next.add(extension.name);
186              else next.delete(extension.name);
187              chosen.set(next);
188            }}
189          />
190          {extension.name}
191        </label>
192      )),
193  );
194  // what the chosen extensions, and those always on, ask of a new conversation, in installed order
195  const options = <div class="start-options" />;
196  swap(
197    scope,
198    options,
199    computed(
200      scope,
201      [extensions, chosen],
202      () =>
203        extensions.get().filter((extension) =>
204          extension.running && extension.configuration && extension.name !== "environment" &&
205          (extension.alwaysOn || isChosen(extension.name))
206        ),
207    ),
208    (list, own) =>
209      list.map((extension) => {
210        const values = new Signal<Values | null>(null);
211        return controls(own, extension, values, (save) =>
212          (
213            <fieldset class="seed">
214              <legend>{extension.name}</legend>
215              {generated(own, extension, values, save).map(({ field }) => field.element)}
216            </fieldset>
217          ) as HTMLElement);
218      }),
219  );
220
221  const unconfigured = (
222    <p class="start-hint" hidden={enabled.derive((list) => list.length > 0)}>
223      no models are enabled yet: <a href="#/settings">enable one in settings</a>
224    </p>
225  );
226
227  const create = async () => {
228    const slash = model.get().indexOf("/");
229    const { tid } = await api<{ tid: string }>("POST", "/api/conversations", {
230      extensions: Object.fromEntries(
231        extensions.get().filter((extension) =>
232          extension.running &&
233          (extension.alwaysOn ? extension.configuration : isChosen(extension.name))
234        ).map(({ name }) => [name, answers[name] ?? {}]),
235      ),
236      model: { provider: model.get().slice(0, slash), modelId: model.get().slice(slash + 1) },
237      thinkingLevel: thinking.get(),
238    });
239    // installed order, so a selection reads the same every time
240    const names = optional.get().map((e) => e.name).filter(isChosen);
241    localStorage.setItem(EXTENSIONS, JSON.stringify(names));
242    localStorage.setItem(MODEL, model.get());
243    localStorage.setItem(THINKING, thinking.get());
244    localStorage.setItem(SEEDS, JSON.stringify(answers));
245    return tid;
246  };
247  // as in a conversation, references and the shell come with code
248  const coding = computed(
249    scope,
250    [extensions, chosen],
251    () =>
252      extensions.get().some((extension) =>
253        extension.name === "code" && extension.running &&
254        (extension.alwaysOn || isChosen(extension.name))
255      ),
256  );
257  const { element: input, setText } = composer(
258    scope,
259    "new",
260    new Signal(false),
261    new Signal<readonly CommandInfo[]>(EMPTY),
262    async (text, _followUp, images) => {
263      const command = text.match(/^\/(\S+)/)?.[1];
264      if (command) {
265        throw new Error(`/${command} needs a conversation: send a message to start this one`);
266      }
267      const shell = coding.get() && text.startsWith("!");
268      if (shell && images.length) throw new Error("Shell commands cannot include images");
269      if (model.get() === "") throw new Error("enable a model in settings first");
270      const tid = await create();
271      try {
272        if (shell) {
273          const excludeFromContext = text.startsWith("!!");
274          await api("POST", `/api/conversations/${tid}/shell`, {
275            command: text.slice(excludeFromContext ? 2 : 1).trim(),
276            excludeFromContext,
277          });
278        } else {
279          await api("POST", `/api/conversations/${tid}/messages`, {
280            content: userContent(text, images),
281          });
282        }
283        go({ tid }, setText);
284      } catch {
285        // the conversation exists now, so the message waits in its composer rather than starting another
286        go({ tid, text }, setText);
287      }
288    },
289    notify,
290    undefined,
291    (prefix) => {
292      if (!coding.get()) return Promise.resolve([]);
293      const query = new URLSearchParams({ environment: environment.get(), prefix });
294      const cwd = String(answers.environment?.cwd ?? "");
295      if (cwd) query.set("cwd", cwd);
296      return api<string[]>("GET", `/api/references?${query}`);
297    },
298    coding,
299  );
300
301  // a conversation with nothing in it yet, for its views, or for a draft finished there
302  const blank = (
303    <button
304      type="button"
305      class="primary start-blank"
306      _onclick={async function () {
307        this.disabled = true;
308        try {
309          if (model.get() === "") throw new Error("enable a model in settings first");
310          const tid = await create();
311          const text = input.querySelector("textarea")!.value;
312          setText("");
313          go({ tid, text }, setText);
314        } catch (error) {
315          notify((error as Error).message, true);
316        } finally {
317          this.disabled = false;
318        }
319      }}
320    >
321      start blank
322    </button>
323  );
324
325  // a project's own page waits for its project to arrive; a place shows the project it is in
326  let pending = project;
327  const place = () => {
328    if (pending === "" || (pending && shared.projects.get()[pending])) {
329      picker.pick(pending);
330      pending = null;
331    }
332    showProject();
333  };
334  scope.add(shared.projects.subscribe(place).unsubscribe);
335  place();
336  if (!matchMedia("(pointer: coarse)").matches) {
337    requestAnimationFrame(() => input.querySelector("textarea")?.focus());
338  }
339
340  return [
341    <header class="bar">
342      <span class="bar-title">new conversation</span>
343      {blank}
344    </header>,
345    <div class="pane start-pane">
346      <div class="scroller">
347        <div class="start">
348          <section class="start-row start-where">
349            <h3>environment</h3>
350            <div class="start-fields">
351              {picker.element}
352              {where}
353            </div>
354          </section>
355          <section class="start-row">
356            <h3>model</h3>
357            <div class="start-fields">
358              {modelSelect(scope, models, enabled, model, (next) => model.set(modelKey(next)))}
359              {thinkingSelect(scope, models, model, thinking, (level) => thinking.set(level))}
360              {unconfigured}
361            </div>
362          </section>
363          <section class="start-row start-with">
364            <h3>extensions</h3>
365            <div class="start-fields">
366              {toggles}
367              {options}
368            </div>
369          </section>
370        </div>
371      </div>
372      {notice}
373      {input}
374    </div>,
375  ];
376}