One approval for app and agent; /api/v2 files and show; app_api 2
- 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>
This commit is contained in:
9 files changed
+383
-164
No files matched your search
@@ -15,6 +15,8 @@ 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)
|
||||
/** A `GET` on /api/v2 that answers plain text (a shown file). Rejects with {status, detail}. */
|
||||
export const getText = (path) => invoke("api_text", { path })
|
||||
|
||||
/** An error's words, whatever shape it came in. */
|
||||
export function why(err) {
|
||||
|
||||
+33
-17
@@ -7,10 +7,10 @@
|
||||
// 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 { api, get, getText, 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"
|
||||
import { readLocal } from "./explorer.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
@@ -101,8 +101,26 @@ function step(s, chat) {
|
||||
}
|
||||
}
|
||||
|
||||
function shownPath(call) {
|
||||
return call.show?.path || call.args?.path || call.path || ""
|
||||
/**
|
||||
* Where the text of a file the agent showed comes from, or null when there is none to fetch:
|
||||
* - an agent chat on this computer: the file itself, read here (the card's path, or the call's
|
||||
* own `args.path` for a step the card does not describe);
|
||||
* - otherwise the server, through the card (`show`): `?node=&version=` for a file in the tree,
|
||||
* `?shown=` for the copy a device sent. The server checks the chat's access again then.
|
||||
*/
|
||||
function shownSource(call, chat) {
|
||||
if (onThisComputer(chat) && chat.device.directory) {
|
||||
const path = call.show?.path || call.args?.path || ""
|
||||
return path ? { local: true, path } : null
|
||||
}
|
||||
const s = call.show
|
||||
if (!s || !chat?.id) return null
|
||||
const q = s.shown_id
|
||||
? `shown=${encodeURIComponent(s.shown_id)}`
|
||||
: s.node_id && s.version_id
|
||||
? `node=${encodeURIComponent(s.node_id)}&version=${encodeURIComponent(s.version_id)}`
|
||||
: ""
|
||||
return q ? { local: false, path: s.path || "", url: `/chats/${encodeURIComponent(chat.id)}/show?${q}` } : null
|
||||
}
|
||||
|
||||
function toolCall(c, chat) {
|
||||
@@ -115,7 +133,7 @@ function toolCall(c, chat) {
|
||||
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 isShow = c.name === "show" && !failed && shownSource(c, chat)
|
||||
const summary = h(
|
||||
"summary",
|
||||
{ class: "call__head" },
|
||||
@@ -132,25 +150,23 @@ function toolCall(c, chat) {
|
||||
|
||||
/** 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 src = shownSource(call, chat)
|
||||
if (!src) return
|
||||
const path = src.path
|
||||
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 }
|
||||
const line = call.show?.line || (src.local ? call.args?.line : 0) || 0
|
||||
const base = { name: call.show?.name || path.split("/").pop(), path, kind: call.show?.kind, language: call.show?.language || language, line }
|
||||
try {
|
||||
if (onThisComputer(chat) && chat.device.directory) {
|
||||
if (src.local) {
|
||||
const dir = chat.device.directory.replace(/\/+$/, "")
|
||||
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
|
||||
const f = await readLocal(dir, rel)
|
||||
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("This computer")}`, text: f.text, withheld: f.withheld, size: f.size })
|
||||
} else 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 })
|
||||
// Plain text, at most 1 MB; the server's 404 (no longer the chat's), 413 (too large) and
|
||||
// 415 (not text) come back as its own words. HTML is its source: the viewer seals it.
|
||||
const text = await getText(src.url)
|
||||
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text })
|
||||
}
|
||||
} catch (err) {
|
||||
openViewer({ ...base, where: t("Shown by the agent"), error: why(err) })
|
||||
|
||||
+24
-22
@@ -6,16 +6,18 @@ 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" }
|
||||
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
||||
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
|
||||
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
|
||||
// A path the person cannot read is the server's 404, in its words.
|
||||
export const SERVER_FILES = {
|
||||
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
||||
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
|
||||
}
|
||||
|
||||
/** A directory in either tree: the server's mounts open like its folders. */
|
||||
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
|
||||
|
||||
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
||||
function tree(box, { list, open, rootPath }) {
|
||||
clear(box)
|
||||
@@ -33,15 +35,15 @@ function tree(box, { list, open, rootPath }) {
|
||||
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),
|
||||
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
|
||||
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
|
||||
icon(isDir(e) ? ICONS.folder : ICONS.file),
|
||||
h("span", { class: "tree__name", text: e.name }),
|
||||
h("span", { class: "tree__size", text: e.kind === "dir" ? "" : bytes(e.size) }),
|
||||
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||
)
|
||||
row.style.setProperty("--depth", String(depth))
|
||||
const li = h("li", { role: "treeitem" }, row)
|
||||
if (e.kind === "dir") {
|
||||
if (isDir(e)) {
|
||||
let kids = null
|
||||
row.addEventListener("click", async () => {
|
||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
||||
@@ -127,22 +129,22 @@ 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
|
||||
}
|
||||
},
|
||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||
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 })
|
||||
openViewer({
|
||||
name: f.name || e.name,
|
||||
path: f.path || e.path,
|
||||
where,
|
||||
text: f.text,
|
||||
// The server's own kind and language: its table and the viewer's are the same one.
|
||||
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
|
||||
language: f.language || undefined,
|
||||
withheld: f.withheld,
|
||||
size: f.size,
|
||||
})
|
||||
} catch (err) {
|
||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,7 +1,8 @@
|
||||
// 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.
|
||||
// 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"
|
||||
@@ -60,7 +61,8 @@ export function renderSetup(s) {
|
||||
|
||||
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.") }))
|
||||
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:")),
|
||||
|
||||
+15
-10
@@ -47,9 +47,22 @@ export function closeViewer() {
|
||||
onChange(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a file has no text, in words: this computer's `withheld` is "binary" | "too_big"; the
|
||||
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
|
||||
* language already.
|
||||
*/
|
||||
function withheldWords(withheld, size) {
|
||||
if (typeof withheld === "object" && withheld.message) return String(withheld.message)
|
||||
const reason = typeof withheld === "object" ? withheld.reason : withheld
|
||||
return reason === "too_big" || reason === "too_large"
|
||||
? t("This file is too big to show here (%(size)s).", { size: bytes(size) })
|
||||
: t("This is not a text file; it cannot be shown here.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
|
||||
* withheld?: "binary" | "too_big", size?, error? }.
|
||||
* withheld? (see withheldWords), size?, error? }.
|
||||
*/
|
||||
export async function openViewer(file) {
|
||||
teardown()
|
||||
@@ -68,15 +81,7 @@ export async function openViewer(file) {
|
||||
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."),
|
||||
}),
|
||||
)
|
||||
body.append(h("p", { class: "viewer__note", text: withheldWords(file.withheld, file.size) }))
|
||||
return
|
||||
}
|
||||
const text = file.text ?? ""
|
||||
|
||||
Reference in new issue
Block a user