import { Signal } from "@char/aftercare"; import { api, watch } from "./client.ts"; import type { ExtensionInfo, Shared } from "./conversation.tsx"; import { computed, keyed, Scope, swap } from "./ui.ts"; import { notificationsAvailable, notificationsOn, setNotifications } from "./notifications.ts"; export type ModelRef = { provider: string; modelId: string }; export type ModelInfo = { provider: string; id: string; name: string; input: ("text" | "image")[]; reasoning: boolean; thinkingLevels: string[]; contextWindow: number; cost: { input: number; output: number }; }; export const modelKey = (model: ModelRef | ModelInfo) => `${model.provider}/${"modelId" in model ? model.modelId : model.id}`; const tokens = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : `${Math.round(n / 1000)}k`); const price = (n: number) => `$${Number(n.toFixed(2))}`; /** A choice among the enabled models, by provider. A model since disabled stays, marked, while it is chosen. */ export function modelSelect( scope: Scope, models: Signal, enabled: Signal, current: Signal, choose: (model: ModelRef) => void, ): HTMLSelectElement { const select = save(enabled.get().filter((m) => modelKey(m) !== key))} /> {describe(known.get(key), key)} ) as HTMLLIElement; // pointer-driven, as chordpad's track reordering: any pointer, one line where the row will land row.addEventListener("pointerdown", (event) => { if (event.button !== 0 || (event.target as Element).closest("input")) return; // no text selection while dragging; the row still takes focus, for alt+↑/↓ event.preventDefault(); row.focus(); row.setPointerCapture(event.pointerId); const all = [...list.children] as HTMLElement[]; let slot = index; const mark = () => all.forEach((other, i) => { const lands = slot !== index && slot !== index + 1; other.classList.toggle("drop-before", lands && i === slot); other.classList.toggle( "drop-after", lands && slot === all.length && i === all.length - 1, ); }); const moved = (e: PointerEvent) => { // the slot is how many rows have their middle above the pointer slot = all.filter((other) => { const box = other.getBoundingClientRect(); return box.top + box.height / 2 < e.clientY; }).length; mark(); }; const done = (e: PointerEvent) => { row.removeEventListener("pointermove", moved); row.removeEventListener("pointerup", done); row.removeEventListener("pointercancel", done); row.classList.remove("dragging"); document.body.classList.remove("reordering"); slot = e.type === "pointercancel" ? index : slot; mark(); move(index, slot > index ? slot - 1 : slot); }; row.classList.add("dragging"); document.body.classList.add("reordering"); row.addEventListener("pointermove", moved); row.addEventListener("pointerup", done); row.addEventListener("pointercancel", done); }); row.addEventListener("keydown", (event) => { if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; event.preventDefault(); refocus = key; move(index, index + (event.key === "ArrowUp" ? -1 : 1)); }); return row; }); queueMicrotask(() => { if (refocus === null) return; rows.find((row) => row.dataset.key === refocus)?.focus(); refocus = null; }); return rows; }); return list; } type ProviderInfo = { id: string; name: string; apiKey: { name: string } | null; oauth: { name: string; label: string | null; subscription: boolean } | null; /** How it is configured, if it is. */ auth: { type: "api_key" | "oauth"; source?: string } | null; /** Whether berry holds its login, and so can log it out. */ stored: boolean; }; type LoginState = { status: "running" | "done" | "failed" | "cancelled"; events: ( | { type: "info"; message: string; links?: { url: string; label?: string }[] } | { type: "auth_url"; url: string; instructions?: string } | { type: "device_code"; userCode: string; verificationUri: string } | { type: "progress"; message: string } )[]; prompt: | { id: number; type: "text" | "secret" | "manual_code"; message: string; placeholder?: string } | { id: number; type: "select"; message: string; options: { id: string; label: string; description?: string }[]; } | null; error: string | null; }; const link = (url: string, label?: string) => ( {label ?? url} ); /** A login as it runs on the server: what it says, what it asks, and how it ended. */ function loginDialog(provider: ProviderInfo, type: "api_key" | "oauth", settled: () => void) { const scope = new Scope(); const dialog = as HTMLDialogElement; const state = new Signal(null); let id: string | null = null; const running = () => state.get()?.status === "running" || state.get() === null; const cancel = () => id && api("POST", `/api/logins/${id}/cancel`).catch(console.error); const close = () => { scope.dispose(); dialog.close(); dialog.remove(); settled(); }; // escape cancels a running login, and closes one that has ended dialog.addEventListener("cancel", (event) => { event.preventDefault(); if (running()) void cancel(); else close(); }); // each part follows its own slice, so an event arriving mid-answer leaves the question as typed const events =