diff --git a/ui/app.css b/ui/app.css index c156d24..7fb6525 100644 --- a/ui/app.css +++ b/ui/app.css @@ -67,7 +67,7 @@ bdi { unicode-bidi: isolate; } .btn--danger { color: var(--ink-inverse); background: var(--danger); border-color: var(--danger); } .btn--danger:hover { background: var(--danger-hover); } -input[type="text"], select, textarea { +input[type="text"], input[type="number"], select, textarea { min-width: 0; font: var(--text-sm) var(--font-ui); color: var(--ink); @@ -75,7 +75,7 @@ input[type="text"], select, textarea { border: var(--border-w) solid var(--border-strong); border-radius: var(--radius); } -input[type="text"], select { height: var(--control-h); padding: 0 var(--control-px-sm); } +input[type="text"], input[type="number"], select { height: var(--control-h); padding: 0 var(--control-px-sm); } textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); } :focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); } @@ -452,6 +452,68 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo /* A model's own picture fills the same square, cut to it: the avatar's size does not depend on it. */ .avatar--image { overflow: hidden; background: var(--surface); } .avatar--image img { display: block; width: 100%; height: 100%; object-fit: cover; } +/* The Thinking switch: a checkbox drawn as a switch, then its words, on two known tracks. The + label is the tap target; the whole of it is a control's height. */ +.toggle { + display: inline-grid; + grid-template-columns: calc(2 * var(--check-size)) auto; + gap: var(--sp-2); + align-items: center; + height: var(--control-h); + color: var(--ink); + font: var(--text-sm) var(--font-ui); + cursor: pointer; + white-space: nowrap; +} +.toggle--compact { height: var(--control-h-sm); } +.toggle input { + appearance: none; + position: relative; + width: calc(2 * var(--check-size)); + height: var(--check-size); + margin: 0; + background: var(--surface-active); + border: var(--border-w) solid var(--border-strong); + border-radius: var(--radius-full); + cursor: pointer; + transition: background var(--dur-1) var(--ease-out); +} +.toggle input::after { + content: ""; + position: absolute; + top: var(--border-w); + left: var(--border-w); + width: calc(var(--check-size) - 4 * var(--border-w)); + height: calc(var(--check-size) - 4 * var(--border-w)); + background: var(--ink-muted); + border-radius: var(--radius-full); + transition: transform var(--dur-1) var(--ease-out); +} +.toggle input:checked { background: var(--accent); border-color: var(--accent); } +.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); } +/* The team's size: two number fields of one width with a dash between them, and in the header + its word before them -- fixed tracks, so nothing moves when a number grows a digit. */ +.agents { + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: "min max"; + gap: var(--sp-2); + align-items: center; + width: 100%; + height: var(--control-h); +} +.agents__dash { display: none; } +.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); } +.agents--compact { + grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2); + grid-template-areas: none; + width: auto; + height: var(--control-h-sm); + font: var(--text-sm) var(--font-ui); +} +.agents--compact .agents__dash { display: inline; color: var(--ink-muted); } +.agents--compact .agents__input { height: var(--control-h-sm); } +.agents__label { color: var(--ink-muted); white-space: nowrap; } /* The model picker: a button the height of a control, avatar · name · chevron. */ .picker { display: inline-grid; diff --git a/ui/js/chat.js b/ui/js/chat.js index 8acac5a..79fc7f9 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -12,7 +12,7 @@ import { markdown } from "./markdown.js" import { openViewer, showKind } from "./viewer.js" import { readLocal } from "./explorer.js" import { menu, ask } from "./popup.js" -import { avatar, modelPicker } from "./models.js" +import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js" import { readyIds, sending, sent, discard } from "./attach.js" const MODES = ["manual", "edit", "auto", "plan"] @@ -544,8 +544,21 @@ async function chatModels(chat) { } } -/** The chat's model, and its effort where the model offers more than one. */ -function headModel(slot, chat, models) { +/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */ +async function teamOffered(chat) { + if (chat.kind !== "chat") return false + try { + return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered + } catch { + return false + } +} + +/** + * The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the + * Thinking switch, or the selector); and the team's size where a team is offered. + */ +async function headModel(slot, chat, models) { const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] } const parts = [] @@ -572,23 +585,38 @@ function headModel(slot, chat, models) { } else if (chat.model) { parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name }))) } - const efforts = current.efforts || [] - if (efforts.length > 1) { - parts.push( - h( - "select", - { - class: "head__mode", - "aria-label": t("Reasoning effort"), - title: t("Reasoning effort"), - onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: e.target.value }).catch((err) => alertIn(why(err))), - }, - chat.effort ? null : h("option", { value: "", text: "—", selected: true }), - efforts.map((x) => h("option", { value: x, text: x, selected: chat.effort === x })), - ), - ) - } + const level = effortControl({ + model: current, + value: chat.effort || levelsOf(current).start, + compact: true, + onChange: async (value) => { + try { + const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value }) + if (open?.chat?.id === chat.id) open.chat = r.chat + } catch (err) { + alertIn(why(err)) + level.value = open?.chat?.effort || levelsOf(current).start + } + }, + }) + if (level) parts.push(level) slot.replaceChildren(...parts) + if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return + const team = agentsControl({ + value: open.chat.agents || chat.agents, + compact: true, + onChange: async (wanted) => { + try { + const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted }) + if (open?.chat?.id === chat.id) open.chat = r.chat + alertIn("") + } catch (err) { + alertIn(why(err)) + team.value = open?.chat?.agents || chat.agents + } + }, + }) + slot.append(team) } /** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */ @@ -728,7 +756,7 @@ export function closeChat() { // --- A new chat ------------------------------------------------------------------------------------ -let draft = null // { kind } +let draft = null // { kind, level (the effort control), agents (the team's size) } /** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */ export function newChat(kind) { @@ -746,19 +774,31 @@ export function newChat(kind) { const s = ctx.status() const models = ctx.models() const start = (models.find((m) => m.default) || models[0])?.id || "" - const effort = h("select", { id: "new-effort" }) - const effortField = field(t("Reasoning effort"), effort, "") - const showEfforts = (id) => { - const efforts = models.find((m) => m.id === id)?.efforts || [] - effort.replaceChildren(h("option", { value: "", text: "—" }), ...efforts.map((x) => h("option", { value: x, text: x }))) - effortField.hidden = efforts.length < 2 + // How hard it thinks, and the team's size: drawn for the chosen model, again when it changes. + // Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown. + const effortField = field(t("Reasoning effort"), h("span"), "") + const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them.")) + const showLevels = (id) => { + const model = models.find((m) => m.id === id) + const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" }) + const switchOnly = levelsOf(model).control === "switch" + effortField.children[0].textContent = switchOnly ? t("Thinking") : t("Reasoning effort") + effortField.children[0].htmlFor = "new-effort" + effortField.children[1].replaceWith(control || h("span", { id: "new-effort" })) + effortField.hidden = !control + draft.level = control + const team = kind === "chat" && teamPossible(model) + draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null + agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" })) + agentsField.children[0].htmlFor = "new-agents" + agentsField.hidden = !team } const modelField = field( t("Model"), - modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showEfforts }), + modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }), models.length ? "" : t("No model is available to this account."), ) - showEfforts(start) + showLevels(start) if (kind === "agent") { const roots = s.remote?.enabled ? s.remote.roots : [] const devices = ctx.thisDevices() @@ -793,7 +833,7 @@ export function newChat(kind) { ), ) } else { - form.append(h("div", { class: "fields" }, modelField, effortField)) + form.append(h("div", { class: "fields" }, modelField, effortField, agentsField)) } showComposer(true) setRunning(false) @@ -813,8 +853,10 @@ async function createChat(text, attachments) { if (attachments.length) body.attachments = attachments const model = $("new-model")?.value if (model) body.model = model - const effort = $("new-effort")?.value - if (effort) body.effort = effort + const effort = draft.level?.value + if (typeof effort === "string" && effort) body.effort = effort + const agents = draft.agents?.value + if (agents && (agents.min || agents.max)) body.agents = agents if (draft.kind === "agent") { const device = ctx.thisDevices()[0] const dir = $("new-dir")?.value diff --git a/ui/js/models.js b/ui/js/models.js index 591f28a..4e552be 100644 --- a/ui/js/models.js +++ b/ui/js/models.js @@ -122,3 +122,142 @@ export function modelPicker({ id, models, value, onChange, label }) { draw() return btn } + +// --- How hard a model thinks, and how many agents a chat may have -------------------------------- +// +// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them: +// `levels` in offering order (`off` first where the model can switch thinking off, then its +// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws +// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or +// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`. + +/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */ +export function levelsOf(model) { + const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null + const levels = given || (model?.efforts?.length ? model.efforts : ["default"]) + const control = + ["none", "switch", "slider"].includes(model?.control) ? model.control + : levels.length < 2 ? "none" + : levels.length === 2 && levels.includes("off") ? "switch" + : "slider" + const start = levels.includes(model?.effort) ? model.effort : levels[0] + return { levels, start, control } +} + +/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */ +export function levelWord(level) { + return level === "off" ? t("off") : level === "default" ? t("default") : String(level) +} + +/** + * The control for a model's levels, or null where it has one level: the Thinking switch (a real + * checkbox with the switch role, the label its tap target) or the selector. `value` is the level + * in force; `onChange(level)` when the person changes it. The element's `value` is the level. + */ +export function effortControl({ model, value, onChange, id, compact }) { + const { levels, start, control } = levelsOf(model) + if (control === "none") return null + let current = levels.includes(value) ? value : start + if (control === "switch") { + const on = levels.find((l) => l !== "off") || start + const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" }) + const el = h( + "label", + { class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") }, + box, + h("span", { class: "toggle__label", text: compact ? t("Thinking") : t("Think before answering") }), + ) + if (compact) box.setAttribute("aria-label", t("Think before answering")) + box.addEventListener("change", () => { + current = box.checked ? on : "off" + onChange?.(current) + }) + Object.defineProperty(el, "value", { + get: () => current, + set: (v) => { + current = v + box.checked = v !== "off" + }, + }) + return el + } + const el = h( + "select", + { id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") }, + levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })), + ) + el.addEventListener("change", () => { + current = el.value + onChange?.(current) + }) + return el +} + +/** + * Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs + * on its device), a model that calls tools and serves two requests at once (`parallel` null is a + * hosted API with no stated limit). The server decides again; this only keeps the fields away + * where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`). + */ +export function teamPossible(model) { + return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2) +} + +/** + * The team's size: the fewest and the most agents, the chat's own agent included, as two number + * fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's + * default for new chats then stands); `onChange({ min, max })` once a field is changed, with the + * empty one left out. The server checks the pair and says the bounds when it refuses one. + */ +export function agentsControl({ value, onChange, id, compact }) { + const field = (which, v, label) => + h("input", { + type: "number", + class: "agents__input", + id: which === "min" ? id : null, + min: "1", + step: "1", + inputmode: "numeric", + value: v === undefined || v === null ? "" : String(v), + placeholder: compact ? null : t("default"), + "aria-label": label, + title: label, + }) + const low = field("min", value?.min, t("Fewest")) + const high = field("max", value?.max, t("Most")) + const bounds = () => { + high.min = low.value || "1" + if (high.value) low.max = high.value + else low.removeAttribute("max") + } + bounds() + const read = () => { + const out = {} + if (low.value) out.min = Number(low.value) + if (high.value) out.max = Number(high.value) + return out + } + for (const input of [low, high]) { + input.addEventListener("change", () => { + bounds() + onChange?.(read()) + }) + } + const el = h( + "span", + { class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Agents"), title: t("Its own agent included. Above one, it can add members that do the work while it leads them.") }, + compact ? h("span", { class: "agents__label", text: t("Agents") }) : null, + low, + h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }), + high, + ) + Object.defineProperty(el, "value", { + get: read, + set: (v) => { + low.value = v?.min ?? "" + high.value = v?.max ?? "" + bounds() + }, + }) + return el +}