- The app's device login asks for `app models` and, where discovery says
login.device.agent_scope, `agent_scope=models memory link` in the same
approval. The agent's token goes to the bundled CLI on stdin
(`lembas login --token-stdin --server <url> --json`) and is never kept
here; if the CLI does not take it, it is given up on the server. A
computer linked already keeps its link; a server without agent_scope
gets the old two steps.
- The server pane reads GET /api/v2/files and /files/content (mounts open
like folders; `withheld` shown in the server's words). The "not offered
yet" state is gone.
- A show card's file comes from GET /api/v2/chats/{chat}/show?node=&version=
or ?shown= as plain text (new `api_text` command, 1 MB cap); args.path is
read only for agent chats on this computer, locally.
- The gate is link protocol 3 plus app_api 2 (APP_API = 2), not the
server's version number, which says 1.7.2 on dev until its first beta.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.6 KiB
JavaScript
115 lines
4.6 KiB
JavaScript
// 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. Where the server offers it (`one_approval`) and this computer is not
|
|
// linked yet, the same approval links it for the agent: there is no second one.
|
|
|
|
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") }))
|
|
const both = s.discovery.one_approval && !s.linked
|
|
sign.append(h("p", { class: "muted", text: both ? 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, and the same approval links this computer for the agent.") : 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())
|
|
})
|
|
}
|