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:
HomerandClaude Opus 5.5 committed 2026-11-03 14:48:00 +00:00
1 parent b82d4c688a
commit ab0c1434c4
9 files changed
+383 -164

No files matched your search

+8 -8
View File
@@ -109,6 +109,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "Agent se spouš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.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
"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.": "Aplikace se k vašemu serveru přihlásí jednou, ve vašem prohlížeči, jako každé zařízení. Pak může číst a psát vaše konverzace, jako webové rozhraní, a stejné schválení propojí tento počítač pro agenta.",
"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.": "Aplikace se k vašemu serveru přihlásí jednou, ve vašem prohlížeči, jako každé zařízení. Pak může číst a psát vaše konverzace, jako webové rozhraní.",
"The connection to the reply was lost: %(why)s": "Spojení s odpovědí se přerušilo: %(why)s",
"The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí",
@@ -118,7 +119,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.",
"This file is too big to show here (%(size)s).": "Tento soubor je příliš velký na zobrazení (%(size)s).",
"This is not a text file; it cannot be shown here.": "Toto není textový soubor; nelze ho zde zobrazit.",
"This server does not offer its files to the app yet.": "Tento server zatím nenabízí své soubory aplikaci.",
"Thought": "Přemýšlel",
"Try again": "Zkusit znovu",
"Waiting for the browser…": "Čeká se na prohlížeč…",
@@ -235,6 +235,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "Der Agent startet…",
"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.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
"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.": "Die App meldet sich einmal an Ihrem Server an, in Ihrem Browser, wie jedes Gerät. Danach kann sie Ihre Chats lesen und schreiben, wie die Weboberfläche, und dieselbe Freigabe verbindet diesen Computer für den Agenten.",
"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.": "Die App meldet sich einmal an Ihrem Server an, in Ihrem Browser, wie jedes Gerät. Danach kann sie Ihre Chats lesen und schreiben, wie die Weboberfläche.",
"The connection to the reply was lost: %(why)s": "Die Verbindung zur Antwort ist abgebrochen: %(why)s",
"The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf",
@@ -244,7 +245,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.",
"This file is too big to show here (%(size)s).": "Diese Datei ist zu groß für die Anzeige (%(size)s).",
"This is not a text file; it cannot be shown here.": "Das ist keine Textdatei; sie kann hier nicht angezeigt werden.",
"This server does not offer its files to the app yet.": "Dieser Server bietet der App seine Dateien noch nicht an.",
"Thought": "Nachgedacht",
"Try again": "Erneut versuchen",
"Waiting for the browser…": "Warte auf den Browser…",
@@ -361,6 +361,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "El agente se está iniciando…",
"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.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
"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.": "La aplicación inicia sesión en su servidor una vez, en su navegador, como cualquier dispositivo. Después puede leer y escribir sus chats, como la interfaz web, y la misma aprobación vincula este ordenador para el agente.",
"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.": "La aplicación inicia sesión en su servidor una vez, en su navegador, como cualquier dispositivo. Después puede leer y escribir sus chats, como la interfaz web.",
"The connection to the reply was lost: %(why)s": "Se perdió la conexión con la respuesta: %(why)s",
"The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí",
@@ -370,7 +371,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.",
"This file is too big to show here (%(size)s).": "Este archivo es demasiado grande para mostrarlo (%(size)s).",
"This is not a text file; it cannot be shown here.": "No es un archivo de texto; no se puede mostrar aquí.",
"This server does not offer its files to the app yet.": "Este servidor aún no ofrece sus archivos a la aplicación.",
"Thought": "Pensamiento",
"Try again": "Reintentar",
"Waiting for the browser…": "Esperando al navegador…",
@@ -487,6 +487,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "L'agent démarre…",
"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.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
"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.": "L'application se connecte une fois à votre serveur, dans votre navigateur, comme tout appareil. Elle peut ensuite lire et écrire vos discussions, comme l'interface web, et la même approbation relie cet ordinateur pour l'agent.",
"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.": "L'application se connecte une fois à votre serveur, dans votre navigateur, comme tout appareil. Elle peut ensuite lire et écrire vos discussions, comme l'interface web.",
"The connection to the reply was lost: %(why)s": "La connexion à la réponse a été perdue : %(why)s",
"The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici",
@@ -496,7 +497,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.",
"This file is too big to show here (%(size)s).": "Ce fichier est trop volumineux pour être affiché (%(size)s).",
"This is not a text file; it cannot be shown here.": "Ce n'est pas un fichier texte ; il ne peut pas être affiché ici.",
"This server does not offer its files to the app yet.": "Ce serveur n'offre pas encore ses fichiers à l'application.",
"Thought": "Réflexion",
"Try again": "Réessayer",
"Waiting for the browser…": "En attente du navigateur…",
@@ -613,6 +613,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "Az ügynök indul…",
"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.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
"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.": "Az alkalmazás egyszer jelentkezik be a szerverére, a böngészőjében, mint bármely eszköz. Ezután olvashatja és írhatja a csevegéseit, mint a webes felület, és ugyanez a jóváhagyás összekapcsolja ezt a számítógépet az ügynök számára.",
"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.": "Az alkalmazás egyszer jelentkezik be a szerverére, a böngészőjében, mint bármely eszköz. Ezután olvashatja és írhatja a csevegéseit, mint a webes felület.",
"The connection to the reply was lost: %(why)s": "A kapcsolat a válasszal megszakadt: %(why)s",
"The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet",
@@ -622,7 +623,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.",
"This file is too big to show here (%(size)s).": "Ez a fájl túl nagy a megjelenítéshez (%(size)s).",
"This is not a text file; it cannot be shown here.": "Ez nem szövegfájl; itt nem jeleníthető meg.",
"This server does not offer its files to the app yet.": "Ez a szerver még nem kínálja fel a fájljait az alkalmazásnak.",
"Thought": "Gondolkodott",
"Try again": "Újrapróbálás",
"Waiting for the browser…": "Várakozás a böngészőre…",
@@ -739,6 +739,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "L'agente si sta avviando…",
"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.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
"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.": "L'app accede al suo server una volta, nel suo browser, come ogni dispositivo. Poi può leggere e scrivere le sue chat, come l'interfaccia web, e la stessa approvazione collega questo computer per l'agente.",
"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.": "L'app accede al suo server una volta, nel suo browser, come ogni dispositivo. Poi può leggere e scrivere le sue chat, come l'interfaccia web.",
"The connection to the reply was lost: %(why)s": "La connessione alla risposta si è interrotta: %(why)s",
"The most an agent chat may do here": "Il massimo che una chat agente può fare qui",
@@ -748,7 +749,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.",
"This file is too big to show here (%(size)s).": "Questo file è troppo grande per essere mostrato (%(size)s).",
"This is not a text file; it cannot be shown here.": "Questo non è un file di testo; non può essere mostrato qui.",
"This server does not offer its files to the app yet.": "Questo server non offre ancora i suoi file all'app.",
"Thought": "Ha ragionato",
"Try again": "Riprova",
"Waiting for the browser…": "In attesa del browser…",
@@ -865,6 +865,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "Agent się uruchamia…",
"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.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
"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.": "Aplikacja loguje się do Twojego serwera raz, w przeglądarce, jak każde urządzenie. Potem może czytać i pisać Twoje czaty, jak interfejs WWW, a to samo zatwierdzenie łączy ten komputer dla agenta.",
"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.": "Aplikacja loguje się do Twojego serwera raz, w przeglądarce, jak każde urządzenie. Potem może czytać i pisać Twoje czaty, jak interfejs WWW.",
"The connection to the reply was lost: %(why)s": "Połączenie z odpowiedzią zostało zerwane: %(why)s",
"The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić",
@@ -874,7 +875,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.",
"This file is too big to show here (%(size)s).": "Ten plik jest za duży, by go tu pokazać (%(size)s).",
"This is not a text file; it cannot be shown here.": "To nie jest plik tekstowy; nie można go tu pokazać.",
"This server does not offer its files to the app yet.": "Ten serwer nie udostępnia jeszcze swoich plików aplikacji.",
"Thought": "Rozumowanie",
"Try again": "Spróbuj ponownie",
"Waiting for the browser…": "Czekam na przeglądarkę…",
@@ -991,6 +991,7 @@ const LEMBAS_I18N = {
"The agent is starting…": "Agent sa spúšťa…",
"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.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
"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.": "Aplikácia sa na váš server prihlási raz, vo vašom prehliadači, ako každé zariadenie. Potom môže čítať a písať vaše konverzácie, ako webové rozhranie, a to isté schválenie prepojí tento počítač pre agenta.",
"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.": "Aplikácia sa na váš server prihlási raz, vo vašom prehliadači, ako každé zariadenie. Potom môže čítať a písať vaše konverzácie, ako webové rozhranie.",
"The connection to the reply was lost: %(why)s": "Spojenie s odpoveďou sa prerušilo: %(why)s",
"The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie",
@@ -1000,7 +1001,6 @@ const LEMBAS_I18N = {
"This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.",
"This file is too big to show here (%(size)s).": "Tento súbor je príliš veľký na zobrazenie (%(size)s).",
"This is not a text file; it cannot be shown here.": "Toto nie je textový súbor; nedá sa tu zobraziť.",
"This server does not offer its files to the app yet.": "Tento server zatiaľ neponúka svoje súbory aplikácii.",
"Thought": "Premýšľal",
"Try again": "Skúsiť znova",
"Waiting for the browser…": "Čaká sa na prehliadač…",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? ""