- 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>
73 lines
2.5 KiB
JavaScript
73 lines
2.5 KiB
JavaScript
// 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)
|
|
/** 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) {
|
|
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(() => {})
|
|
}
|