The app's own interface: agent chats first, two explorers, a viewer
Plain HTML, CSS and ES modules in ui/, no build step. The sidebar opens on agent chats (this computer first), with chats on the server as the second tab; the transcript draws the /api/v2 steps, follows a reply as it is written, answers cards, and starts agent chats in one of the agent's folders. Two explorers kept apart (this computer's, the server's) and a viewer: Markdown drawn by a safe renderer that never parses HTML, code in read-only CodeMirror with the server's token colours, HTML in the sealed frame, show calls opened in it. Settings for the sign-in, the agent and its folders, notifications and updates; no admin pages. Layout on fixed and shared tracks; the sidebar is a drawer below 48rem, the viewer goes over the chat below 64rem. All 124 sentences in the server's nine languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
20 files changed
+4275
-758
No files matched your search
@@ -0,0 +1,70 @@
|
||||
// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
|
||||
// makes no request of its own -- the content security policy allows it none but the bridge. The
|
||||
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
|
||||
|
||||
const tauri = window.__TAURI__
|
||||
export const invoke = tauri.core.invoke
|
||||
export const listen = tauri.event.listen
|
||||
export const win = tauri.window.getCurrentWindow()
|
||||
|
||||
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
|
||||
export function api(method, path, body) {
|
||||
return invoke("api", { method, path, body: body === undefined ? null : body })
|
||||
}
|
||||
export const get = (path) => api("GET", path)
|
||||
export const post = (path, body) => api("POST", path, body ?? {})
|
||||
export const patch = (path, body) => api("PATCH", path, body)
|
||||
export const del = (path) => api("DELETE", path)
|
||||
|
||||
/** An error's words, whatever shape it came in. */
|
||||
export function why(err) {
|
||||
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
|
||||
return String(err)
|
||||
}
|
||||
|
||||
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
|
||||
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
|
||||
const followers = new Map()
|
||||
const early = new Map()
|
||||
listen("stream", (e) => {
|
||||
const { id, event, data } = e.payload
|
||||
const f = followers.get(id)
|
||||
if (f) deliver(f, id, event, data)
|
||||
else {
|
||||
if (!early.has(id)) early.set(id, [])
|
||||
early.get(id).push([event, data])
|
||||
}
|
||||
})
|
||||
|
||||
function deliver(f, id, event, data) {
|
||||
if (event === "end") {
|
||||
followers.delete(id)
|
||||
f.onEnd(data)
|
||||
} else f.onFrame(event, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
|
||||
* `close()` stops it (onEnd still comes).
|
||||
*/
|
||||
export async function follow(path, { onFrame, onEnd }) {
|
||||
const id = await invoke("stream_open", { path })
|
||||
const f = { onFrame, onEnd }
|
||||
followers.set(id, f)
|
||||
for (const [event, data] of early.get(id) || []) {
|
||||
if (!followers.has(id)) break
|
||||
deliver(f, id, event, data)
|
||||
}
|
||||
early.delete(id)
|
||||
return {
|
||||
id,
|
||||
close() {
|
||||
invoke("stream_close", { id })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A web link, in the system's browser. */
|
||||
export function openLink(url) {
|
||||
return invoke("open_external", { url }).catch(() => {})
|
||||
}
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
// One chat: its transcript, the reply being written (followed as the server streams it), the
|
||||
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
|
||||
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
|
||||
//
|
||||
// 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 } from "./dom.js"
|
||||
import { api, get, post, patch, follow, why, invoke } from "./bridge.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
import { openViewer, showKind } from "./viewer.js"
|
||||
import { readLocal, readServer } from "./explorer.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
|
||||
let open = null // { chat, live, more, first }
|
||||
|
||||
export function openChatId() {
|
||||
return open?.chat?.id || null
|
||||
}
|
||||
|
||||
// --- Reading -----------------------------------------------------------------------------------
|
||||
|
||||
function onThisComputer(chat) {
|
||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function userMessage(m) {
|
||||
return h(
|
||||
"article",
|
||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "msg__text", text: m.text || "" }),
|
||||
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
|
||||
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
if (meta || m.model) {
|
||||
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : 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", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
|
||||
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" })
|
||||
}
|
||||
}
|
||||
|
||||
function shownPath(call) {
|
||||
return call.show?.path || call.args?.path || call.path || ""
|
||||
}
|
||||
|
||||
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 && shownPath(c)
|
||||
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__title", title: block.title || c.name, text: block.title || c.name || "" }),
|
||||
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
|
||||
)
|
||||
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 path = shownPath(call)
|
||||
const kind = call.show?.kind
|
||||
const { language } = showKind(path)
|
||||
const line = call.show?.line || call.args?.line || 0
|
||||
const base = { name: path.split("/").pop(), path, kind, language: call.show?.language || language, line }
|
||||
try {
|
||||
if (onThisComputer(chat) && chat.device.directory) {
|
||||
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 if (call.show?.shown_id) {
|
||||
// The copy a device sent (server: `GET /api/chats/{chat}/shown/{id}` is the web UI's; the
|
||||
// app's door does not have it yet -- see SERVER_FILES in explorer.js).
|
||||
const f = await get(`/chats/${encodeURIComponent(chat.id)}/shown/${encodeURIComponent(call.show.shown_id)}`)
|
||||
openViewer({ ...base, where: t("Shown by the agent"), text: f.text })
|
||||
} else {
|
||||
const f = await readServer(path)
|
||||
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text: f.text, withheld: f.withheld, size: f.size })
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
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.handle?.close()
|
||||
}
|
||||
|
||||
frame(event, data) {
|
||||
if (this.done) return
|
||||
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.status.textContent = data.text || t("Writing…")
|
||||
break
|
||||
case "ask":
|
||||
renderCards(this.chat, data.cards || [])
|
||||
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.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
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 ----------------------------------------------------------------------------------------
|
||||
|
||||
function renderCards(chat, cards) {
|
||||
const box = clear($("cards"))
|
||||
box.hidden = !cards.length
|
||||
for (const card of cards) box.append(cardOf(chat, card))
|
||||
}
|
||||
|
||||
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" })
|
||||
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||
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) {
|
||||
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
|
||||
edit.addEventListener("input", () => {
|
||||
if (edit.value !== item.title) answers.texts[item.key] = edit.value
|
||||
else delete answers.texts[item.key]
|
||||
})
|
||||
part.append(edit)
|
||||
} else part.append(h("pre", { class: "call__pre", text: item.title }))
|
||||
} 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) part.append(h("p", { class: "muted", text: item.detail }))
|
||||
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
|
||||
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
|
||||
}
|
||||
|
||||
function planList(plan) {
|
||||
return h(
|
||||
"ol",
|
||||
{ class: "plan" },
|
||||
(plan.phases || []).map((p) =>
|
||||
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Opening a chat --------------------------------------------------------------------------------
|
||||
|
||||
function setTitle(title) {
|
||||
$("chat-title").textContent = title || t("New chat")
|
||||
$("chat-title").title = title || ""
|
||||
}
|
||||
|
||||
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: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
|
||||
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
|
||||
)
|
||||
const mode = h(
|
||||
"select",
|
||||
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
|
||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
||||
)
|
||||
tools.append(mode)
|
||||
} else if (chat.model) {
|
||||
place.append(h("span", { class: "chip", text: chat.model }))
|
||||
}
|
||||
}
|
||||
|
||||
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?.live) open.live.stop()
|
||||
open = { chat: { id }, live: null, more: false }
|
||||
invoke("set_open_chat", { chat: id })
|
||||
showComposer(true)
|
||||
$("new-chat").hidden = true
|
||||
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
|
||||
renderHead(r.chat)
|
||||
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
|
||||
invoke("set_open_chat", { chat: null })
|
||||
}
|
||||
|
||||
// --- A new chat ------------------------------------------------------------------------------------
|
||||
|
||||
let draft = null // { kind }
|
||||
|
||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
||||
export function newChat(kind) {
|
||||
closeChat()
|
||||
draft = { kind }
|
||||
clear($("messages"))
|
||||
renderCards({ id: "" }, [])
|
||||
alertIn("")
|
||||
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
|
||||
clear($("chat-place"))
|
||||
clear($("chat-tools"))
|
||||
const form = clear($("new-chat"))
|
||||
form.hidden = false
|
||||
const s = ctx.status()
|
||||
const models = ctx.models()
|
||||
const modelField = field(
|
||||
t("Model"),
|
||||
h(
|
||||
"select",
|
||||
{ id: "new-model" },
|
||||
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
|
||||
),
|
||||
models.length ? "" : t("No model is available to this account."),
|
||||
)
|
||||
if (kind === "agent") {
|
||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
||||
const devices = ctx.thisDevices()
|
||||
form.append(
|
||||
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
|
||||
)
|
||||
if (!devices.length) {
|
||||
form.append(
|
||||
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
|
||||
)
|
||||
}
|
||||
form.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "fields" },
|
||||
field(
|
||||
t("Folder"),
|
||||
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
|
||||
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
|
||||
),
|
||||
field(
|
||||
t("Mode"),
|
||||
h(
|
||||
"select",
|
||||
{ id: "new-mode" },
|
||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
|
||||
),
|
||||
t("A chat asking for more than the folder allows is lowered to it."),
|
||||
),
|
||||
modelField,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
form.append(h("div", { class: "fields" }, modelField))
|
||||
}
|
||||
showComposer(true)
|
||||
setRunning(false)
|
||||
invoke("set_open_chat", { chat: null })
|
||||
$("composer-text").focus()
|
||||
}
|
||||
|
||||
/** A field: label, control, hint -- always all three, so a row of them lines up. */
|
||||
function field(label, control, hint) {
|
||||
const id = control.id || `f${Math.random().toString(36).slice(2)}`
|
||||
control.id = id
|
||||
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
|
||||
}
|
||||
|
||||
async function createChat(text) {
|
||||
const body = { text }
|
||||
const model = $("new-model")?.value
|
||||
if (model) body.model = model
|
||||
if (draft.kind === "agent") {
|
||||
const device = ctx.thisDevices()[0]
|
||||
const dir = $("new-dir")?.value
|
||||
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
|
||||
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
|
||||
}
|
||||
const r = await post("/chats", body)
|
||||
draft = null
|
||||
$("new-chat").hidden = true
|
||||
open = { chat: r.chat, live: null, more: false }
|
||||
invoke("set_open_chat", { chat: r.chat.id })
|
||||
renderHead(r.chat)
|
||||
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 (!text) return
|
||||
$("send").disabled = true
|
||||
alertIn("")
|
||||
try {
|
||||
if (draft) await createChat(text)
|
||||
else if (open?.chat?.id) {
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
|
||||
if (r.message) $("messages").append(userMessage(r.message))
|
||||
if (r.reply) follower(open.chat, r.reply.id)
|
||||
}
|
||||
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
|
||||
$("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))
|
||||
}
|
||||
})
|
||||
showComposer(false)
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Source in the viewer: CodeMirror, read-only, from the server's own vendored build (ui/vendor,
|
||||
// copied and pinned by scripts/fetch-vendor.sh). The look is the server's editor's: colours from
|
||||
// the theme's tokens, never CodeMirror's own. Loaded the first time a file is shown.
|
||||
|
||||
let cm = null
|
||||
|
||||
async function load() {
|
||||
if (cm) return cm
|
||||
const [state, view, language, search, commands, highlight] = await Promise.all([
|
||||
import("../vendor/cm-state.mjs"),
|
||||
import("../vendor/cm-view.mjs"),
|
||||
import("../vendor/cm-language.mjs"),
|
||||
import("../vendor/cm-search.mjs"),
|
||||
import("../vendor/cm-commands.mjs"),
|
||||
import("../vendor/lezer-highlight.mjs"),
|
||||
])
|
||||
const { EditorView } = view
|
||||
const { HighlightStyle, syntaxHighlighting } = language
|
||||
const { tags } = highlight
|
||||
// As the server's static/js/editor.js has them.
|
||||
const look = EditorView.theme({
|
||||
"&": { color: "var(--ink)", backgroundColor: "var(--code-bg)", fontSize: "var(--text-sm)", height: "100%" },
|
||||
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "var(--leading-normal)" },
|
||||
".cm-content": { caretColor: "var(--accent)" },
|
||||
".cm-gutters": { backgroundColor: "var(--code-bg)", color: "var(--ink-faint)", border: "none" },
|
||||
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--surface-hover)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--selection)" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-panels": { backgroundColor: "var(--surface)", color: "var(--ink)" },
|
||||
".cm-searchMatch": { backgroundColor: "var(--accent-soft)" },
|
||||
})
|
||||
const colours = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: "var(--accent)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "var(--leaf)" },
|
||||
{ tag: [tags.comment], color: "var(--ink-faint)", fontStyle: "italic" },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--warning)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: "var(--ink)", fontWeight: "600" },
|
||||
{ tag: [tags.heading], color: "var(--accent)", fontWeight: "700" },
|
||||
{ tag: [tags.link, tags.url], color: "var(--accent)", textDecoration: "underline" },
|
||||
{ tag: [tags.tagName, tags.typeName, tags.className], color: "var(--accent-hover)" },
|
||||
{ tag: [tags.invalid], color: "var(--danger)" },
|
||||
])
|
||||
cm = {
|
||||
state,
|
||||
view,
|
||||
base: [
|
||||
view.lineNumbers(),
|
||||
view.highlightActiveLine(),
|
||||
view.highlightActiveLineGutter(),
|
||||
view.drawSelection(),
|
||||
view.keymap.of([...search.searchKeymap, ...commands.defaultKeymap]),
|
||||
search.highlightSelectionMatches(),
|
||||
state.EditorState.readOnly.of(true),
|
||||
view.EditorView.editable.of(false),
|
||||
view.EditorView.lineWrapping,
|
||||
look,
|
||||
syntaxHighlighting(colours),
|
||||
],
|
||||
}
|
||||
return cm
|
||||
}
|
||||
|
||||
/** The language support for a name as `show` gives it; nothing for one without support. */
|
||||
async function language(name) {
|
||||
try {
|
||||
switch (name) {
|
||||
case "markdown":
|
||||
return (await import("../vendor/cm-lang-markdown.mjs")).markdown()
|
||||
case "python":
|
||||
return (await import("../vendor/cm-lang-python.mjs")).python()
|
||||
case "javascript":
|
||||
case "jsx":
|
||||
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ jsx: name === "jsx" })
|
||||
case "typescript":
|
||||
case "tsx":
|
||||
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ typescript: true, jsx: name === "tsx" })
|
||||
case "html":
|
||||
return (await import("../vendor/cm-lang-html.mjs")).html()
|
||||
case "css":
|
||||
case "scss":
|
||||
return (await import("../vendor/cm-lang-css.mjs")).css()
|
||||
case "json":
|
||||
return (await import("../vendor/cm-lang-json.mjs")).json()
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Show `text` in `parent`, read-only, at `line` when given. Returns something to `destroy()`. */
|
||||
export async function codeView(parent, text, { lang = "", line = 0 } = {}) {
|
||||
const { state, view, base } = await load()
|
||||
const ed = new view.EditorView({
|
||||
parent,
|
||||
state: state.EditorState.create({ doc: text, extensions: [base, await language(lang)] }),
|
||||
})
|
||||
if (line > 0 && line <= ed.state.doc.lines) {
|
||||
const at = ed.state.doc.line(line).from
|
||||
ed.dispatch({ selection: { anchor: at }, effects: view.EditorView.scrollIntoView(at, { y: "center" }) })
|
||||
}
|
||||
return ed
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Building the page: elements made in code, text always as text. Nothing here ever parses a
|
||||
// string as HTML -- what the server sends (a reply, a file, a title) is model output or the
|
||||
// person's own words, and is set with textContent, never innerHTML.
|
||||
|
||||
export const $ = (id) => document.getElementById(id)
|
||||
|
||||
/**
|
||||
* h("button", { class: "btn", onclick: fn, "aria-label": "…" }, "text", child, [more])
|
||||
* Attributes: `class`, `text`, `hidden`, `on<event>` (a function), `dataset` (an object), any
|
||||
* other as an attribute (false/null/undefined leave it out, true sets it empty).
|
||||
*/
|
||||
export function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag)
|
||||
for (const [key, value] of Object.entries(attrs || {})) {
|
||||
if (value === null || value === undefined || value === false) continue
|
||||
if (key === "class") el.className = value
|
||||
else if (key === "text") el.textContent = value
|
||||
else if (key === "dataset") Object.assign(el.dataset, value)
|
||||
else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value)
|
||||
else if (key === "hidden") el.hidden = !!value
|
||||
else if (key === "value") el.value = value
|
||||
else if (key === "checked") el.checked = !!value
|
||||
else if (key === "disabled") el.disabled = !!value
|
||||
else el.setAttribute(key, value === true ? "" : String(value))
|
||||
}
|
||||
append(el, children)
|
||||
return el
|
||||
}
|
||||
|
||||
export function append(el, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c === null || c === undefined || c === false) continue
|
||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
export function clear(el) {
|
||||
el.replaceChildren()
|
||||
return el
|
||||
}
|
||||
|
||||
/** An inline SVG icon from a path list, drawn in currentColor. */
|
||||
export function icon(d, label) {
|
||||
const ns = "http://www.w3.org/2000/svg"
|
||||
const svg = document.createElementNS(ns, "svg")
|
||||
svg.setAttribute("viewBox", "0 0 16 16")
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.classList.add("icon")
|
||||
for (const one of [].concat(d)) {
|
||||
const p = document.createElementNS(ns, "path")
|
||||
p.setAttribute("d", one)
|
||||
p.setAttribute("fill-rule", "evenodd")
|
||||
svg.append(p)
|
||||
}
|
||||
if (label) {
|
||||
svg.removeAttribute("aria-hidden")
|
||||
svg.setAttribute("role", "img")
|
||||
svg.setAttribute("aria-label", label)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
|
||||
export const ICONS = {
|
||||
plus: "M7.25 3h1.5v4.25H13v1.5H8.75V13h-1.5V8.75H3v-1.5h4.25z",
|
||||
close: "M4.5 3.4L8 6.9l3.5-3.5 1.1 1.1L9.1 8l3.5 3.5-1.1 1.1L8 9.1l-3.5 3.5-1.1-1.1L6.9 8 3.4 4.5z",
|
||||
folder: "M1.5 3h5l1.5 1.5h6.5v8.5h-13zM3 6v5.5h10V6z",
|
||||
file: "M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z",
|
||||
chevron: "M6 3.5L10.5 8 6 12.5 4.9 11.4 8.3 8 4.9 4.6z",
|
||||
menu: "M3 4h10v1.5H3zM3 7.25h10v1.5H3zM3 10.5h10V12H3z",
|
||||
side: "M2 3h12v10H2zM3.5 4.5v7H6v-7z",
|
||||
gear: "M7 1.5h2l.4 1.8 1.1.5 1.6-1 1.4 1.4-1 1.6.5 1.1 1.8.4v2l-1.8.4-.5 1.1 1 1.6-1.4 1.4-1.6-1-1.1.5L9 14.5H7l-.4-1.8-1.1-.5-1.6 1-1.4-1.4 1-1.6-.5-1.1-1.8-.4v-2l1.8-.4.5-1.1-1-1.6 1.4-1.4 1.6 1 1.1-.5zM8 5.75a2.25 2.25 0 100 4.5 2.25 2.25 0 000-4.5z",
|
||||
refresh: "M8 2.5a5.5 5.5 0 015.2 3.7H11.5v1.5H15V4.2h-1.5v1A7 7 0 001 8h1.5A5.5 5.5 0 018 2.5zM2.5 11.8v-1h1.7A5.5 5.5 0 0013.5 8H15A7 7 0 012.5 11.8z",
|
||||
min: "M4 10h8v1.5H4z",
|
||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||
}
|
||||
|
||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||
export function when(stamp) {
|
||||
if (!stamp) return ""
|
||||
const then = new Date(stamp)
|
||||
if (Number.isNaN(then.getTime())) return ""
|
||||
const seconds = Math.round((then.getTime() - Date.now()) / 1000)
|
||||
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
|
||||
const steps = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.35, "week"],
|
||||
[12, "month"],
|
||||
[Infinity, "year"],
|
||||
]
|
||||
let value = seconds
|
||||
for (const [size, unit] of steps) {
|
||||
if (Math.abs(value) < size) return rtf.format(Math.round(value), unit)
|
||||
value /= size
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** A size in bytes, as a person reads it. */
|
||||
export function bytes(n) {
|
||||
if (!Number.isFinite(n)) return ""
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i += 1
|
||||
}
|
||||
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
|
||||
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the server's
|
||||
// tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, get, why } from "./bridge.js"
|
||||
import { openViewer } from "./viewer.js"
|
||||
|
||||
// The server's tree as the app's door gives it. `/api/v2` has no file routes yet (the web UI's
|
||||
// /files pages take the session cookie and answer HTML); these are the routes this pane is written
|
||||
// against, and a 404 says "not on this server yet" instead of failing:
|
||||
// GET /files?path=/a -> { path, entries: [{ name, path, kind: "dir"|"file", size, mime }] }
|
||||
// GET /files/content?path= -> { path, name, size, text | null, withheld?: "binary"|"too_big" }
|
||||
export const SERVER_FILES = {
|
||||
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
||||
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
|
||||
}
|
||||
|
||||
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
||||
function tree(box, { list, open, rootPath }) {
|
||||
clear(box)
|
||||
const ul = h("ul", { class: "tree", role: "tree" })
|
||||
box.append(ul)
|
||||
const level = async (parent, path, depth) => {
|
||||
let entries
|
||||
try {
|
||||
entries = await list(path)
|
||||
} catch (err) {
|
||||
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
|
||||
return
|
||||
}
|
||||
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
|
||||
for (const e of entries) {
|
||||
const row = h(
|
||||
"button",
|
||||
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": e.kind === "dir" ? "false" : null },
|
||||
h("span", { class: "tree__chevron" }, e.kind === "dir" ? icon(ICONS.chevron) : null),
|
||||
icon(e.kind === "dir" ? ICONS.folder : ICONS.file),
|
||||
h("span", { class: "tree__name", text: e.name }),
|
||||
h("span", { class: "tree__size", text: e.kind === "dir" ? "" : bytes(e.size) }),
|
||||
)
|
||||
row.style.setProperty("--depth", String(depth))
|
||||
const li = h("li", { role: "treeitem" }, row)
|
||||
if (e.kind === "dir") {
|
||||
let kids = null
|
||||
row.addEventListener("click", async () => {
|
||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
||||
row.setAttribute("aria-expanded", String(opening))
|
||||
if (!opening) {
|
||||
kids?.remove()
|
||||
kids = null
|
||||
return
|
||||
}
|
||||
kids = h("ul", { role: "group" })
|
||||
li.append(kids)
|
||||
await level(kids, e.path, depth + 1)
|
||||
})
|
||||
} else row.addEventListener("click", () => open(e))
|
||||
parent.append(li)
|
||||
}
|
||||
}
|
||||
return level(ul, rootPath, 0)
|
||||
}
|
||||
|
||||
// --- This computer ------------------------------------------------------------------------------
|
||||
|
||||
let local = { root: "", roots: [], fixed: false }
|
||||
|
||||
/**
|
||||
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
|
||||
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
|
||||
*/
|
||||
export function showLocal({ roots, root, fixed }) {
|
||||
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
|
||||
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
|
||||
if (changed) renderLocal()
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
const head = clear($("local-where"))
|
||||
const box = $("local-tree")
|
||||
if (!local.roots.length) {
|
||||
clear(box).append(
|
||||
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (local.fixed) {
|
||||
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
|
||||
} else {
|
||||
const pick = h(
|
||||
"select",
|
||||
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
|
||||
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
|
||||
)
|
||||
head.append(pick)
|
||||
}
|
||||
const root = local.root
|
||||
tree(box, {
|
||||
rootPath: "",
|
||||
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
||||
open: async (e) => {
|
||||
const where = t("This computer")
|
||||
try {
|
||||
const f = await invoke("local_read", { root, path: e.path })
|
||||
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
|
||||
} catch (err) {
|
||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
|
||||
export async function readLocal(root, path) {
|
||||
return invoke("local_read", { root, path })
|
||||
}
|
||||
|
||||
// --- The server ---------------------------------------------------------------------------------
|
||||
|
||||
export async function readServer(path) {
|
||||
return get(SERVER_FILES.read(path))
|
||||
}
|
||||
|
||||
export function renderServer() {
|
||||
const box = $("server-tree")
|
||||
tree(box, {
|
||||
rootPath: "/",
|
||||
list: async (path) => {
|
||||
try {
|
||||
const r = await get(SERVER_FILES.list(path))
|
||||
return r.entries || []
|
||||
} catch (err) {
|
||||
if (err && (err.status === 404 || err.status === 405)) {
|
||||
throw { detail: t("This server does not offer its files to the app yet.") }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
open: async (e) => {
|
||||
const where = t("Server")
|
||||
try {
|
||||
const f = await readServer(e.path)
|
||||
openViewer({ name: f.name || e.name, path: f.path || e.path, where, text: f.text, withheld: f.withheld, size: f.size })
|
||||
} catch (err) {
|
||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function initExplorer() {
|
||||
for (const pane of document.querySelectorAll(".pane")) {
|
||||
const toggle = pane.querySelector(".pane__toggle")
|
||||
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
|
||||
toggle.addEventListener("click", () => {
|
||||
const open = pane.dataset.open !== "true"
|
||||
pane.dataset.open = String(open)
|
||||
toggle.setAttribute("aria-expanded", String(open))
|
||||
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
|
||||
})
|
||||
}
|
||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("server-refresh").addEventListener("click", renderServer)
|
||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("local-refresh").addEventListener("click", renderLocal)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
|
||||
// for) or chats on the server. Agent chats on this computer come first; those on another device
|
||||
// of the account follow under their own heading.
|
||||
|
||||
import { $, h, clear, when, append } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current() }
|
||||
let rows = []
|
||||
let more = false
|
||||
|
||||
/** The list again, from the server. */
|
||||
export async function refreshList() {
|
||||
const kind = ctx.kind()
|
||||
try {
|
||||
const r = await get(`/chats?kind=${kind}&limit=50`)
|
||||
if (ctx.kind() !== kind) return
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
render()
|
||||
} catch (err) {
|
||||
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
const kind = ctx.kind()
|
||||
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
if (ctx.kind() !== kind) return
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
render()
|
||||
}
|
||||
|
||||
export function markCurrent() {
|
||||
const id = ctx.current()
|
||||
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||
}
|
||||
|
||||
function row(c) {
|
||||
const b = h(
|
||||
"button",
|
||||
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
|
||||
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "row__title", text: c.title || t("New chat") }),
|
||||
h("span", { class: "row__when", text: when(c.updated_at) }),
|
||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
||||
)
|
||||
b.addEventListener("click", () => ctx.open(c.id))
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = clear($("chat-list"))
|
||||
const kind = ctx.kind()
|
||||
const visible = rows.filter((r) => !r.archived && !r.temporary)
|
||||
if (!visible.length) {
|
||||
list.append(
|
||||
h("li", {
|
||||
class: "list__note",
|
||||
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (kind === "agent") {
|
||||
const mine = ctx.thisDevices()
|
||||
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
|
||||
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
|
||||
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
|
||||
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
|
||||
} else append(list, visible.map(row))
|
||||
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||
markCurrent()
|
||||
}
|
||||
|
||||
export function initList(context) {
|
||||
ctx = context
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
|
||||
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
|
||||
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
|
||||
// `api` (the server's /api/v2, as the signed-in account).
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen, get, why } from "./bridge.js"
|
||||
import { renderBar, initBar } from "./titlebar.js"
|
||||
import { renderSetup, initSetup } from "./setup.js"
|
||||
import { renderSettings, initSettings } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
let me = null
|
||||
let models = []
|
||||
let devices = [] // this account's linked devices
|
||||
let kind = "agent"
|
||||
let started = false
|
||||
let currentChat = null
|
||||
|
||||
const remembered = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* The device ids that are this computer: a linked device whose host is this computer's name (the
|
||||
* CLI sends it at login). Remembered, so agent chats here are still known as here while the
|
||||
* agent is off and the device is not linked right now.
|
||||
*/
|
||||
function thisDevices() {
|
||||
let changed = false
|
||||
for (const d of devices) {
|
||||
if (status?.host && d.host === status.host && !remembered.includes(d.id)) {
|
||||
remembered.push(d.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
remembered.splice(0, Math.max(0, remembered.length - 8))
|
||||
try {
|
||||
localStorage.setItem("lembas.thisDevices", JSON.stringify(remembered))
|
||||
} catch {}
|
||||
}
|
||||
return remembered.slice()
|
||||
}
|
||||
|
||||
function applyTheme(name) {
|
||||
if (name !== "moria" && name !== "shire") name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
}
|
||||
|
||||
// --- Views ------------------------------------------------------------------------------------
|
||||
|
||||
function route() {
|
||||
const ready = status.checked && status.discovery && !status.refused && status.account
|
||||
const want = ready ? "app" : "setup"
|
||||
if (want !== view) {
|
||||
view = want
|
||||
$("setup").hidden = want !== "setup"
|
||||
$("shell").hidden = want !== "app"
|
||||
if (want === "app") startApp()
|
||||
else stopApp()
|
||||
}
|
||||
if (view === "setup") renderSetup(status)
|
||||
if (view === "app") {
|
||||
if (!$("settings").hidden) renderSettings(status)
|
||||
localPane()
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
started = true
|
||||
try {
|
||||
me = await get("/me")
|
||||
applyTheme(me.theme)
|
||||
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
|
||||
} catch (err) {
|
||||
$("thread-note").textContent = why(err)
|
||||
$("thread-note").hidden = false
|
||||
}
|
||||
await Promise.all([loadModels(), loadDevices()])
|
||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
||||
setKind(kind)
|
||||
renderServer()
|
||||
invoke("remote_refresh")
|
||||
}
|
||||
|
||||
function stopApp() {
|
||||
started = false
|
||||
closeChat()
|
||||
closeViewer()
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
models = (await get("/models")).models || []
|
||||
} catch {
|
||||
models = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices = (await get("/devices")).devices || []
|
||||
} catch {
|
||||
devices = []
|
||||
}
|
||||
}
|
||||
|
||||
// --- The sidebar --------------------------------------------------------------------------------
|
||||
|
||||
function setKind(k) {
|
||||
kind = k
|
||||
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
|
||||
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
|
||||
clear($("chat-list"))
|
||||
refreshList()
|
||||
// With no chat open, the main view is where a chat of this kind starts.
|
||||
if (!openChatId() && $("settings").hidden) {
|
||||
currentChat = null
|
||||
newChat(k)
|
||||
localPane()
|
||||
}
|
||||
}
|
||||
|
||||
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
|
||||
function localPane(chat) {
|
||||
const roots = status?.remote?.enabled ? status.remote.roots : []
|
||||
const c = chat || currentChat
|
||||
const here = c?.device && thisDevices().includes(c.device.token_id)
|
||||
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
|
||||
else showLocal({ roots, root: "", fixed: false })
|
||||
}
|
||||
|
||||
function narrow() {
|
||||
return window.matchMedia("(max-width: 48rem)").matches
|
||||
}
|
||||
|
||||
function sideOpen(on) {
|
||||
$("shell").dataset.side = String(on)
|
||||
$("side-toggle").setAttribute("aria-expanded", String(on))
|
||||
}
|
||||
|
||||
// --- Settings -----------------------------------------------------------------------------------
|
||||
|
||||
function showSettings(on) {
|
||||
$("settings").hidden = !on
|
||||
$("main").hidden = on
|
||||
if (on) {
|
||||
renderSettings(status)
|
||||
invoke("remote_refresh")
|
||||
$("settings-close").focus()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Start --------------------------------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
initBar()
|
||||
initViewer()
|
||||
initExplorer()
|
||||
initSetup(() => status)
|
||||
initSettings(() => status)
|
||||
initList({
|
||||
kind: () => kind,
|
||||
thisDevices,
|
||||
current: () => openChatId(),
|
||||
open: (id) => {
|
||||
showSettings(false)
|
||||
if (narrow()) sideOpen(false)
|
||||
openChat(id).then(markCurrent)
|
||||
},
|
||||
})
|
||||
initChat({
|
||||
status: () => status,
|
||||
thisDevices,
|
||||
models: () => models,
|
||||
me: () => me,
|
||||
changed: () => refreshList(),
|
||||
opened: (chat) => {
|
||||
currentChat = chat
|
||||
localPane(chat)
|
||||
markCurrent()
|
||||
},
|
||||
})
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
|
||||
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
|
||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||
$("new-chat-btn").addEventListener("click", () => {
|
||||
showSettings(false)
|
||||
currentChat = null
|
||||
if (narrow()) sideOpen(false)
|
||||
newChat(kind)
|
||||
markCurrent()
|
||||
localPane()
|
||||
})
|
||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
$("settings-close").addEventListener("click", () => showSettings(false))
|
||||
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
||||
})
|
||||
|
||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
|
||||
listen("status", (e) => {
|
||||
const before = status
|
||||
status = e.payload
|
||||
renderBar(status)
|
||||
route()
|
||||
// The agent came up or went: which devices are linked changes with it.
|
||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
|
||||
})
|
||||
listen("open-settings", () => {
|
||||
if (view === "app") showSettings(true)
|
||||
})
|
||||
status = await invoke("get_status")
|
||||
applyTheme(status.theme)
|
||||
renderBar(status)
|
||||
route()
|
||||
// The list keeps up with replies finished elsewhere (the web interface, the phone).
|
||||
setInterval(() => {
|
||||
if (view === "app" && document.visibilityState === "visible") refreshList()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
boot()
|
||||
@@ -0,0 +1,347 @@
|
||||
// Markdown, as a model writes it, to elements -- never to an HTML string. A reply is model
|
||||
// output and untrusted: no raw HTML passes (it shows as the text it is), no image is fetched
|
||||
// (an image is its link), and a link opens in the system's browser only when it is http, https
|
||||
// or mail. What it does draw: headings, paragraphs, emphasis, strike-through, inline code, fenced
|
||||
// code, block quotes, lists (nested), tables, rules, links and line breaks. A reply still being
|
||||
// written renders the same way; an unclosed `*` is simply a star.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
import { openLink } from "./bridge.js"
|
||||
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
|
||||
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?\s*#*\s*$/
|
||||
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
|
||||
const QUOTE = /^ {0,3}> ?/
|
||||
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
|
||||
const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
|
||||
const SAFE_LINK = /^(https?:|mailto:)/i
|
||||
|
||||
/** The rendered fragment of `source`. */
|
||||
export function markdown(source) {
|
||||
const out = document.createDocumentFragment()
|
||||
blocks(String(source ?? "").replace(/\r\n?/g, "\n").split("\n"), out)
|
||||
return out
|
||||
}
|
||||
|
||||
function indentOf(line) {
|
||||
const m = /^\s*/.exec(line)
|
||||
return m ? m[0].replace(/\t/g, " ").length : 0
|
||||
}
|
||||
|
||||
function startsBlock(line, next) {
|
||||
return (
|
||||
FENCE.test(line) ||
|
||||
HEADING.test(line) ||
|
||||
RULE.test(line) ||
|
||||
QUOTE.test(line) ||
|
||||
ITEM.test(line) ||
|
||||
(line.includes("|") && next !== undefined && TABLE_RULE.test(next) && next.includes("-"))
|
||||
)
|
||||
}
|
||||
|
||||
function blocks(lines, parent) {
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line.trim()) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const fence = FENCE.exec(line)
|
||||
if (fence) {
|
||||
const mark = fence[1]
|
||||
const body = []
|
||||
i += 1
|
||||
while (i < lines.length && !new RegExp(`^ {0,3}${mark[0]}{${mark.length},}\\s*$`).test(lines[i])) {
|
||||
body.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
i += 1 // the closing fence (or the end)
|
||||
parent.append(codeBlock(body.join("\n"), fence[2]))
|
||||
continue
|
||||
}
|
||||
const heading = HEADING.exec(line)
|
||||
if (heading) {
|
||||
parent.append(h(`h${heading[1].length}`, null, inline(heading[2] || "")))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (RULE.test(line)) {
|
||||
parent.append(h("hr"))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (QUOTE.test(line)) {
|
||||
const inner = []
|
||||
while (i < lines.length && lines[i].trim() && (QUOTE.test(lines[i]) || !startsBlock(lines[i], lines[i + 1]))) {
|
||||
inner.push(lines[i].replace(QUOTE, ""))
|
||||
i += 1
|
||||
}
|
||||
const quote = h("blockquote")
|
||||
blocks(inner, quote)
|
||||
parent.append(quote)
|
||||
continue
|
||||
}
|
||||
if (ITEM.test(line)) {
|
||||
i = list(lines, i, parent)
|
||||
continue
|
||||
}
|
||||
if (line.includes("|") && TABLE_RULE.test(lines[i + 1] || "") && (lines[i + 1] || "").includes("-")) {
|
||||
i = table(lines, i, parent)
|
||||
continue
|
||||
}
|
||||
const para = [line]
|
||||
i += 1
|
||||
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
|
||||
para.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
parent.append(h("p", null, inline(para.join("\n"))))
|
||||
}
|
||||
}
|
||||
|
||||
function codeBlock(text, lang) {
|
||||
const code = h("code", { text })
|
||||
const copy = h("button", {
|
||||
type: "button",
|
||||
class: "md-copy",
|
||||
text: t("Copy"),
|
||||
onclick: () => navigator.clipboard?.writeText(text).catch(() => {}),
|
||||
})
|
||||
return h("div", { class: "md-code" }, lang ? h("span", { class: "md-lang", text: lang }) : null, copy, h("pre", null, code))
|
||||
}
|
||||
|
||||
function list(lines, start, parent) {
|
||||
const first = ITEM.exec(lines[start])
|
||||
const base = indentOf(first[1])
|
||||
const ordered = /\d/.test(first[2])
|
||||
const el = h(ordered ? "ol" : "ul")
|
||||
if (ordered) {
|
||||
const n = parseInt(first[2], 10)
|
||||
if (n !== 1) el.setAttribute("start", String(n))
|
||||
}
|
||||
let i = start
|
||||
while (i < lines.length) {
|
||||
const m = ITEM.exec(lines[i])
|
||||
if (!m || indentOf(m[1]) !== base || /\d/.test(m[2]) !== ordered) break
|
||||
const content = [m[3]]
|
||||
const width = indentOf(m[1]) + m[2].length + 1
|
||||
i += 1
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]
|
||||
if (!l.trim()) {
|
||||
// A blank line inside an item, when the item goes on indented after it.
|
||||
if (i + 1 < lines.length && indentOf(lines[i + 1]) > base && lines[i + 1].trim()) {
|
||||
content.push("")
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
const sub = ITEM.exec(l)
|
||||
if (sub && indentOf(sub[1]) <= base) break
|
||||
if (indentOf(l) <= base && startsBlock(l, lines[i + 1])) break
|
||||
content.push(l.slice(Math.min(width, indentOf(l))))
|
||||
i += 1
|
||||
}
|
||||
const li = h("li")
|
||||
const task = /^\[([ xX])\]\s+/.exec(content[0])
|
||||
if (task) {
|
||||
content[0] = content[0].slice(task[0].length)
|
||||
li.classList.add("md-task")
|
||||
li.append(h("input", { type: "checkbox", disabled: true, checked: task[1] !== " " }))
|
||||
}
|
||||
// A tight item is its text; one with blocks in it is blocks.
|
||||
if (content.length === 1 || !content.slice(1).some((c) => startsBlock(c) || !c.trim())) {
|
||||
li.append(...inline(content.join("\n")))
|
||||
} else blocks(content, li)
|
||||
el.append(li)
|
||||
while (i < lines.length && !lines[i].trim()) {
|
||||
const next = ITEM.exec(lines[i + 1] || "")
|
||||
if (next && indentOf(next[1]) === base) i += 1
|
||||
else break
|
||||
}
|
||||
}
|
||||
parent.append(el)
|
||||
return i
|
||||
}
|
||||
|
||||
function cells(line) {
|
||||
let s = line.trim()
|
||||
if (s.startsWith("|")) s = s.slice(1)
|
||||
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
|
||||
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"))
|
||||
}
|
||||
|
||||
function table(lines, start, parent) {
|
||||
const head = cells(lines[start])
|
||||
const aligns = cells(lines[start + 1]).map((c) =>
|
||||
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "",
|
||||
)
|
||||
let i = start + 2
|
||||
const rows = []
|
||||
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
|
||||
rows.push(cells(lines[i]))
|
||||
i += 1
|
||||
}
|
||||
const cell = (tag, text, n) => {
|
||||
const c = h(tag, null, inline(text ?? ""))
|
||||
if (aligns[n]) c.dataset.align = aligns[n]
|
||||
return c
|
||||
}
|
||||
const tbl = h(
|
||||
"table",
|
||||
null,
|
||||
h("thead", null, h("tr", null, head.map((c, n) => cell("th", c, n)))),
|
||||
h("tbody", null, rows.map((r) => h("tr", null, head.map((_, n) => cell("td", r[n], n))))),
|
||||
)
|
||||
// A wide table scrolls by itself; the page does not.
|
||||
parent.append(h("div", { class: "md-table" }, tbl))
|
||||
return i
|
||||
}
|
||||
|
||||
function link(label, url) {
|
||||
const safe = SAFE_LINK.test(url)
|
||||
const a = h("a", { href: safe ? url : null, title: url, class: safe ? null : "md-unsafe" }, label)
|
||||
if (safe) {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault()
|
||||
openLink(url)
|
||||
})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/** The closing `mark` for an opening at `from`, or -1. */
|
||||
function closing(text, mark, from) {
|
||||
let at = text.indexOf(mark, from)
|
||||
while (at !== -1) {
|
||||
const before = text[at - 1]
|
||||
if (at > from && before !== " " && before !== "\\") {
|
||||
if (mark !== "_" || !/\w/.test(text[at + 1] || "")) return at
|
||||
}
|
||||
at = text.indexOf(mark, at + 1)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Inline markdown to nodes. */
|
||||
export function inline(text) {
|
||||
const out = []
|
||||
let plain = ""
|
||||
const flush = () => {
|
||||
if (plain) out.push(document.createTextNode(plain))
|
||||
plain = ""
|
||||
}
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
const c = text[i]
|
||||
const rest = text.slice(i)
|
||||
if (c === "\\" && i + 1 < text.length) {
|
||||
if (text[i + 1] === "\n") {
|
||||
flush()
|
||||
out.push(h("br"))
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (/[\\`*_{}[\]()#+\-.!|~<>]/.test(text[i + 1])) {
|
||||
plain += text[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "`") {
|
||||
const run = /^`+/.exec(rest)[0]
|
||||
const end = text.indexOf(run, i + run.length)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("code", { text: text.slice(i + run.length, end).replace(/^ (.+) $/, "$1") }))
|
||||
i = end + run.length
|
||||
continue
|
||||
}
|
||||
plain += run
|
||||
i += run.length
|
||||
continue
|
||||
}
|
||||
const img = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
|
||||
if (img) {
|
||||
flush()
|
||||
out.push(link(img[1] || img[2], img[2]))
|
||||
i += img[0].length
|
||||
continue
|
||||
}
|
||||
if (c === "[") {
|
||||
const m = /^\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
|
||||
if (m) {
|
||||
flush()
|
||||
out.push(link(inline(m[1]), m[2]))
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "<") {
|
||||
const m = /^<((?:https?:\/\/|mailto:)[^\s>]+)>/i.exec(rest)
|
||||
if (m) {
|
||||
flush()
|
||||
out.push(link(m[1], m[1]))
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ((c === "h" || c === "H") && !/\w/.test(text[i - 1] || "")) {
|
||||
const m = /^https?:\/\/[^\s<]+/i.exec(rest)
|
||||
if (m) {
|
||||
let url = m[0]
|
||||
// Trailing punctuation belongs to the sentence, and an unmatched ")" to a parenthesis.
|
||||
while (/[.,;:!?'"]$/.test(url) || (url.endsWith(")") && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length)) {
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
flush()
|
||||
out.push(link(url, url))
|
||||
i += url.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
const strong = rest.startsWith("**") ? "**" : rest.startsWith("__") && !/\w/.test(text[i - 1] || "") ? "__" : ""
|
||||
if (strong && text[i + 2] && text[i + 2] !== " ") {
|
||||
const end = closing(text, strong, i + 2)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("strong", null, inline(text.slice(i + 2, end))))
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (rest.startsWith("~~") && text[i + 2] && text[i + 2] !== " ") {
|
||||
const end = closing(text, "~~", i + 2)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("del", null, inline(text.slice(i + 2, end))))
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ((c === "*" || (c === "_" && !/\w/.test(text[i - 1] || ""))) && text[i + 1] && !/\s/.test(text[i + 1]) && text[i + 1] !== c) {
|
||||
const end = closing(text, c, i + 1)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("em", null, inline(text.slice(i + 1, end))))
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "\n") {
|
||||
if (plain.endsWith(" ")) {
|
||||
plain = plain.replace(/ +$/, "")
|
||||
flush()
|
||||
out.push(h("br"))
|
||||
} else plain += " "
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
plain += c
|
||||
i += 1
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
|
||||
// notifications, and this release.
|
||||
|
||||
import { $, h, clear } from "./dom.js"
|
||||
import { invoke, listen, why } from "./bridge.js"
|
||||
|
||||
const AGENT = {
|
||||
off: () => [t("The agent is off."), ""],
|
||||
not_linked: () => [t("This computer is not linked to the server yet."), ""],
|
||||
starting: () => [t("The agent is starting…"), ""],
|
||||
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
||||
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
|
||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
||||
}
|
||||
|
||||
let folderError = ""
|
||||
let linking = null // { user_code, uri } while the computer is being linked
|
||||
let linkError = ""
|
||||
|
||||
export function agentWords(s) {
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
||||
}
|
||||
|
||||
function section(id, title, ...body) {
|
||||
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
|
||||
}
|
||||
|
||||
export function renderSettings(s) {
|
||||
const box = clear($("settings-body"))
|
||||
// The server and the account.
|
||||
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
||||
box.append(
|
||||
section(
|
||||
"set-server",
|
||||
t("Server"),
|
||||
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
|
||||
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
|
||||
h("p", { class: "muted", text: t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.") }),
|
||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
|
||||
),
|
||||
)
|
||||
|
||||
// The agent.
|
||||
const [text, cls] = agentWords(s)
|
||||
const agent = section(
|
||||
"set-agent",
|
||||
t("Agent"),
|
||||
h("p", { class: "muted", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
||||
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
|
||||
)
|
||||
if (linking) {
|
||||
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
|
||||
}
|
||||
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
|
||||
agent.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn",
|
||||
text: s.linked ? t("Link again") : t("Link this computer"),
|
||||
onclick: () => {
|
||||
linkError = ""
|
||||
invoke("link_computer").catch((err) => {
|
||||
linkError = why(err)
|
||||
renderSettings(s)
|
||||
})
|
||||
},
|
||||
}),
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn",
|
||||
text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"),
|
||||
disabled: !s.linked && !s.agent_wanted,
|
||||
onclick: () => invoke("set_agent", { on: !s.agent_wanted }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
box.append(agent)
|
||||
|
||||
// The folders.
|
||||
const r = s.remote
|
||||
if (s.linked && r) {
|
||||
const list = h("ul", { class: "folders" })
|
||||
if (!r.enabled || !r.roots.length) list.append(h("li", { class: "empty", text: t("No folder yet: the agent can open nothing. Add the folder of a project.") }))
|
||||
for (const dir of r.enabled ? r.roots : []) {
|
||||
list.append(
|
||||
h(
|
||||
"li",
|
||||
null,
|
||||
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
||||
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
const mode = h(
|
||||
"select",
|
||||
{ id: "max-mode", onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
||||
[
|
||||
["manual", t("Manual — asks first")],
|
||||
["edit", t("Edit — asks before commands")],
|
||||
["auto", t("Auto — asks nothing")],
|
||||
["plan", t("Plan — changes nothing")],
|
||||
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
||||
)
|
||||
const terminal = h("input", { type: "checkbox", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
||||
box.append(
|
||||
section(
|
||||
"set-folders",
|
||||
t("Project folders"),
|
||||
h("p", { class: "muted", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
||||
list,
|
||||
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
||||
h(
|
||||
"div",
|
||||
{ class: "fields" },
|
||||
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
|
||||
h(
|
||||
"div",
|
||||
{ class: "field" },
|
||||
h("span", { class: "label", text: t("Terminal") }),
|
||||
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
|
||||
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications.
|
||||
box.append(
|
||||
section(
|
||||
"set-notify",
|
||||
t("Notifications"),
|
||||
h(
|
||||
"label",
|
||||
{ class: "check" },
|
||||
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
|
||||
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// This release.
|
||||
box.append(
|
||||
section(
|
||||
"set-about",
|
||||
t("About"),
|
||||
h("p", null, `LLeMbas Desktop ${s.version}`),
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function initSettings(current) {
|
||||
listen("remote-error", (e) => {
|
||||
folderError = String(e.payload)
|
||||
renderSettings(current())
|
||||
})
|
||||
listen("status", () => {
|
||||
folderError = ""
|
||||
})
|
||||
listen("linking", (e) => {
|
||||
const p = e.payload
|
||||
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
||||
linkError = p.state === "failed" ? p.message : ""
|
||||
renderSettings(current())
|
||||
})
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
||||
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
||||
// server's device login: the approval page opens in the system's browser; this page shows the
|
||||
// code to compare and waits.
|
||||
|
||||
import { $, h, clear } from "./dom.js"
|
||||
import { invoke, listen, why } from "./bridge.js"
|
||||
|
||||
let code = null // the code being approved, while signing in
|
||||
let failed = ""
|
||||
|
||||
export function renderSetup(s) {
|
||||
const box = clear($("setup-body"))
|
||||
$("setup-version").textContent = s.version
|
||||
const server = h("section", { "aria-labelledby": "setup-server-h" }, h("h2", { id: "setup-server-h", text: t("Server") }))
|
||||
const address = h("input", {
|
||||
id: "address",
|
||||
name: "address",
|
||||
type: "text",
|
||||
inputmode: "url",
|
||||
autocomplete: "url",
|
||||
placeholder: "https://ai.example",
|
||||
spellcheck: "false",
|
||||
value: s.server || "",
|
||||
})
|
||||
const hint = h("p", { class: "hint", id: "server-hint", "aria-live": "polite" })
|
||||
if (!s.checked) hint.textContent = t("Looking for a LLeMbas server there…")
|
||||
else if (s.refused) {
|
||||
hint.textContent = s.refused
|
||||
hint.classList.add("is-error")
|
||||
} else if (s.discovery) hint.textContent = t("%(name)s, LLeMbas %(version)s.", { name: s.discovery.name, version: s.discovery.version })
|
||||
else hint.textContent = t("LLeMbas Desktop needs a LLeMbas 2.0 server or later.")
|
||||
const connect = h("button", { type: "submit", class: "btn btn--primary", text: t("Connect") })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "field", novalidate: true },
|
||||
h("label", { for: "address", text: t("Address of your LLeMbas server") }),
|
||||
h("div", { class: "row" }, address, connect),
|
||||
hint,
|
||||
)
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
connect.disabled = true
|
||||
hint.className = "hint"
|
||||
hint.textContent = t("Looking for a LLeMbas server there…")
|
||||
try {
|
||||
await invoke("set_server", { address: address.value })
|
||||
} catch (err) {
|
||||
hint.className = "hint is-error"
|
||||
hint.textContent = why(err)
|
||||
} finally {
|
||||
connect.disabled = false
|
||||
}
|
||||
})
|
||||
server.append(form)
|
||||
if (s.refused && s.server) {
|
||||
server.append(h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Try again"), onclick: () => invoke("recheck_server") })))
|
||||
}
|
||||
box.append(server)
|
||||
|
||||
if (s.discovery && !s.account) {
|
||||
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
||||
sign.append(h("p", { class: "muted", text: t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
||||
if (s.signing_in && code) {
|
||||
sign.append(
|
||||
h("p", null, t("Approve this code in the page that opened in your browser:")),
|
||||
h("p", { class: "code", text: code.user_code }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", { type: "button", class: "btn", text: t("Open the page again"), onclick: () => invoke("open_external", { url: code.verification_uri_complete }) }),
|
||||
h("button", { type: "button", class: "btn", text: t("Cancel"), onclick: () => invoke("sign_in_cancel") }),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
sign.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn btn--primary",
|
||||
text: s.signing_in ? t("Waiting for the browser…") : t("Sign in"),
|
||||
disabled: s.signing_in,
|
||||
onclick: () => {
|
||||
failed = ""
|
||||
invoke("sign_in").catch((err) => {
|
||||
failed = why(err)
|
||||
renderSetup(s)
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (failed) sign.append(h("p", { class: "status is-error", text: failed }))
|
||||
box.append(sign)
|
||||
}
|
||||
}
|
||||
|
||||
export function initSetup(current) {
|
||||
listen("signin", (e) => {
|
||||
const p = e.payload
|
||||
if (p.state === "code") code = p.code
|
||||
if (p.state === "done") code = null
|
||||
if (p.state === "failed") {
|
||||
code = null
|
||||
failed = p.message
|
||||
}
|
||||
renderSetup(current())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
||||
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
||||
// does that for an undecorated one); the menu and the agent pill open the app's native menu.
|
||||
|
||||
import { $, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen, win } from "./bridge.js"
|
||||
|
||||
const WORDS = {
|
||||
off: () => t("agent off"),
|
||||
not_linked: () => t("not linked"),
|
||||
starting: () => t("agent starting…"),
|
||||
running: () => t("agent running"),
|
||||
elsewhere: () => t("agent running (service)"),
|
||||
failed: () => t("agent stopped"),
|
||||
no_folders: () => t("agent: no folders"),
|
||||
}
|
||||
|
||||
export function renderBar(s) {
|
||||
let host = ""
|
||||
try {
|
||||
host = s.server ? new URL(s.server).host : ""
|
||||
} catch {}
|
||||
$("host").textContent = host
|
||||
$("host").title = s.server || ""
|
||||
// Connected but allowed into no folder: a green light would say it works when it cannot.
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
|
||||
$("agent").dataset.state = state
|
||||
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
|
||||
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
|
||||
$("update").hidden = !s.update
|
||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
||||
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||
}
|
||||
|
||||
function maximized(on) {
|
||||
const b = $("maximize")
|
||||
b.replaceChildren(icon(on ? ICONS.restore : ICONS.max))
|
||||
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
|
||||
b.title = on ? t("Restore") : t("Maximise")
|
||||
}
|
||||
|
||||
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
|
||||
function menuAt(button) {
|
||||
const r = button.getBoundingClientRect()
|
||||
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
||||
}
|
||||
|
||||
export function initBar() {
|
||||
$("minimize").replaceChildren(icon(ICONS.min))
|
||||
$("close").replaceChildren(icon(ICONS.close))
|
||||
$("menu").replaceChildren(icon(ICONS.menu))
|
||||
$("side-toggle").replaceChildren(icon(ICONS.side))
|
||||
$("minimize").addEventListener("click", () => win.minimize())
|
||||
$("maximize").addEventListener("click", () => win.toggleMaximize())
|
||||
$("close").addEventListener("click", () => win.close())
|
||||
$("update").addEventListener("click", () => invoke("update_app"))
|
||||
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
||||
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
|
||||
listen("maximized", (e) => maximized(!!e.payload))
|
||||
win.isMaximized().then(maximized).catch(() => maximized(false))
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
|
||||
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
|
||||
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
|
||||
// out. What a file says is never part of the app's page.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke } from "./bridge.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
import { codeView } from "./code.js"
|
||||
|
||||
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
|
||||
const LANGUAGES = {
|
||||
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
|
||||
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
|
||||
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
|
||||
}
|
||||
|
||||
export function showKind(path) {
|
||||
const name = String(path).split("/").pop() || ""
|
||||
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
|
||||
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
|
||||
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
|
||||
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
|
||||
return language ? { kind: "code", language } : { kind: "text", language: "" }
|
||||
}
|
||||
|
||||
let current = null // { editor, sealed }
|
||||
let onChange = () => {}
|
||||
|
||||
/** Called when the viewer opens or closes (the layout makes room for it). */
|
||||
export function onViewer(fn) {
|
||||
onChange = fn
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (!current) return
|
||||
current.editor?.destroy()
|
||||
if (current.sealed) invoke("sealed_close", { url: current.sealed })
|
||||
current = null
|
||||
}
|
||||
|
||||
export function closeViewer() {
|
||||
teardown()
|
||||
$("viewer").hidden = true
|
||||
clear($("viewer-body"))
|
||||
onChange(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
|
||||
* withheld?: "binary" | "too_big", size?, error? }.
|
||||
*/
|
||||
export async function openViewer(file) {
|
||||
teardown()
|
||||
const kind = file.kind || showKind(file.path || file.name).kind
|
||||
const lang = file.language ?? showKind(file.path || file.name).language
|
||||
$("viewer").hidden = false
|
||||
onChange(true)
|
||||
$("viewer-name").textContent = file.name || file.path || ""
|
||||
$("viewer-name").title = file.path || ""
|
||||
$("viewer-where").textContent = file.where || ""
|
||||
const body = clear($("viewer-body"))
|
||||
const modes = clear($("viewer-modes"))
|
||||
current = {}
|
||||
if (file.error) {
|
||||
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
|
||||
return
|
||||
}
|
||||
if (file.withheld) {
|
||||
body.append(
|
||||
h("p", {
|
||||
class: "viewer__note",
|
||||
text:
|
||||
file.withheld === "too_big"
|
||||
? t("This file is too big to show here (%(size)s).", { size: bytes(file.size) })
|
||||
: t("This is not a text file; it cannot be shown here."),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const text = file.text ?? ""
|
||||
// Markdown and HTML can be seen drawn or as their source.
|
||||
const drawn = kind === "markdown" || kind === "html"
|
||||
const show = async (asSource) => {
|
||||
teardown()
|
||||
current = {}
|
||||
clear(body)
|
||||
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
|
||||
if (asSource || !drawn) {
|
||||
const holder = h("div", { class: "viewer__code" })
|
||||
body.append(holder)
|
||||
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
|
||||
} else if (kind === "markdown") {
|
||||
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
|
||||
} else {
|
||||
const url = await invoke("sealed_open", { html: text })
|
||||
current.sealed = url
|
||||
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
|
||||
// scripts and nothing else. The address's own policy says the same again.
|
||||
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
|
||||
}
|
||||
}
|
||||
if (drawn) {
|
||||
modes.append(
|
||||
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
|
||||
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
|
||||
)
|
||||
}
|
||||
await show(false)
|
||||
}
|
||||
|
||||
export function initViewer() {
|
||||
$("viewer-close").replaceChildren(icon(ICONS.close))
|
||||
$("viewer-close").addEventListener("click", closeViewer)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user