Files
LLeMbas-Desktop/ui/js/chat.js
T
HomerandClaude Opus 5.5 2cdd4e5355
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Draw a picture in a chat: force_tool image_generate on POST /chats and /messages
A switch among a chat's chips (not an agent chat's), offered where /me says
the server draws for the account, asks for the next message's reply to be a
picture; it goes off once the message is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:31:02 +00:00

1510 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// One chat: its transcript, the reply being written (followed as the server streams it), the
// cards that wait for an answer, and the composer with the chat's settings as chips under the
// text (folder, mode, model, effort, agents). And a new chat, which is the same page empty: a
// greeting, the choice of an agent chat on this computer or a chat on the server, and the composer.
//
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
// repository is the contract): a person's message is their text as typed; a reply is its steps --
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
import { $, h, clear, append, icon, ICONS, bytes } from "./dom.js"
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
import { folderName, listed, folderTree, newFolder } from "./list.js"
import { unarchive } from "./archived.js"
import { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
import { scopePopover } from "./scope.js"
import { readAloudButton } from "./voice.js"
const MODES = ["manual", "edit", "auto", "plan"]
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
return open?.chat?.id || null
}
// --- Reading -----------------------------------------------------------------------------------
export function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
function deviceOf(id) {
return (ctx.devices?.() || []).find((d) => d.id === id) || null
}
/** This computer's device: the one linked right now, else the last one it was. */
function hereDevice() {
const mine = ctx.thisDevices()
return mine.find((id) => deviceOf(id)) || mine[0] || ""
}
/**
* The device a new agent chat goes to: the one chosen on its chip while it is still linked, else
* this computer's.
*/
function draftDevice() {
if (draft?.device && deviceOf(draft.device)) return draft.device
return hereDevice()
}
/** Whether the new agent chat works here (the agent built into this app) or on another device. */
function draftHere() {
const d = draftDevice()
return !d || ctx.thisDevices().includes(d)
}
/** The other linked devices of this account: where else a new agent chat can work. */
function otherDevices() {
const mine = ctx.thisDevices()
return (ctx.devices?.() || []).filter((d) => !mine.includes(d.id))
}
/**
* The folders an agent chat on another device has worked in: the sidebar's agent chats there,
* newest first. This app cannot browse another computer; these and a typed path are the choice.
*/
function knownFolders(device) {
const out = []
for (const c of listed()) {
const dir = c.kind === "agent" && c.device?.token_id === device ? c.device.directory : ""
if (dir && !out.includes(dir)) out.push(dir)
}
return out.slice(0, 8)
}
/**
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
* are chosen (the shell before the first message), an open agent chat on a device that opens
* terminals, or nothing.
*/
function terminalTarget() {
if (draft) {
if (draft.kind !== "agent" || !draft.dir) return null
const device = deviceOf(draftDevice())
if (!device?.terminal) return null
return { device: device.id, directory: draft.dir, name: device.name || (draftHere() ? t("This computer") : t("Another device")) }
}
const chat = open?.chat
if (chat?.kind !== "agent" || !chat.device) return null
const device = deviceOf(chat.device.token_id)
if (!device?.terminal) return null
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
}
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
async function readAgentFacts(device) {
if (!device) return null
const known = agentFacts.get(device)
if (known && Date.now() - known.at < 5000) return known
try {
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
if (!r.agent_subagents) {
agentFacts.set(device, { at: Date.now(), none: true })
return agentFacts.get(device)
}
noteSubagentsCap(r.subagents_cap)
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
agentFacts.set(device, facts)
return facts
} catch {
return null
}
}
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
function agentOffer(facts, model) {
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
const said = facts.models.get(model?.id)
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
return { cap: subagentsCap(), own: namedOnly(said) }
}
/** Text into the message box, after what is there, for the person to read before sending. */
export function intoComposer(text) {
const box = $("composer-text")
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
box.dispatchEvent(new Event("input"))
box.focus()
}
function nearBottom() {
const th = $("thread")
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
}
function toBottom(force) {
const th = $("thread")
if (force || nearBottom()) th.scrollTop = th.scrollHeight
}
/** A small icon button under a message. */
function action(ico, label, onclick, cls = "") {
return h("button", { type: "button", class: `msg__act ${cls}`, "aria-label": label, title: label, onclick }, icon(ico))
}
function copyText(text) {
navigator.clipboard?.writeText(text).catch(() => {})
}
/**
* A file a message carries, as a tile: a picture as its thumbnail (read through the bridge, as
* a `data:` URL), anything else as its icon, name and size.
*/
export function fileTile(a) {
const tile = h(
"li",
{ class: `tile${a.image ? " tile--image" : ""}`, title: a.name },
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: a.name })),
h("span", { class: "tile__size", text: a.size ? bytes(a.size) : "" }),
)
if (a.image && a.id) {
picture(`/attachments/${a.id}`).then((url) => {
if (!url || !url.startsWith("data:image/")) return
tile.classList.add("has-thumb")
tile.prepend(h("img", { class: "tile__thumb", src: url, alt: "", draggable: "false" }))
})
}
return tile
}
function userMessage(m) {
const el = h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
m.attachments?.length ? h("ul", { class: "tiles msg__files" }, m.attachments.map(fileTile)) : null,
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
// A background job's report is not the person's to rewrite (the server refuses it too).
if (!m.machine) {
el.append(
h(
"div",
{ class: "msg__actions" },
action(ICONS.edit, t("Edit message"), () => startEdit(el, m)),
action(ICONS.copy, t("Copy"), () => copyText(m.text || "")),
),
)
}
return el
}
/**
* Edit one of the person's messages in place. Saving rewrites it, drops everything after it and
* starts the reply again (on the device too, for an agent chat): the server's rule, said first.
*/
function startEdit(el, m) {
if (!open?.chat?.id || el.querySelector(".msg__edit")) return
const box = h("textarea", { class: "msg__edit", rows: "3", value: m.text || "", "aria-label": t("Edit message") })
const note = h("p", { class: "hint", text: t("Saving starts the reply again; everything after this message is dropped.") })
const said = h("p", { class: "msg__note is-error", hidden: true })
const save = h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save") })
const cancel = h("button", { type: "button", class: "btn btn--sm", text: t("Cancel") })
const form = h("div", { class: "msg__editor" }, box, note, said, h("div", { class: "msg__editbar" }, cancel, save))
const parts = [...el.children]
for (const c of parts) c.hidden = true
el.append(form)
const close = () => {
form.remove()
for (const c of parts) c.hidden = false
}
cancel.addEventListener("click", close)
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") close()
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) save.click()
})
save.addEventListener("click", async () => {
const text = box.value.trim()
if (!text && !m.attachments?.length) return box.focus()
save.disabled = cancel.disabled = true
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/edit`, { text })
reload()
} catch (err) {
said.textContent = why(err)
said.hidden = false
save.disabled = cancel.disabled = false
}
})
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/** The reply's words, as Markdown: what Copy puts on the clipboard. */
function replyText(m) {
return (m.steps || [])
.filter((s) => s.kind === "text")
.map((s) => s.markdown || "")
.join("\n\n")
}
async function regenerate(m) {
if (!open?.chat?.id) return
alertIn("")
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/regenerate`)
reload()
} catch (err) {
alertIn(why(err))
}
}
function metricsLine(x) {
if (!x) return null
const parts = []
if (x.total_tokens) parts.push(t("%(n)s tokens", { n: x.total_tokens.toLocaleString() }))
if (x.tok_s) parts.push(`${x.tok_s} tok/s`)
if (x.elapsed_ms) parts.push(`${(x.elapsed_ms / 1000).toFixed(1)} s`)
if (x.context_limit && x.percent !== undefined) parts.push(`${x.percent} % ctx`)
return parts.length ? h("p", { class: "msg__metrics", text: parts.join(" · ") }) : null
}
function assistantMessage(m, chat) {
const el = h("article", { class: "msg msg--assistant", dataset: { id: m.id } })
const steps = h("div", { class: "msg__steps" })
renderSteps(steps, m.steps || [], chat)
el.append(steps)
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
const meta = metricsLine(m.metrics)
const model = m.model ? ctx.models().find((x) => x.id === m.model) || { id: m.model, name: m.model } : null
el.append(
h(
"footer",
{ class: "msg__foot" },
h(
"div",
{ class: "msg__actions" },
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
readAloudButton(m.id, () => replyText(m)),
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
),
model ? h("span", { class: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
meta,
),
)
return el
}
function renderSteps(box, steps, chat) {
clear(box)
for (const s of steps) box.append(step(s, chat))
}
function step(s, chat) {
switch (s.kind) {
case "thinking":
return h(
"details",
{ class: "step step--thinking", open: s.open || null },
h("summary", { class: "step__row" }, h("span", { class: "step__icon" }, icon(ICONS.bulb)), h("span", { class: "step__label" }, t("Thought"), s.label ? h("span", { class: "step__meta", text: ` · ${s.label}` }) : null), h("span", { class: "step__chev" }, icon(ICONS.chevron))),
h("div", { class: "step__pre", text: s.text || "" }),
)
case "text":
return h("div", { class: "step step--text md" }, markdown(s.markdown || ""))
case "tools":
return h("div", { class: "step step--tools" }, (s.calls || []).map((c) => toolCall(c, chat)))
case "steer":
return h(
"div",
{ class: "step step--steer" },
h("p", { class: "muted", text: t("Taken in while it ran:") }),
(s.messages || []).map((m) => h("p", { class: "steer__text", text: m.text })),
)
default:
return h("div", { class: "step" })
}
}
/**
* Where the text of a file the agent showed comes from, or null when there is none to fetch:
* - an agent chat on this computer: the file itself, read here (the card's path, or the call's
* own `args.path` for a step the card does not describe);
* - otherwise the server, through the card (`show`): `?node=&version=` for a file in the tree,
* `?shown=` for the copy a device sent. The server checks the chat's access again then.
*/
function shownSource(call, chat) {
if (onThisComputer(chat) && chat.device.directory) {
const path = call.show?.path || call.args?.path || ""
return path ? { local: true, path } : null
}
const s = call.show
if (!s || !chat?.id) return null
const q = s.shown_id
? `shown=${encodeURIComponent(s.shown_id)}`
: s.node_id && s.version_id
? `node=${encodeURIComponent(s.node_id)}&version=${encodeURIComponent(s.version_id)}`
: ""
return q ? { local: false, path: s.path || "", url: `/chats/${encodeURIComponent(chat.id)}/show?${q}` } : null
}
function toolCall(c, chat) {
const block = c.block || {}
const failed = c.status === "error" || !!c.error
const body = h("div", { class: "call__body" })
if (c.purpose) body.append(h("p", { class: "muted", text: c.purpose }))
if (c.args?.command) body.append(h("pre", { class: "call__pre", text: c.args.command }))
if (c.diff) body.append(h("pre", { class: "call__pre call__diff", text: typeof c.diff === "string" ? c.diff : JSON.stringify(c.diff, null, 2) }))
if (c.text) body.append(h("pre", { class: "call__pre", text: c.text }))
if (c.error) body.append(h("p", { class: "msg__note is-error", text: typeof c.error === "string" ? c.error : JSON.stringify(c.error) }))
if (c.truncated) body.append(h("p", { class: "muted", text: t("Cut short here; the whole of it is in the web interface.") }))
const isShow = c.name === "show" && !failed && shownSource(c, chat)
const summary = h(
"summary",
{ class: "call__head" },
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "call__name", text: c.name || "" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title && block.title !== c.name ? block.title : "" }),
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
h("span", { class: "step__chev" }, icon(ICONS.chevron)),
)
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
if (isShow) {
el.append(h("button", { type: "button", class: "btn btn--sm call__open", text: t("Open in the viewer"), onclick: () => openShown(c, chat) }))
}
return el
}
/** A file the agent showed: from this computer when the chat runs here, else from the server. */
async function openShown(call, chat) {
const src = shownSource(call, chat)
if (!src) return
const path = src.path
const { language } = showKind(path)
const line = call.show?.line || (src.local ? call.args?.line : 0) || 0
const base = { name: call.show?.name || path.split("/").pop(), path, kind: call.show?.kind, language: call.show?.language || language, line }
try {
if (src.local) {
const dir = chat.device.directory.replace(/\/+$/, "")
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
const f = await readLocal(dir, rel)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("This computer")}`, text: f.text, withheld: f.withheld, size: f.size })
} else {
// Plain text, at most 1 MB; the server's 404 (no longer the chat's), 413 (too large) and
// 415 (not text) come back as its own words. HTML is its source: the viewer seals it.
const text = await getText(src.url)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text })
}
} catch (err) {
openViewer({ ...base, where: t("Shown by the agent"), error: why(err) })
}
}
// --- The reply being written ---------------------------------------------------------------------
class Live {
constructor(chat, messageId) {
this.chat = chat
this.id = messageId
this.steps = h("div", { class: "msg__steps" })
this.tail = h("div", { class: "step step--text md msg__tail" })
this.thinking = h("p", { class: "msg__thinking muted" })
this.status = h("p", { class: "msg__status muted", text: t("Writing…") })
this.jobs = h("ul", { class: "msg__jobs", hidden: true })
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
this.done = false
this.handle = null
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
this.said = "" // the last `status` text, for a server that sends no progress
this.clock = 0
}
/** The status line: the progress object's line where the server sends one, else its text. */
drawStatus() {
const p = this.progress
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
this.status.dataset.phase = isProgress(p) ? p.phase : ""
const ticks = progressTicks(p)
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
else if (!ticks) this.stopClock()
}
stopClock() {
clearInterval(this.clock)
this.clock = 0
}
async start() {
try {
this.handle = await follow(`/chats/${encodeURIComponent(this.chat.id)}/messages/${encodeURIComponent(this.id)}/stream`, {
onFrame: (event, data) => this.frame(event, data),
onEnd: (err) => this.end(err),
})
} catch (err) {
this.end(err)
}
}
stop() {
this.done = true
this.stopClock()
this.handle?.close()
}
frame(event, data) {
if (this.done) return
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
// the object, or null for nothing running -- then the status text again.
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
this.progress = isProgress(data.progress) ? data.progress : null
if (event !== "status") this.drawStatus()
}
switch (event) {
case "steps":
renderSteps(this.steps, data.steps || [], this.chat)
this.tailed(data.tail)
break
case "tail":
this.tailed(data)
break
case "status":
this.said = data.text || ""
this.drawStatus()
break
case "ask":
renderCards(this.chat, data.cards || [])
break
case "tree":
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
treeChanged(this.chat.id, data.tree)
break
case "jobs":
this.jobs.hidden = !(data.jobs || []).length
append(
clear(this.jobs),
(data.jobs || []).map((j) =>
h("li", { class: `job job--${j.state}` }, h("span", { class: "chip", text: j.state }), h("span", { text: j.description || j.command || j.agent || "" })),
),
)
break
case "steered":
for (const id of data.ids || []) document.querySelector(`.msg--user[data-id="${CSS.escape(id)}"]`)?.classList.remove("is-queued")
break
case "done":
this.done = true
this.stopClock()
this.el.replaceWith(assistantMessage(data.message, this.chat))
renderCards(this.chat, [])
if (data.chat?.title) setTitle(data.chat.title)
ctx.changed(this.chat)
if (open?.live === this) open.live = null
setRunning(false)
// A message queued behind this reply has one of its own now: read the chat again.
if (data.next) reload()
break
default:
break
}
toBottom(false)
}
tailed(tail) {
if (!tail) return
this.thinking.textContent = tail.thinking ? `${t("Thinking")} ${tail.label || ""}…` : ""
this.thinking.hidden = !tail.thinking
clear(this.tail).append(markdown(tail.text || ""))
}
end(err) {
if (this.done) return
this.done = true
this.stopClock()
if (open?.live === this) open.live = null
setRunning(false)
if (err) {
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
this.status.classList.add("is-error")
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
} else {
reload()
activityChanged()
}
}
}
/** While a reply is written, Stop takes Send's place (Send comes back beside it for a message typed meanwhile). */
function setRunning(on) {
$("send").hidden = on && !$("composer-text").value.trim()
$("stop").hidden = !on
$("composer").dataset.running = String(on)
}
function follower(chat, messageId) {
if (open?.live) open.live.stop()
const live = new Live(chat, messageId)
open.live = live
$("messages").append(live.el)
setRunning(true)
live.start()
toBottom(true)
}
// --- Cards ----------------------------------------------------------------------------------------
/**
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
* countdown included, and drawing it again would take away a command being corrected or an option
* already picked. New ones are added in the server's order; the ones no longer listed go.
*/
function renderCards(chat, cards) {
const box = $("cards")
const ids = cards.map((c) => String(c.id))
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
cards.forEach((card, i) => {
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
if (!el) {
try {
el = cardOf(chat, card)
} catch (err) {
// Never an empty box under a status that says the reply waits: say what went wrong.
console.error("card", card, err)
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
}
el.dataset.card = String(card.id)
el.dataset.chat = chat.id
}
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
})
box.hidden = !box.children.length
}
function cardOf(chat, card) {
const answers = { choices: {}, texts: {} }
const decides = card.items.some((i) => i.kind === "approval" || i.kind === "plan")
const el = h("section", { class: `ask ask--${card.kind}`, "aria-label": t("Waiting for your answer") })
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
for (const item of card.items) {
const part = h("div", { class: "ask__item" })
// A background subagent asks, not the conversation's own turn: said first (`job`).
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
if (item.subagent) {
const sub = item.subagent
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
}
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
let detailShown = false
if (item.kind === "approval") {
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
if (item.editable) {
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
// this item's text, and only when it differs -- an untouched one is not an edit.
const command = item.detail || item.title
detailShown = true
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
edit.addEventListener("input", () => {
if (edit.value !== command) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else {
// What it wants to do (a tool's own title), then its input as the device sent it.
part.append(h("p", { class: "ask__what", text: item.title }))
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
detailShown = true
}
} else if (item.kind === "plan" && item.plan) {
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
} else {
part.append(h("p", { class: "ask__title", text: item.title }))
}
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
// The model's account of what it is for, attributed to it; then the server's reason for asking.
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
if (item.options?.length) {
const picked = new Set()
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
for (const o of item.options) {
const b = h(
"button",
{ type: "button", class: "opt", "aria-pressed": "false" },
h("span", { class: "opt__label", text: o.label }),
o.description ? h("span", { class: "opt__desc", text: o.description }) : null,
o.recommended ? h("span", { class: "chip", text: t("Recommended") }) : null,
)
b.addEventListener("click", () => {
if (!item.multiple) {
picked.clear()
for (const x of opts.children) x.setAttribute("aria-pressed", "false")
}
if (picked.has(o.label)) picked.delete(o.label)
else picked.add(o.label)
b.setAttribute("aria-pressed", String(picked.has(o.label)))
answers.choices[item.key] = [...picked]
})
opts.append(b)
}
part.append(opts)
}
if (item.free_text && item.kind === "question") {
const typed = h("input", { type: "text", class: "ask__text", placeholder: t("Or type an answer"), "aria-label": t("Your answer") })
typed.addEventListener("input", () => {
answers.texts[item.key] = typed.value
})
part.append(typed)
}
el.append(part)
}
const said = h("p", { class: "ask__said", "aria-live": "polite" })
const send = async (verdict) => {
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = true
try {
await post(`/chats/${encodeURIComponent(chat.id)}/interactions/${encodeURIComponent(card.id)}`, {
verdict,
choices: answers.choices,
texts: answers.texts,
})
el.remove()
if (!$("cards").children.length) $("cards").hidden = true
} catch (err) {
if (err?.status === 409) {
el.remove()
reload()
return
}
said.textContent = why(err)
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = false
}
}
const actions = h("div", { class: "ask__actions" })
if (decides) {
const isPlan = card.items.some((i) => i.kind === "plan")
append(actions, [
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
])
} else {
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
}
el.append(actions, said)
return el
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
$("chat-title").textContent = title || t("New chat")
$("chat-title").title = title || ""
}
/** The chat's header: its title, where it works (an agent chat's folder and device), and its tools. */
function renderHead(chat) {
setTitle(chat.title)
const place = clear($("chat-place"))
const tools = clear($("chat-tools"))
if (chat.kind === "agent" && chat.device) {
const here = onThisComputer(chat)
place.append(
h(
"span",
{ class: `place${here ? " is-here" : ""}`, title: chat.device.directory },
icon(ICONS.folder),
h("span", { class: "place__name", text: folderName(chat.device.directory) }),
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
),
)
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
// "Continue in a terminal" stays in the chat's menu.
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
}
// An archived chat says so where it is read, with the way back into the sidebar.
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
async function chatModels(chat) {
try {
return (await get(`/models?chat=${encodeURIComponent(chat.id)}`)).models || []
} catch {
return ctx.models()
}
}
/**
* Whether this chat is offered subagents: `{ cap, own }` (the instance's most; whether only a model
* the person names could serve them), or null. The chat's own `subagents` where the server gives
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
*/
async function teamOffered(chat) {
const said = chat.subagents
if (said !== undefined && said !== null) {
const offered = typeof said === "object" ? said.offered : said
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
}
if (chat.kind !== "chat") return null
try {
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
return team?.offered ? { cap: subagentsCap(), own: false } : null
} catch {
return null
}
}
// --- The chips under the text -------------------------------------------------------------------
//
// A chat's settings are small buttons under what is being written, each opening a menu: the
// folder and the mode (an agent chat), the model with its picture, how hard it thinks (as the
// model offers it: nothing for one level), and the team's size (where a team is offered). For a
// new chat they say what it will start with; for an open one, changing one changes the chat.
/** One chip: an icon or a lead (an avatar), its words, and a chevron when it opens a menu. */
function chip({ ico, lead, label, title, onclick, menuish = true, cls = "", disabled = false }) {
return h(
"button",
{ type: "button", class: `cchip ${cls}`, title: title || label, "aria-haspopup": menuish ? "menu" : null, disabled, onclick },
lead || (ico ? icon(ico) : null),
h("span", { class: "cchip__label", text: label }),
menuish ? h("span", { class: "cchip__chev" }, icon(ICONS.chevronDown)) : null,
)
}
const MODE_WORDS = {
manual: () => t("Manual — asks first"),
edit: () => t("Edit — asks before commands"),
auto: () => t("Auto — asks nothing"),
plan: () => t("Plan — changes nothing"),
}
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
/** Whether a chat here may be asked for a picture: the server draws for this account. */
function picturesOffered() {
const me = ctx.me()
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
}
function pictureChip() {
const b = chip({
ico: ICONS.picture,
label: t("Draw a picture"),
title: t("The reply to the next message is a picture drawn from it."),
menuish: false,
cls: "cchip--picture",
onclick: () => {
wantPicture = !wantPicture
b.setAttribute("aria-pressed", String(wantPicture))
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
$("composer-text").focus()
},
})
b.setAttribute("aria-pressed", String(wantPicture))
return b
}
/** The message's body, asking for a picture while the switch is on. */
function takePicture(body) {
return wantPicture ? { ...body, force_tool: "image_generate" } : body
}
/** The message went: the switch goes off with it. */
function pictureSent() {
if (!wantPicture) return
wantPicture = false
$("composer-text").placeholder = t("Message")
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
}
function modeChip(value, onChange, disabled) {
return chip({
ico: ICONS.shield,
label: modeName(value),
title: `${t("Mode")}: ${MODE_WORDS[value]?.() || value}`,
disabled,
onclick: (e) => menu(e.currentTarget, MODES.map((m) => ({ label: MODE_WORDS[m](), checked: m === value, run: () => m !== value && onChange(m) }))),
})
}
function modelChip(models, value, onChange, mayPick) {
const m = models.find((x) => x.id === value) || (value ? { id: value, name: value } : null)
if (!m) return chip({ ico: ICONS.info, label: t("No model is available to this account."), menuish: false, disabled: true })
return chip({
lead: avatar(m),
label: m.name,
title: `${t("Model")}: ${m.name} (${m.id})`,
menuish: mayPick && models.length > 1,
cls: "cchip--model",
onclick: (e) => {
if (!mayPick || models.length < 2) return
menu(e.currentTarget, models.map((x) => ({ label: x.name, lead: avatar(x), checked: x.id === value, run: () => x.id !== value && onChange(x.id) })))
},
})
}
/** How hard the model thinks: nothing where it has one level; else its levels, in a menu. */
function effortChip(model, value, onChange) {
const { levels, start, control } = levelsOf(model)
if (control === "none") return null
const current = levels.includes(value) ? value : start
const words = (l) => (control === "switch" ? (l === "off" ? t("off") : t("Think before answering")) : levelWord(l))
return chip({
ico: ICONS.bulb,
label: control === "switch" ? (current === "off" ? t("off") : levelWord(current)) : levelWord(current),
title: `${t("Reasoning effort")}: ${levelWord(current)}`,
cls: current === "off" ? "is-dim" : "",
onclick: (e) => menu(e.currentTarget, levels.map((l) => ({ label: words(l), hint: control === "switch" && l !== "off" ? levelWord(l) : "", checked: l === current, run: () => l !== current && onChange(l) }))),
})
}
/**
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
*/
function agentsChip(value, onChange, { cap, own } = {}) {
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
return chip({
ico: ICONS.users,
label: `${t("Subagents")} ${said}`,
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
cls: `cchip--subagents${own ? " is-warn" : ""}`,
onclick: (e) => {
const fields = agentsControl({ value, id: "agents-min", cap })
popover(
e.currentTarget,
h(
"div",
{ class: "pop__body" },
h("p", { class: "pop__title", text: t("Subagents") }),
fields,
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
),
{ up: true, label: t("Subagents") },
)
},
})
}
/** The folder of a new agent chat: one of the agent's folders, or Add folder… where there is none. */
function folderChip(roots, value, onChange) {
if (!roots.length) return chip({ ico: ICONS.folder, label: t("Add folder…"), menuish: false, cls: "is-warn", onclick: () => invoke("remote_add") })
return chip({
ico: ICONS.folder,
label: folderName(value),
title: `${t("Folder")}: ${value}`,
onclick: (e) =>
menu(e.currentTarget, [
...roots.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
null,
{ label: t("Add folder…"), run: () => invoke("remote_add") },
]),
})
}
/**
* Which computer a new agent chat works on: this one (the agent built into this app), or another
* linked device of the account, as `GET /devices` lists them. Shown only where there is another.
*/
function deviceChip(others) {
const here = draftHere()
const current = deviceOf(draftDevice())
const choose = (id) => {
if (id === draftDevice()) return
draft.device = id
const elsewhere = !ctx.thisDevices().includes(id)
draft.dir = elsewhere ? knownFolders(id)[0] || "" : ""
renderChips()
renderHello()
renderNotice()
}
const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }]
for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) })
const label = here ? t("This computer") : current?.name || current?.host || t("Another device")
const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) })
return b
}
/** The folder of a new agent chat on another device: one it has worked in, or one typed. */
function elsewhereFolderChip(device, value, onChange) {
const type = async () => {
const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") })
if (dir) onChange(dir)
}
const known = knownFolders(device)
if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type })
return chip({
ico: ICONS.folder,
label: value ? folderName(value) : t("Choose a folder…"),
title: value ? `${t("Folder")}: ${value}` : "",
cls: value ? "" : "is-warn",
onclick: (e) =>
menu(e.currentTarget, [
...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
known.length ? null : undefined,
{ label: t("Type a folder…"), run: type },
].filter((x) => x !== undefined)),
})
}
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
async function renderChips() {
const box = $("composer-chips")
const drawing = ++chipsDrawn
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
if (draft) {
const s = ctx.status()
const models = ctx.models()
const model = models.find((m) => m.id === draft.model)
const parts = []
if (draft.kind === "agent") {
const others = otherDevices()
if (others.length) parts.push(deviceChip(others))
if (draftHere()) {
const roots = s?.remote?.enabled ? s.remote.roots : []
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
} else {
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
}
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
const shell = terminalTarget()
aimTerminal(shell)
parts.push(modelChip(models, draft.model, (id) => {
draft.model = id
draft.effort = levelsOf(models.find((m) => m.id === id)).start
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : ""
const known = device ? agentFacts.get(device) : null
const offer = device ? agentOffer(known, model) : null
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
if (device && (!known || Date.now() - known.at > 5000)) {
readAgentFacts(device).then((f) => {
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
})
}
// Last, so the chat's own settings come first where the line scrolls.
if (shell) {
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
b.dataset.terminalToggle = ""
b.setAttribute("aria-pressed", String(terminalOpen()))
b.setAttribute("aria-controls", "terminal")
parts.push(b)
}
box.replaceChildren(...parts.filter(Boolean))
return
}
const chat = open?.chat
aimTerminal(terminalTarget())
if (!chat?.kind) return box.replaceChildren()
const save = async (body) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id) {
open.chat = r.chat
renderChips()
if (body.model) activityChanged()
}
alertIn("")
} catch (err) {
alertIn(why(err))
}
}
const models = await chatModels(chat)
if (drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
const parts = []
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
box.replaceChildren(...parts.filter(Boolean))
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
}
/**
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
*/
function renderNotice() {
const box = $("link-notice")
const s = ctx.status()
const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat)
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
box.hidden = !show
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
else box.replaceChildren()
}
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
export function statusChanged() {
renderNotice()
if (draft) {
renderChips()
renderHello()
} else if (open?.chat?.kind) {
// The devices were read again: whether this chat's opens a terminal may have changed.
renderHead(open.chat)
aimTerminal(terminalTarget())
}
}
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (!chat.archived) {
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
}
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) })
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
/**
* Continue an agent chat in a terminal: the CLI's own session on the device it works on
* (`device_session_id`), opened with `lembas -s <id>` in the chat's folder. Shown to copy rather
* than run: a terminal is not something every desktop opens the same way, and the command is the
* person's to run where they want it. On this computer the first word is the CLI as it is
* started here (`lembas` on PATH, else the app's own copy by its path).
*/
export async function continueInTerminal(chat) {
const session = String(chat.device_session_id || "")
if (!/^[A-Za-z0-9_-]+$/.test(session)) return
const here = onThisComputer(chat)
let word = "lembas"
if (here) word = await invoke("terminal_word").catch(() => "lembas")
const dir = chat.device?.directory || ""
const quoted = /^[A-Za-z0-9/._+:@%=-]+$/.test(dir) ? dir : `'${dir.replace(/'/g, "'\\''")}'`
const code = `${dir ? `cd ${quoted} && ` : ""}${word} -s ${session}`
await showLine({
title: t("Continue in a terminal"),
text: here
? t("Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.")
: t("Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it."),
code,
copy: t("Copy"),
copied: t("Copied"),
close: t("Close"),
})
}
/** Where a chat can go: no folder, any of the account's, or a new one. */
function moveMenu(anchor, chat) {
const here = chat.folder_id || ""
menu(anchor, [
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
null,
{
label: t("New folder…"),
run: async () => {
const id = await newFolder()
if (id) setChat(chat, { folder_id: id })
},
},
])
}
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
async function setChat(chat, body) {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
ctx.changed(r?.chat)
} catch (err) {
alertIn(why(err))
}
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { title })
if (open?.chat?.id === chat.id) {
open.chat = r.chat
setTitle(r.chat.title)
}
ctx.changed(r.chat)
} catch (err) {
alertIn(why(err))
}
}
async function archiveChat(chat) {
try {
await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: true })
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
/** An archived chat, open or in its page's menu, back into the sidebar. */
async function unarchiveChat(chat) {
if (!(await unarchive(chat))) return
if (open?.chat?.id === chat.id) {
open.chat = { ...open.chat, archived: false }
renderHead(open.chat)
}
}
async function deleteChat(chat) {
const yes = await ask({
title: t("Delete this chat?"),
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
ok: t("Delete"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
try {
await del(`/chats/${encodeURIComponent(chat.id)}`)
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
function modeName(m) {
return { manual: "Manual", edit: "Edit", auto: "Auto", plan: "Plan" }[m] || m
}
function alertIn(text) {
const box = $("thread-note")
box.textContent = text
box.hidden = !text
}
/** Open a chat by id: its newest page, and the reply being written, if one is. */
export async function openChat(id) {
if (open?.chat?.id !== id) discard()
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
draft = null
closePopover()
invoke("set_open_chat", { chat: id })
showComposer(true)
$("new-chat").hidden = true
$("main").dataset.new = "false"
clear($("composer-chips"))
renderNotice()
const list = clear($("messages"))
renderCards({ id }, [])
alertIn("")
list.append(h("p", { class: "muted thread__loading", text: t("Loading…") }))
let r
try {
r = await get(`/chats/${encodeURIComponent(id)}`)
} catch (err) {
clear(list)
alertIn(why(err))
return
}
if (open?.chat?.id !== id) return
open.chat = r.chat
open.more = r.more
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
clear(list)
if (r.more) list.append(earlier())
for (const m of r.messages) {
if (m.id === r.live) continue
list.append(m.role === "user" ? userMessage(m) : assistantMessage(m, r.chat))
}
ctx.opened(r.chat)
if (r.live) follower(r.chat, r.live)
else setRunning(false)
toBottom(true)
$("composer-text").focus()
}
function earlier() {
return h("button", {
type: "button",
class: "btn btn--sm thread__earlier",
text: t("Earlier messages"),
onclick: async (e) => {
const first = $("messages").querySelector(".msg")?.dataset.id
if (!first || !open) return
e.target.disabled = true
try {
const r = await get(`/chats/${encodeURIComponent(open.chat.id)}/messages?before=${encodeURIComponent(first)}`)
const th = $("thread")
const from = th.scrollHeight
const frag = document.createDocumentFragment()
if (r.more) frag.append(earlier())
for (const m of r.messages) frag.append(m.role === "user" ? userMessage(m) : assistantMessage(m, open.chat))
e.target.replaceWith(frag)
th.scrollTop += th.scrollHeight - from
} catch (err) {
e.target.disabled = false
alertIn(why(err))
}
},
})
}
function reload() {
if (open?.chat?.id) openChat(open.chat.id)
}
export function closeChat() {
if (open?.live) open.live.stop()
open = null
watchActivity(null)
invoke("set_open_chat", { chat: null })
}
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written
let lastKind = "agent"
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
export function draftKind() {
return draft?.kind || lastKind
}
/** `/models` was read again: a new chat being written is drawn with what it says now. */
export function modelsChanged() {
if (!draft) return
const models = ctx.models()
if (!models.some((m) => m.id === draft.model)) {
const start = models.find((m) => m.default) || models[0]
draft.model = start?.id || ""
draft.effort = levelsOf(start).start
}
renderChips()
}
/** The greeting of a new chat, and the choice of what it is. */
function renderHello() {
const box = $("new-chat")
if (!draft) return
const me = ctx.me()
const first = String(me?.name || "").trim().split(/\s+/)[0]
const kind = draft.kind
const s = ctx.status()
const server = (() => {
try {
return new URL(s?.server || "").host
} catch {
return ""
}
})()
const tab = (k, ico, label) =>
h(
"button",
{
type: "button",
role: "tab",
class: "kind__tab",
"aria-selected": String(kind === k),
onclick: () => {
if (draft.kind === k) return
draft.kind = lastKind = k
renderHello()
renderChips()
renderNotice()
ctx.drafted?.(k)
$("composer-text").focus()
},
},
icon(ico),
h("span", { text: label }),
)
box.replaceChildren(
h("img", { class: "hello__mark", src: "logo-mark.svg", alt: "", width: "40", height: "40" }),
h("h2", { class: "hello__title", text: first ? t("Hello, %(name)s", { name: first }) : t("Hello") }),
h(
"p",
{ class: "hello__sub" },
kind === "agent" && !draftHere()
? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") })
: kind === "agent"
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
),
h("div", { class: "kind", role: "tablist", "aria-label": t("New chat") }, tab("agent", ICONS.terminal, t("Agent")), tab("chat", ICONS.chat, t("Chat"))),
)
}
/** A new chat in the main view: the page empty, the greeting, and the composer ready. */
export function newChat(kind) {
closeChat()
discard()
closePopover()
const models = ctx.models()
const start = models.find((m) => m.default) || models[0]
lastKind = kind || lastKind
draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
setTitle(t("New chat"))
clear($("chat-place"))
clear($("chat-tools"))
$("main").dataset.new = "true"
$("new-chat").hidden = false
renderHello()
renderChips()
renderNotice()
showComposer(true)
setRunning(false)
invoke("set_open_chat", { chat: null })
$("composer-text").focus()
}
async function createChat(text, attachments) {
const body = { text }
if (attachments.length) body.attachments = attachments
if (draft.model) body.model = draft.model
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
const agents = draft.agents
if (agents && (agents.min || agents.max)) body.agents = agents
if (draft.kind === "agent") {
const device = draftDevice()
if (draftHere()) {
if (!device || !agentReady(ctx.status())) {
renderNotice()
throw { detail: agentState(ctx.status()).sentence }
}
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
} else {
if (!deviceOf(device)) throw { detail: t("This device is not linked right now.") }
if (!draft.dir) throw { detail: t("Choose a folder first.") }
}
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
}
const r = await post("/chats", draft.kind === "chat" ? takePicture(body) : body)
pictureSent()
draft = null
$("new-chat").hidden = true
$("main").dataset.new = "false"
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))
ctx.changed(r.chat)
ctx.opened(r.chat)
if (r.reply) follower(r.chat, r.reply.id)
}
// --- The composer ------------------------------------------------------------------------------------
function showComposer(on) {
$("composer").hidden = !on
}
async function submit() {
const box = $("composer-text")
const text = box.value.trim()
if (sending()) {
alertIn(t("Wait until the files are sent."))
return
}
const files = readyIds()
if (!text && !files.length) return
$("send").disabled = true
alertIn("")
try {
if (draft) await createChat(text, files)
else if (open?.chat?.id) {
let body = { text }
if (files.length) body.attachments = files
if (open.chat.kind === "chat") body = takePicture(body)
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
pictureSent()
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
} else return
sent()
box.value = ""
grow()
toBottom(true)
} catch (err) {
alertIn(why(err))
} finally {
$("send").disabled = false
setRunning(!!open?.live)
}
}
function grow() {
const box = $("composer-text")
box.style.height = "auto"
box.style.height = `${Math.min(box.scrollHeight, window.innerHeight * 0.4)}px`
}
export function initChat(context) {
ctx = context
$("send").replaceChildren(icon(ICONS.send))
$("stop").replaceChildren(icon(ICONS.stop))
$("composer").addEventListener("submit", (e) => {
e.preventDefault()
submit()
})
$("composer-text").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault()
submit()
}
})
$("composer-text").addEventListener("input", () => {
grow()
if (open?.live) $("send").hidden = !$("composer-text").value.trim()
})
$("stop").addEventListener("click", async () => {
const live = open?.live
if (!live) return
try {
await api("POST", `/chats/${encodeURIComponent(live.chat.id)}/messages/${encodeURIComponent(live.id)}/stop`)
} catch (err) {
alertIn(why(err))
}
})
onAgentChange(renderNotice)
showComposer(false)
}