import { Signal } from "@char/aftercare"; import type { JsonValue } from "@berry/api"; import { userContent } from "@berry/api/images.ts"; import { api } from "./client.ts"; import { composer, go } from "./composer.tsx"; import { configurationControls, configurationField, type ConfigurationLocation, liveChoices, } from "./configuration.tsx"; import { type CommandInfo, type ExtensionInfo, notifier, type Shared, thinkingSelect, } from "./conversation.tsx"; import { projectPicker } from "./projects.tsx"; import { modelKey, modelSelect } from "./settings.tsx"; import { computed, type Scope, swap } from "./ui.ts"; type Values = Record; const EMPTY: readonly never[] = []; const EXTENSIONS = "berry.new.extensions"; const MODEL = "berry.new.model"; const THINKING = "berry.new.thinking"; const SEEDS = "berry.new.seeds"; /** * The page a conversation starts from: where it works, how it thinks, and with which extensions, above a composer * whose first message starts it. It starts in `project`, or in none for `""`, or where the last one did for `null`. */ export function newConversation(scope: Scope, shared: Shared, project: string | null): Element[] { const { extensions, models, enabled } = shared; const { element: notice, notify } = notifier(); const optional = extensions.derive((list) => list.filter((extension) => extension.running && !extension.alwaysOn) ); const remembered = JSON.parse(localStorage.getItem(EXTENSIONS) ?? "null") as string[] | null; const chosen = new Signal | null>(remembered && new Set(remembered)); // what the extensions ask for, rather than every one installed: delegation, the browser, and mcp are opt-in const defaults = extensions.derive((list) => new Set(list.filter((extension) => extension.defaultEnabled).map((extension) => extension.name)) ); const isChosen = (name: string) => chosen.get()?.has(name) ?? defaults.get().has(name); // each extension's configuration, as last given; what is not answered takes its default on the server const answers = JSON.parse(localStorage.getItem(SEEDS) ?? "{}") as Record; const model = new Signal(localStorage.getItem(MODEL) ?? ""); const thinking = new Signal(localStorage.getItem(THINKING) ?? "high"); // the last model used, while it stays enabled; otherwise the first enabled one const settle = () => { const keys = enabled.get().map(modelKey); if (!keys.includes(model.get())) model.set(keys[0] ?? ""); }; scope.add(enabled.subscribe(settle).unsubscribe); settle(); const location = computed(scope, [model], () => { const key = model.get(); const slash = key.indexOf("/"); return { tid: null, model: slash < 0 ? null : { provider: key.slice(0, slash), modelId: key.slice(slash + 1) }, }; }); // where it works: a project's workspace, or a machine and folder chosen by hand, which show their project const environment = new Signal(String(answers.environment?.environment ?? "local")); const environmentValues = new Signal(null); const picker = projectPicker(scope, shared.projects, (root) => { answers.environment = { environment: root?.environment ?? "local", cwd: root?.cwd ?? "" }; environment.set(String(answers.environment.environment)); environmentValues.set({ ...environmentValues.get(), ...answers.environment }); }); const showProject = () => picker.show({ environment: environment.get(), cwd: String(answers.environment?.cwd ?? "") }); /** An extension's questions, with its answers so far and its defaults; `fields` draws them when it has no view. */ const controls = ( own: Scope, extension: ExtensionInfo, values: Signal, fields: (save: (patch: Values) => Promise) => HTMLElement, answered: (patch: Values) => void = () => {}, ) => { const defaults = Object.fromEntries( Object.entries(extension.configuration!.properties) .filter(([, field]) => field.default !== undefined) .map(([key, field]) => [key, field.default!]), ); values.set({ ...defaults, ...answers[extension.name] }); return configurationControls( own, extension.name, extension.configuration!, location, values, (patch) => { answers[extension.name] = { ...answers[extension.name], ...patch }; answered(patch); return { ...defaults, ...answers[extension.name] }; }, fields, ); }; /** The generated form of an extension's questions, each answered as it changes. */ const generated = ( own: Scope, extension: ExtensionInfo, values: Signal, save: (patch: Values) => Promise, folders?: (key: string) => Parameters[5], ) => { const live = liveChoices(own, extension.name, shared); const fields = Object.entries(extension.configuration!.properties).map(([key, schema]) => ({ key, field: configurationField( key, schema, values.get()?.[key], (value) => void save({ [key]: value }).catch(() => {}), live[key] && { scope: own, choices: live[key] }, folders?.(key), ), })); own.add( values.subscribe((next) => { for (const { key, field } of fields) { if (!field.input.contains(document.activeElement)) field.update(next?.[key]); } }).unsubscribe, ); return fields; }; const where =
; swap( scope, where, extensions.derive((list) => list.find((extension) => extension.name === "environment" && extension.running && extension.configuration ) ?? null ), (extension, own) => { if (!extension) return []; const folders = { scope: own, environment, projects: shared.projects, base: () => null }; const fields = (save: (patch: Values) => Promise) => (
{generated( own, extension, environmentValues, save, (key) => key === "cwd" ? folders : undefined, ).map(({ field }) => field.element)}
) as HTMLElement; return [controls(own, extension, environmentValues, fields, (patch) => { // another machine has other folders if (Object.hasOwn(patch, "environment")) { environment.set(String(patch.environment)); if (!Object.hasOwn(patch, "cwd")) answers.environment.cwd = ""; } showProject(); })]; }, ); const toggles =
; swap( scope, toggles, optional, (list) => list.map((extension) => ( )), ); // what the chosen extensions, and those always on, ask of a new conversation, in installed order const options =
; swap( scope, options, computed( scope, [extensions, chosen], () => extensions.get().filter((extension) => extension.running && extension.configuration && extension.name !== "environment" && (extension.alwaysOn || isChosen(extension.name)) ), ), (list, own) => list.map((extension) => { const values = new Signal(null); return controls(own, extension, values, (save) => (
{extension.name} {generated(own, extension, values, save).map(({ field }) => field.element)}
) as HTMLElement); }), ); const unconfigured = ( ); const create = async () => { const slash = model.get().indexOf("/"); const { tid } = await api<{ tid: string }>("POST", "/api/conversations", { extensions: Object.fromEntries( extensions.get().filter((extension) => extension.running && (extension.alwaysOn ? extension.configuration : isChosen(extension.name)) ).map(({ name }) => [name, answers[name] ?? {}]), ), model: { provider: model.get().slice(0, slash), modelId: model.get().slice(slash + 1) }, thinkingLevel: thinking.get(), }); // installed order, so a selection reads the same every time const names = optional.get().map((e) => e.name).filter(isChosen); localStorage.setItem(EXTENSIONS, JSON.stringify(names)); localStorage.setItem(MODEL, model.get()); localStorage.setItem(THINKING, thinking.get()); localStorage.setItem(SEEDS, JSON.stringify(answers)); return tid; }; // as in a conversation, references and the shell come with code const coding = computed( scope, [extensions, chosen], () => extensions.get().some((extension) => extension.name === "code" && extension.running && (extension.alwaysOn || isChosen(extension.name)) ), ); const { element: input, setText } = composer( scope, "new", new Signal(false), new Signal(EMPTY), async (text, _followUp, images) => { const command = text.match(/^\/(\S+)/)?.[1]; if (command) { throw new Error(`/${command} needs a conversation: send a message to start this one`); } const shell = coding.get() && text.startsWith("!"); if (shell && images.length) throw new Error("Shell commands cannot include images"); if (model.get() === "") throw new Error("enable a model in settings first"); const tid = await create(); try { if (shell) { const excludeFromContext = text.startsWith("!!"); await api("POST", `/api/conversations/${tid}/shell`, { command: text.slice(excludeFromContext ? 2 : 1).trim(), excludeFromContext, }); } else { await api("POST", `/api/conversations/${tid}/messages`, { content: userContent(text, images), }); } go({ tid }, setText); } catch { // the conversation exists now, so the message waits in its composer rather than starting another go({ tid, text }, setText); } }, notify, undefined, (prefix) => { if (!coding.get()) return Promise.resolve([]); const query = new URLSearchParams({ environment: environment.get(), prefix }); const cwd = String(answers.environment?.cwd ?? ""); if (cwd) query.set("cwd", cwd); return api("GET", `/api/references?${query}`); }, coding, ); // a conversation with nothing in it yet, for its views, or for a draft finished there const blank = ( ); // a project's own page waits for its project to arrive; a place shows the project it is in let pending = project; const place = () => { if (pending === "" || (pending && shared.projects.get()[pending])) { picker.pick(pending); pending = null; } showProject(); }; scope.add(shared.projects.subscribe(place).unsubscribe); place(); if (!matchMedia("(pointer: coarse)").matches) { requestAnimationFrame(() => input.querySelector("textarea")?.focus()); } return [
new conversation {blank}
,

environment

{picker.element} {where}

model

{modelSelect(scope, models, enabled, model, (next) => model.set(modelKey(next)))} {thinkingSelect(scope, models, model, thinking, (level) => thinking.set(level))} {unconfigured}

extensions

{toggles} {options}
{notice} {input}
, ]; }