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
+9
-9
@@ -88,7 +88,6 @@ KEYS = (
|
|||||||
"Empty folder",
|
"Empty folder",
|
||||||
"No project folder yet: the agent can open nothing on this computer.",
|
"No project folder yet: the agent can open nothing on this computer.",
|
||||||
"Add folder…",
|
"Add folder…",
|
||||||
"This server does not offer its files to the app yet.",
|
|
||||||
# list.js
|
# list.js
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.",
|
"No agent chat yet. Start one: it works in a folder on this computer.",
|
||||||
"No chat yet.",
|
"No chat yet.",
|
||||||
@@ -145,6 +144,7 @@ KEYS = (
|
|||||||
"Try again",
|
"Try again",
|
||||||
"Sign in",
|
"Sign in",
|
||||||
"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.",
|
"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.",
|
||||||
|
"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.",
|
||||||
"Open the page again",
|
"Open the page again",
|
||||||
"Cancel",
|
"Cancel",
|
||||||
"Waiting for the browser…",
|
"Waiting for the browser…",
|
||||||
@@ -212,7 +212,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Prázdná složka",
|
"Empty folder": "Prázdná složka",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Zatím žádná složka projektu: agent na tomto počítači nemůže nic otevřít.",
|
"No project folder yet: the agent can open nothing on this computer.": "Zatím žádná složka projektu: agent na tomto počítači nemůže nic otevřít.",
|
||||||
"Add folder…": "Přidat složku…",
|
"Add folder…": "Přidat složku…",
|
||||||
"This server does not offer its files to the app yet.": "Tento server zatím nenabízí své soubory aplikaci.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatím žádná agentní konverzace. Začněte ji: pracuje ve složce na tomto počítači.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatím žádná agentní konverzace. Začněte ji: pracuje ve složce na tomto počítači.",
|
||||||
"No chat yet.": "Zatím žádná konverzace.",
|
"No chat yet.": "Zatím žádná konverzace.",
|
||||||
"Other devices": "Jiná zařízení",
|
"Other devices": "Jiná zařízení",
|
||||||
@@ -256,6 +255,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Připojit",
|
"Connect": "Připojit",
|
||||||
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
|
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Otevřít stránku znovu",
|
"Open the page again": "Otevřít stránku znovu",
|
||||||
"Waiting for the browser…": "Čeká se na prohlížeč…",
|
"Waiting for the browser…": "Čeká se na prohlížeč…",
|
||||||
"agent off": "agent vypnutý",
|
"agent off": "agent vypnutý",
|
||||||
@@ -313,7 +313,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Leerer Ordner",
|
"Empty folder": "Leerer Ordner",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Noch kein Projektordner: Der Agent kann auf diesem Computer nichts öffnen.",
|
"No project folder yet: the agent can open nothing on this computer.": "Noch kein Projektordner: Der Agent kann auf diesem Computer nichts öffnen.",
|
||||||
"Add folder…": "Ordner hinzufügen…",
|
"Add folder…": "Ordner hinzufügen…",
|
||||||
"This server does not offer its files to the app yet.": "Dieser Server bietet der App seine Dateien noch nicht an.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Noch kein Agenten-Chat. Starten Sie einen: Er arbeitet in einem Ordner auf diesem Computer.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Noch kein Agenten-Chat. Starten Sie einen: Er arbeitet in einem Ordner auf diesem Computer.",
|
||||||
"No chat yet.": "Noch kein Chat.",
|
"No chat yet.": "Noch kein Chat.",
|
||||||
"Other devices": "Andere Geräte",
|
"Other devices": "Andere Geräte",
|
||||||
@@ -357,6 +356,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Verbinden",
|
"Connect": "Verbinden",
|
||||||
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
|
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Seite erneut öffnen",
|
"Open the page again": "Seite erneut öffnen",
|
||||||
"Waiting for the browser…": "Warte auf den Browser…",
|
"Waiting for the browser…": "Warte auf den Browser…",
|
||||||
"agent off": "Agent aus",
|
"agent off": "Agent aus",
|
||||||
@@ -414,7 +414,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Carpeta vacía",
|
"Empty folder": "Carpeta vacía",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Aún no hay carpeta de proyecto: el agente no puede abrir nada en este equipo.",
|
"No project folder yet: the agent can open nothing on this computer.": "Aún no hay carpeta de proyecto: el agente no puede abrir nada en este equipo.",
|
||||||
"Add folder…": "Añadir carpeta…",
|
"Add folder…": "Añadir carpeta…",
|
||||||
"This server does not offer its files to the app yet.": "Este servidor aún no ofrece sus archivos a la aplicación.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Aún no hay chats de agente. Inicie uno: trabaja en una carpeta de este equipo.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Aún no hay chats de agente. Inicie uno: trabaja en una carpeta de este equipo.",
|
||||||
"No chat yet.": "Aún no hay chats.",
|
"No chat yet.": "Aún no hay chats.",
|
||||||
"Other devices": "Otros dispositivos",
|
"Other devices": "Otros dispositivos",
|
||||||
@@ -458,6 +457,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Conectar",
|
"Connect": "Conectar",
|
||||||
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
|
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Abrir la página de nuevo",
|
"Open the page again": "Abrir la página de nuevo",
|
||||||
"Waiting for the browser…": "Esperando al navegador…",
|
"Waiting for the browser…": "Esperando al navegador…",
|
||||||
"agent off": "agente apagado",
|
"agent off": "agente apagado",
|
||||||
@@ -515,7 +515,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Dossier vide",
|
"Empty folder": "Dossier vide",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Aucun dossier de projet pour l'instant : l'agent ne peut rien ouvrir sur cet ordinateur.",
|
"No project folder yet: the agent can open nothing on this computer.": "Aucun dossier de projet pour l'instant : l'agent ne peut rien ouvrir sur cet ordinateur.",
|
||||||
"Add folder…": "Ajouter un dossier…",
|
"Add folder…": "Ajouter un dossier…",
|
||||||
"This server does not offer its files to the app yet.": "Ce serveur n'offre pas encore ses fichiers à l'application.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Aucune discussion d'agent pour l'instant. Commencez-en une : elle travaille dans un dossier de cet ordinateur.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Aucune discussion d'agent pour l'instant. Commencez-en une : elle travaille dans un dossier de cet ordinateur.",
|
||||||
"No chat yet.": "Aucune discussion pour l'instant.",
|
"No chat yet.": "Aucune discussion pour l'instant.",
|
||||||
"Other devices": "Autres appareils",
|
"Other devices": "Autres appareils",
|
||||||
@@ -559,6 +558,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Se connecter",
|
"Connect": "Se connecter",
|
||||||
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
|
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Rouvrir la page",
|
"Open the page again": "Rouvrir la page",
|
||||||
"Waiting for the browser…": "En attente du navigateur…",
|
"Waiting for the browser…": "En attente du navigateur…",
|
||||||
"agent off": "agent arrêté",
|
"agent off": "agent arrêté",
|
||||||
@@ -616,7 +616,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Üres mappa",
|
"Empty folder": "Üres mappa",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Még nincs projektmappa: az ügynök semmit sem nyithat meg ezen a számítógépen.",
|
"No project folder yet: the agent can open nothing on this computer.": "Még nincs projektmappa: az ügynök semmit sem nyithat meg ezen a számítógépen.",
|
||||||
"Add folder…": "Mappa hozzáadása…",
|
"Add folder…": "Mappa hozzáadása…",
|
||||||
"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.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Még nincs ügynökcsevegés. Indítson egyet: egy mappában dolgozik ezen a számítógépen.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Még nincs ügynökcsevegés. Indítson egyet: egy mappában dolgozik ezen a számítógépen.",
|
||||||
"No chat yet.": "Még nincs csevegés.",
|
"No chat yet.": "Még nincs csevegés.",
|
||||||
"Other devices": "Más eszközök",
|
"Other devices": "Más eszközök",
|
||||||
@@ -660,6 +659,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Csatlakozás",
|
"Connect": "Csatlakozás",
|
||||||
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
|
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Oldal újbóli megnyitása",
|
"Open the page again": "Oldal újbóli megnyitása",
|
||||||
"Waiting for the browser…": "Várakozás a böngészőre…",
|
"Waiting for the browser…": "Várakozás a böngészőre…",
|
||||||
"agent off": "ügynök kikapcsolva",
|
"agent off": "ügynök kikapcsolva",
|
||||||
@@ -717,7 +717,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Cartella vuota",
|
"Empty folder": "Cartella vuota",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Nessuna cartella di progetto ancora: l'agente non può aprire nulla su questo computer.",
|
"No project folder yet: the agent can open nothing on this computer.": "Nessuna cartella di progetto ancora: l'agente non può aprire nulla su questo computer.",
|
||||||
"Add folder…": "Aggiungi cartella…",
|
"Add folder…": "Aggiungi cartella…",
|
||||||
"This server does not offer its files to the app yet.": "Questo server non offre ancora i suoi file all'app.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Nessuna chat con agente ancora. Ne inizi una: lavora in una cartella di questo computer.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Nessuna chat con agente ancora. Ne inizi una: lavora in una cartella di questo computer.",
|
||||||
"No chat yet.": "Nessuna chat ancora.",
|
"No chat yet.": "Nessuna chat ancora.",
|
||||||
"Other devices": "Altri dispositivi",
|
"Other devices": "Altri dispositivi",
|
||||||
@@ -761,6 +760,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Connetti",
|
"Connect": "Connetti",
|
||||||
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
|
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Riapri la pagina",
|
"Open the page again": "Riapri la pagina",
|
||||||
"Waiting for the browser…": "In attesa del browser…",
|
"Waiting for the browser…": "In attesa del browser…",
|
||||||
"agent off": "agente spento",
|
"agent off": "agente spento",
|
||||||
@@ -818,7 +818,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Pusty folder",
|
"Empty folder": "Pusty folder",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Jeszcze nie ma folderu projektu: agent nie może niczego otworzyć na tym komputerze.",
|
"No project folder yet: the agent can open nothing on this computer.": "Jeszcze nie ma folderu projektu: agent nie może niczego otworzyć na tym komputerze.",
|
||||||
"Add folder…": "Dodaj folder…",
|
"Add folder…": "Dodaj folder…",
|
||||||
"This server does not offer its files to the app yet.": "Ten serwer nie udostępnia jeszcze swoich plików aplikacji.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Jeszcze nie ma czatu z agentem. Zacznij go: pracuje w folderze na tym komputerze.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Jeszcze nie ma czatu z agentem. Zacznij go: pracuje w folderze na tym komputerze.",
|
||||||
"No chat yet.": "Jeszcze nie ma czatu.",
|
"No chat yet.": "Jeszcze nie ma czatu.",
|
||||||
"Other devices": "Inne urządzenia",
|
"Other devices": "Inne urządzenia",
|
||||||
@@ -862,6 +861,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Połącz",
|
"Connect": "Połącz",
|
||||||
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
|
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Otwórz stronę ponownie",
|
"Open the page again": "Otwórz stronę ponownie",
|
||||||
"Waiting for the browser…": "Czekam na przeglądarkę…",
|
"Waiting for the browser…": "Czekam na przeglądarkę…",
|
||||||
"agent off": "agent wyłączony",
|
"agent off": "agent wyłączony",
|
||||||
@@ -919,7 +919,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Empty folder": "Prázdny priečinok",
|
"Empty folder": "Prázdny priečinok",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Zatiaľ žiadny priečinok projektu: agent na tomto počítači nemôže nič otvoriť.",
|
"No project folder yet: the agent can open nothing on this computer.": "Zatiaľ žiadny priečinok projektu: agent na tomto počítači nemôže nič otvoriť.",
|
||||||
"Add folder…": "Pridať priečinok…",
|
"Add folder…": "Pridať priečinok…",
|
||||||
"This server does not offer its files to the app yet.": "Tento server zatiaľ neponúka svoje súbory aplikácii.",
|
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatiaľ žiadna agentská konverzácia. Začnite ju: pracuje v priečinku na tomto počítači.",
|
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatiaľ žiadna agentská konverzácia. Začnite ju: pracuje v priečinku na tomto počítači.",
|
||||||
"No chat yet.": "Zatiaľ žiadna konverzácia.",
|
"No chat yet.": "Zatiaľ žiadna konverzácia.",
|
||||||
"Other devices": "Iné zariadenia",
|
"Other devices": "Iné zariadenia",
|
||||||
@@ -963,6 +962,7 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Connect": "Pripojiť",
|
"Connect": "Pripojiť",
|
||||||
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
|
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
|
||||||
"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 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 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.",
|
||||||
"Open the page again": "Otvoriť stránku znova",
|
"Open the page again": "Otvoriť stránku znova",
|
||||||
"Waiting for the browser…": "Čaká sa na prehliadač…",
|
"Waiting for the browser…": "Čaká sa na prehliadač…",
|
||||||
"agent off": "agent vypnutý",
|
"agent off": "agent vypnutý",
|
||||||
|
|||||||
+115
-21
@@ -8,9 +8,10 @@
|
|||||||
//! - **Only that page gets the bridge.** The one capability names the `main` window and no remote
|
//! - **Only that page gets the bridge.** The one capability names the `main` window and no remote
|
||||||
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
||||||
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
||||||
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and
|
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
|
||||||
//! `lembas service run` is the device (`agent.rs`). The app reads the CLI's state and runs its
|
//! approval, and `lembas login --token-stdin` takes it (a server without that: `lembas login
|
||||||
//! commands; it never writes the CLI's files.
|
//! --json`, a second approval); `lembas service run` is the device (`agent.rs`). The app reads
|
||||||
|
//! the CLI's state and runs its commands; it never writes the CLI's files.
|
||||||
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
@@ -23,7 +24,7 @@ mod settings;
|
|||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -282,7 +283,10 @@ enum SignIn {
|
|||||||
Failed { message: String },
|
Failed { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign the app in to the server: a device login for the `app` scope, approved in the browser.
|
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
|
||||||
|
/// Where the server can (`one_approval`) and this computer is not linked to it yet, the same
|
||||||
|
/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its
|
||||||
|
/// link; "Link again" is the way to replace it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
@@ -300,7 +304,8 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
announce(&handle);
|
announce(&handle);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let host = cli::hostname().unwrap_or_default();
|
let host = cli::hostname().unwrap_or_default();
|
||||||
let result = server::start_sign_in(&d, &host).and_then(|code| {
|
let with_agent = d.one_approval && !cli::linked_to(&d.base);
|
||||||
|
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
||||||
let _ = handle
|
let _ = handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_url(&code.verification_uri_complete, None::<&str>);
|
.open_url(&code.verification_uri_complete, None::<&str>);
|
||||||
@@ -314,21 +319,36 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
*busy = None;
|
*busy = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut agent_token = None;
|
||||||
let outcome = match result {
|
let outcome = match result {
|
||||||
Ok(account) => match server::save_account(&app.config_dir, &account) {
|
Ok(granted) => match server::save_account(&app.config_dir, &granted.account) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
*app.account.lock().unwrap() = Some(account);
|
*app.account.lock().unwrap() = Some(granted.account);
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
agent_token = granted.agent_token;
|
||||||
SignIn::Done
|
SignIn::Done
|
||||||
}
|
}
|
||||||
Err(e) => SignIn::Failed {
|
Err(e) => {
|
||||||
message: format!("The sign-in could not be saved: {e}"),
|
// Nobody holds either token now: give both up.
|
||||||
},
|
server::sign_out(&d.base, &granted.account.token);
|
||||||
|
if let Some(t) = granted.agent_token {
|
||||||
|
server::sign_out(&d.base, &t);
|
||||||
|
}
|
||||||
|
SignIn::Failed {
|
||||||
|
message: format!("The sign-in could not be saved: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(message) => SignIn::Failed { message },
|
Err(message) => SignIn::Failed { message },
|
||||||
};
|
};
|
||||||
let _ = handle.emit("signin", outcome);
|
let _ = handle.emit("signin", outcome);
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
|
if let Some(token) = agent_token {
|
||||||
|
if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
||||||
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
|
announce(&handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -387,6 +407,22 @@ async fn api(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One request to `/api/v2` that answers plain text: the file a `show` card names.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_text(handle: AppHandle, path: String) -> Result<String, ApiError> {
|
||||||
|
let Some((app_url, token)) = handle.state::<App>().door() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let result =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || server::request_text(&app_url, &token, &path))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||||
|
if matches!(&result, Err(e) if e.status == 401) {
|
||||||
|
token_refused(&handle);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// A frame of a reply being written, as the page hears it.
|
/// A frame of a reply being written, as the page hears it.
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct Frame {
|
struct Frame {
|
||||||
@@ -662,20 +698,15 @@ enum Linking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's
|
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's
|
||||||
/// browser (where the person is signed in to the server), then the agent started.
|
/// browser (where the person is signed in to the server), then the agent started. The way to
|
||||||
|
/// link again, and the only way on a server that gives no agent token with the app's sign-in.
|
||||||
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
let Some(server) = app.settings().server else {
|
let Some(server) = app.settings().server else {
|
||||||
return Err("Choose a server first.".into());
|
return Err("Choose a server first.".into());
|
||||||
};
|
};
|
||||||
{
|
claim_linking(&app)?;
|
||||||
let mut busy = app.linking.lock().unwrap();
|
let child = match Command::new(cli::binary())
|
||||||
if *busy {
|
|
||||||
return Err("Linking is already in progress.".into());
|
|
||||||
}
|
|
||||||
*busy = true;
|
|
||||||
}
|
|
||||||
let mut child = match Command::new(cli::binary())
|
|
||||||
.arg("login")
|
.arg("login")
|
||||||
.arg(&server)
|
.arg(&server)
|
||||||
.arg("--json")
|
.arg("--json")
|
||||||
@@ -690,6 +721,60 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
|||||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
follow_login(handle, child, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the agent's token from the app's own approval to the bundled CLI:
|
||||||
|
/// `lembas login --token-stdin --server <url> --json`. The token goes on the CLI's standard
|
||||||
|
/// input and nowhere else -- not an argument, not the environment, not a file of the app's --
|
||||||
|
/// and the CLI writes its own files, as `lembas login` does. If the CLI does not take it, the
|
||||||
|
/// token is given up on the server, so no live token is left that nobody holds.
|
||||||
|
fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
if let Err(e) = claim_linking(&app) {
|
||||||
|
server::sign_out(base, &token);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let spawned = Command::new(cli::binary())
|
||||||
|
.args(["login", "--token-stdin", "--server", base, "--json"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn();
|
||||||
|
let mut child = match spawned {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
*app.linking.lock().unwrap() = false;
|
||||||
|
server::sign_out(base, &token);
|
||||||
|
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
// Dropped at the end of this block: the CLI reads to the end of its input.
|
||||||
|
let _ = stdin.write_all(format!("{token}\n").as_bytes());
|
||||||
|
}
|
||||||
|
follow_login(handle, child, Some((base.to_string(), token)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claim_linking(app: &App) -> Result<(), String> {
|
||||||
|
let mut busy = app.linking.lock().unwrap();
|
||||||
|
if *busy {
|
||||||
|
return Err("Linking is already in progress.".into());
|
||||||
|
}
|
||||||
|
*busy = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a `lembas login --json` to its end: a code to approve (opened in the browser), then
|
||||||
|
/// `done` or `error`; the agent started on success. `revoke`: the server and the token to give
|
||||||
|
/// up if the login fails (the agent's token from the app's approval).
|
||||||
|
fn follow_login(
|
||||||
|
handle: &AppHandle,
|
||||||
|
mut child: std::process::Child,
|
||||||
|
mut revoke: Option<(String, String)>,
|
||||||
|
) {
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut failure: Option<String> = None;
|
let mut failure: Option<String> = None;
|
||||||
@@ -701,6 +786,12 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
match ev.get("event").and_then(Value::as_str) {
|
match ev.get("event").and_then(Value::as_str) {
|
||||||
Some("code") => {
|
Some("code") => {
|
||||||
|
// A CLI asking for an approval did not take the token it was handed
|
||||||
|
// (one too old for `--token-stdin`): give that token up now, and let
|
||||||
|
// this login go on as the two-step one.
|
||||||
|
if let Some((base, token)) = revoke.take() {
|
||||||
|
server::sign_out(&base, &token);
|
||||||
|
}
|
||||||
let field =
|
let field =
|
||||||
|k: &str| ev.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
|k: &str| ev.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||||
let uri = field("verification_uri_complete");
|
let uri = field("verification_uri_complete");
|
||||||
@@ -742,12 +833,14 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
|||||||
let _ = set_agent_wanted(&handle, true);
|
let _ = set_agent_wanted(&handle, true);
|
||||||
}
|
}
|
||||||
Some(message) => {
|
Some(message) => {
|
||||||
|
if let Some((base, token)) = revoke {
|
||||||
|
server::sign_out(&base, &token);
|
||||||
|
}
|
||||||
let _ = handle.emit("linking", Linking::Failed { message });
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
});
|
});
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The window, the tray and the menu ------------------------------------------------------------
|
// --- The window, the tray and the menu ------------------------------------------------------------
|
||||||
@@ -1160,6 +1253,7 @@ pub fn run() {
|
|||||||
sign_in_cancel,
|
sign_in_cancel,
|
||||||
sign_out,
|
sign_out,
|
||||||
api,
|
api,
|
||||||
|
api_text,
|
||||||
stream_open,
|
stream_open,
|
||||||
stream_close,
|
stream_close,
|
||||||
local_list,
|
local_list,
|
||||||
|
|||||||
+173
-75
@@ -2,22 +2,27 @@
|
|||||||
//! sign-in to it, and every request the app's pages make to it.
|
//! sign-in to it, and every request the app's pages make to it.
|
||||||
//!
|
//!
|
||||||
//! - **Discovery** (`/.well-known/lembas.json`) decides first. LLeMbas Desktop 1.0 needs a
|
//! - **Discovery** (`/.well-known/lembas.json`) decides first. LLeMbas Desktop 1.0 needs a
|
||||||
//! LLeMbas 2.0 server: one that speaks link protocol 3 and has the app's door (`/api/v2`) in a
|
//! LLeMbas 2.0 server: one that speaks link protocol 3 and has the app's door (`/api/v2`) in
|
||||||
//! version this app knows. An older server is refused with a sentence that says which side to
|
//! version 2. That is checked by `app_api`, not by the server's version number, which says 2
|
||||||
//! update, before anything is signed in.
|
//! only from its first 2.0 release on. An older server is refused with a sentence that says
|
||||||
|
//! which side to update, before anything is signed in.
|
||||||
//! - **The app's sign-in** is the server's device login (RFC 8628), the same one `lembas login`
|
//! - **The app's sign-in** is the server's device login (RFC 8628), the same one `lembas login`
|
||||||
//! uses, for one scope: `app`, "read and write your chats, as the web interface does". The
|
//! uses, for `app models`: "read and write your chats, as the web interface does", and the
|
||||||
|
//! speech routes. **One approval, two tokens**: where discovery says
|
||||||
|
//! `login.device.agent_scope`, the same login asks for the agent's scopes too
|
||||||
|
//! (`models memory link`), and the approval answers with a second token, the agent's, which is
|
||||||
|
//! handed straight to the bundled CLI (`lembas login --token-stdin`) and never kept here. The
|
||||||
//! approval page opens in the system's browser, where the person is signed in already. The
|
//! approval page opens in the system's browser, where the person is signed in already. The
|
||||||
//! token is kept in the app's config directory, readable by this user only.
|
//! app's token is kept in the app's config directory, readable by this user only.
|
||||||
//! - **The token stays in Rust.** The pages ask for `/api/v2/...` through a command and get the
|
//! - **The token stays in Rust.** The pages ask for `/api/v2/...` through a command and get the
|
||||||
//! JSON back; they never hold the token, so no page -- and nothing a page shows -- can send it
|
//! JSON back; they never hold the token, so no page -- and nothing a page shows -- can send it
|
||||||
//! anywhere. A request can only reach the server's `/api/v2`: the path is checked here.
|
//! anywhere. A request can only reach the server's `/api/v2`: the path is checked here.
|
||||||
//!
|
//!
|
||||||
//! The agent's own link is a different token, the CLI's (`lembas login`), which this app never
|
//! The agent's own link is a different token, the CLI's, which this app never keeps or reads
|
||||||
//! reads: a token that may accept work as a device is not one the chat window needs.
|
//! back: a token that may accept work as a device is not one the chat window needs.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -27,11 +32,15 @@ use serde_json::Value;
|
|||||||
|
|
||||||
/// The link protocol a 2.0 server speaks (harness spec `acp.md`, *Versions*).
|
/// The link protocol a 2.0 server speaks (harness spec `acp.md`, *Versions*).
|
||||||
pub const NEEDS_PROTOCOL: i64 = 3;
|
pub const NEEDS_PROTOCOL: i64 = 3;
|
||||||
/// The server's major version this app is written for.
|
/// The `/api/v2` contract this app is written against: discovery's `app_api`. A lower number is
|
||||||
pub const NEEDS_MAJOR: u64 = 2;
|
/// a server before 2.0 (its chats carry what this app no longer reads, and it has no `show`
|
||||||
/// The `/api/v2` contract this app is written against: discovery's `app_api`. A higher number is
|
/// card); a higher one is a change an existing client would misread, so this app says it is too
|
||||||
/// a change an existing client would misread, so this app says it is too old rather than guess.
|
/// old rather than guess.
|
||||||
pub const APP_API: i64 = 1;
|
pub const APP_API: i64 = 2;
|
||||||
|
/// The scopes the app's own token asks for: the chats, and the speech routes of `/v1`.
|
||||||
|
pub const APP_SCOPE: &str = "app models";
|
||||||
|
/// The scopes the agent's token asks for, in the same approval: what `lembas service` needs.
|
||||||
|
pub const AGENT_SCOPE: &str = "models memory link";
|
||||||
|
|
||||||
/// What discovery said, once it has passed every check.
|
/// What discovery said, once it has passed every check.
|
||||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||||
@@ -46,6 +55,9 @@ pub struct Discovery {
|
|||||||
pub device_code: String,
|
pub device_code: String,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub device_token: String,
|
pub device_token: String,
|
||||||
|
/// The server gives the agent's token from the app's own approval
|
||||||
|
/// (`login.device.agent_scope`): one approval, not two.
|
||||||
|
pub one_approval: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn agent(read: Duration) -> ureq::Agent {
|
fn agent(read: Duration) -> ureq::Agent {
|
||||||
@@ -113,18 +125,9 @@ pub fn discover(base: &str) -> Result<Discovery, String> {
|
|||||||
check(&body, base)
|
check(&body, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The leading number of a version: `2` of `2.0.0-beta.1`, `1` of `1.7.3`.
|
/// The checks, on discovery's answer: a LLeMbas server, a 2.0 one (protocol 3, and the app
|
||||||
fn major(version: &str) -> Option<u64> {
|
/// door in version 2), with an app door whose version this app knows. The server's version
|
||||||
let digits: String = version
|
/// number is not checked: a 2.0 server in the making still says 1.7 until its first beta.
|
||||||
.trim_start_matches('v')
|
|
||||||
.chars()
|
|
||||||
.take_while(|c| c.is_ascii_digit())
|
|
||||||
.collect();
|
|
||||||
digits.parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The checks, on discovery's answer: a LLeMbas server, a 2.0 one (protocol 3, version 2 or
|
|
||||||
/// later), with an app door whose version this app knows.
|
|
||||||
pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
|
pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
|
||||||
if body.get("service").and_then(Value::as_str) != Some("lembas") {
|
if body.get("service").and_then(Value::as_str) != Some("lembas") {
|
||||||
return Err(format!("{base} is not a LLeMbas server."));
|
return Err(format!("{base} is not a LLeMbas server."));
|
||||||
@@ -143,8 +146,8 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
let new_enough =
|
let app_api = body.get("app_api").and_then(Value::as_i64).unwrap_or(0);
|
||||||
protocols.contains(&NEEDS_PROTOCOL) && major(&version).is_some_and(|m| m >= NEEDS_MAJOR);
|
let new_enough = protocols.contains(&NEEDS_PROTOCOL) && app_api >= APP_API;
|
||||||
if !new_enough {
|
if !new_enough {
|
||||||
let runs = if version.is_empty() {
|
let runs = if version.is_empty() {
|
||||||
"an older LLeMbas".to_string()
|
"an older LLeMbas".to_string()
|
||||||
@@ -156,12 +159,6 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
|
|||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let app_api = body.get("app_api").and_then(Value::as_i64).unwrap_or(0);
|
|
||||||
if app_api < 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"{base} has no app interface (/api/v2). Update the server to LLeMbas 2.0 or later."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if app_api > APP_API {
|
if app_api > APP_API {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{base} speaks a newer app interface (version {app_api}) than LLeMbas Desktop {} knows (version {APP_API}). Update LLeMbas Desktop.",
|
"{base} speaks a newer app interface (version {app_api}) than LLeMbas Desktop {} knows (version {APP_API}). Update LLeMbas Desktop.",
|
||||||
@@ -195,6 +192,10 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
|
|||||||
app,
|
app,
|
||||||
device_code,
|
device_code,
|
||||||
device_token,
|
device_token,
|
||||||
|
one_approval: device
|
||||||
|
.and_then(|d| d.get("agent_scope"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,25 +269,37 @@ pub struct Code {
|
|||||||
pub device_code: String,
|
pub device_code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a device login for the `app` scope.
|
/// What a device login asks the server for, as the form it posts.
|
||||||
pub fn start_sign_in(d: &Discovery, host: &str) -> Result<Code, String> {
|
fn code_request(host: &str, with_agent: bool) -> Value {
|
||||||
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
||||||
let device_name = if host.is_empty() {
|
let device_name = if host.is_empty() {
|
||||||
"LLeMbas Desktop".to_string()
|
"LLeMbas Desktop".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("LLeMbas Desktop on {host}")
|
format!("LLeMbas Desktop on {host}")
|
||||||
};
|
};
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"client_id": format!("llembas-desktop {}", env!("CARGO_PKG_VERSION")),
|
||||||
|
"device_name": device_name,
|
||||||
|
"device_platform": platform,
|
||||||
|
"scope": APP_SCOPE,
|
||||||
|
});
|
||||||
|
if with_agent {
|
||||||
|
// The agent's token is the device an agent chat runs on: it carries this computer's
|
||||||
|
// host, as `lembas login` sends it, so the app knows which device is this one. The
|
||||||
|
// app's own token alone names no host: it is not a device.
|
||||||
|
body["device_host"] = Value::String(host.to_string());
|
||||||
|
body["agent_scope"] = Value::String(AGENT_SCOPE.to_string());
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
|
||||||
|
/// discovery offered it).
|
||||||
|
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
|
||||||
let res = agent(Duration::from_secs(15))
|
let res = agent(Duration::from_secs(15))
|
||||||
.post(&d.device_code)
|
.post(&d.device_code)
|
||||||
.set("accept", "application/json")
|
.set("accept", "application/json")
|
||||||
.send_json(serde_json::json!({
|
.send_json(code_request(host, with_agent && d.one_approval))
|
||||||
"client_id": format!("llembas-desktop {}", env!("CARGO_PKG_VERSION")),
|
|
||||||
"device_name": device_name,
|
|
||||||
// No host on purpose: the host names the device an agent chat runs on, and that is
|
|
||||||
// the CLI's link, not this sign-in.
|
|
||||||
"device_platform": platform,
|
|
||||||
"scope": "app",
|
|
||||||
}))
|
|
||||||
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
||||||
let v: Value = res
|
let v: Value = res
|
||||||
.into_json()
|
.into_json()
|
||||||
@@ -327,12 +340,45 @@ fn oauth_error(e: ureq::Error, what: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What an approval gave: the app's sign-in, and the agent's token when the login asked for one.
|
||||||
|
/// The agent's token is not kept: it goes to the CLI and is dropped.
|
||||||
|
pub struct Granted {
|
||||||
|
pub account: Account,
|
||||||
|
pub agent_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token endpoint's answer, once it carries a token.
|
||||||
|
fn granted(v: &Value, server: &str) -> Option<Granted> {
|
||||||
|
let token = v.get("access_token").and_then(Value::as_str)?;
|
||||||
|
let account = v.get("account");
|
||||||
|
let field = |k: &str| {
|
||||||
|
account
|
||||||
|
.and_then(|a| a.get(k))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
Some(Granted {
|
||||||
|
account: Account {
|
||||||
|
server: server.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
email: field("email"),
|
||||||
|
name: field("name"),
|
||||||
|
},
|
||||||
|
agent_token: v
|
||||||
|
.get("agent_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait for the person to approve the code (or not), checking `cancelled` while waiting.
|
/// Wait for the person to approve the code (or not), checking `cancelled` while waiting.
|
||||||
pub fn await_sign_in(
|
pub fn await_sign_in(
|
||||||
d: &Discovery,
|
d: &Discovery,
|
||||||
code: &Code,
|
code: &Code,
|
||||||
cancelled: &AtomicBool,
|
cancelled: &AtomicBool,
|
||||||
) -> Result<Account, String> {
|
) -> Result<Granted, String> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(code.expires_in.max(1));
|
let deadline = Instant::now() + Duration::from_secs(code.expires_in.max(1));
|
||||||
let mut interval = Duration::from_secs(code.interval);
|
let mut interval = Duration::from_secs(code.interval);
|
||||||
let http = agent(Duration::from_secs(15));
|
let http = agent(Duration::from_secs(15));
|
||||||
@@ -358,21 +404,8 @@ pub fn await_sign_in(
|
|||||||
return Err(format!("Could not reach the server: {t}"))
|
return Err(format!("Could not reach the server: {t}"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(token) = v.get("access_token").and_then(Value::as_str) {
|
if let Some(g) = granted(&v, &d.base) {
|
||||||
let account = v.get("account");
|
return Ok(g);
|
||||||
let field = |k: &str| {
|
|
||||||
account
|
|
||||||
.and_then(|a| a.get(k))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
};
|
|
||||||
return Ok(Account {
|
|
||||||
server: d.base.clone(),
|
|
||||||
token: token.to_string(),
|
|
||||||
email: field("email"),
|
|
||||||
name: field("name"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
match v.get("error").and_then(Value::as_str) {
|
match v.get("error").and_then(Value::as_str) {
|
||||||
Some("authorization_pending") => {}
|
Some("authorization_pending") => {}
|
||||||
@@ -388,8 +421,8 @@ pub fn await_sign_in(
|
|||||||
Err("The code expired before it was approved. Sign in again.".into())
|
Err("The code expired before it was approved. Sign in again.".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Give the token up on the server (it is gone from the server's device list then). Best effort:
|
/// Give a token up on the server (the app's: gone from the account's apps then; the agent's:
|
||||||
/// the app forgets it either way.
|
/// one the CLI did not take). Best effort: the app forgets it either way.
|
||||||
pub fn sign_out(base: &str, token: &str) {
|
pub fn sign_out(base: &str, token: &str) {
|
||||||
let _ = agent(Duration::from_secs(10))
|
let _ = agent(Duration::from_secs(10))
|
||||||
.delete(&format!("{base}/api/v1/token"))
|
.delete(&format!("{base}/api/v1/token"))
|
||||||
@@ -452,6 +485,28 @@ pub fn request(
|
|||||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
|
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most a plain-text answer may be: the server's own limit for a shown file (1 MB), and a
|
||||||
|
/// little room. Anything longer is not read to the end.
|
||||||
|
pub const TEXT_LIMIT: u64 = 1024 * 1024 + 4096;
|
||||||
|
|
||||||
|
/// One `GET` on `/api/v2` that answers plain text, not JSON: the file a `show` card names
|
||||||
|
/// (`/chats/{chat}/show?…`). An error is still the server's JSON `detail` -- 404 when the file
|
||||||
|
/// is no longer the chat's to show, 413 when it is too large, 415 when it is not text.
|
||||||
|
pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
||||||
|
let path = checked_path(path)?;
|
||||||
|
let res = agent(Duration::from_secs(60))
|
||||||
|
.get(&format!("{app}{path}"))
|
||||||
|
.set("authorization", &format!("Bearer {token}"))
|
||||||
|
.set("accept", "text/plain")
|
||||||
|
.call()?;
|
||||||
|
let mut text = String::new();
|
||||||
|
res.into_reader()
|
||||||
|
.take(TEXT_LIMIT)
|
||||||
|
.read_to_string(&mut text)
|
||||||
|
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Sse {
|
pub struct Sse {
|
||||||
@@ -537,11 +592,12 @@ mod tests {
|
|||||||
"name": "Example",
|
"name": "Example",
|
||||||
"protocol": 3,
|
"protocol": 3,
|
||||||
"protocols": [3],
|
"protocols": [3],
|
||||||
"app_api": 1,
|
"app_api": 2,
|
||||||
"api": {"app": "https://ai.example/api/v2"},
|
"api": {"app": "https://ai.example/api/v2"},
|
||||||
"login": {"device": {
|
"login": {"device": {
|
||||||
"code": "https://ai.example/api/device/code",
|
"code": "https://ai.example/api/device/code",
|
||||||
"token": "https://ai.example/api/device/token"
|
"token": "https://ai.example/api/device/token",
|
||||||
|
"agent_scope": true
|
||||||
}}
|
}}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -552,6 +608,54 @@ mod tests {
|
|||||||
assert_eq!(d.app, "https://ai.example/api/v2");
|
assert_eq!(d.app, "https://ai.example/api/v2");
|
||||||
assert_eq!(d.name, "Example");
|
assert_eq!(d.name, "Example");
|
||||||
assert_eq!(d.device_token, "https://ai.example/api/device/token");
|
assert_eq!(d.device_token, "https://ai.example/api/device/token");
|
||||||
|
assert!(d.one_approval);
|
||||||
|
// A server in the making still says 1.7 until its first 2.0 beta: `app_api` decides.
|
||||||
|
let mut dev = two_oh();
|
||||||
|
dev["version"] = json!("1.7.2");
|
||||||
|
assert!(check(&dev, "https://ai.example").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_agent_scope_in_discovery_there_are_two_approvals() {
|
||||||
|
let mut older = two_oh();
|
||||||
|
older["login"]["device"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("agent_scope");
|
||||||
|
assert!(!check(&older, "https://ai.example").unwrap().one_approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_approval_asks_for_both_tokens_and_only_then_names_the_host() {
|
||||||
|
let both = code_request("workbench", true);
|
||||||
|
assert_eq!(both["scope"], "app models");
|
||||||
|
assert_eq!(both["agent_scope"], "models memory link");
|
||||||
|
assert_eq!(both["device_host"], "workbench");
|
||||||
|
assert_eq!(both["device_name"], "LLeMbas Desktop on workbench");
|
||||||
|
let app_only = code_request("workbench", false);
|
||||||
|
assert_eq!(app_only["scope"], "app models");
|
||||||
|
assert!(app_only.get("agent_scope").is_none());
|
||||||
|
assert!(app_only.get("device_host").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
|
||||||
|
let v = json!({
|
||||||
|
"access_token": "lmb_app", "token_type": "Bearer", "scope": "app models",
|
||||||
|
"account": {"email": "a@example.org", "name": "A"},
|
||||||
|
"base_url": "https://ai.example",
|
||||||
|
"agent_token": "lmb_agent", "agent_scope": "models memory link"
|
||||||
|
});
|
||||||
|
let g = granted(&v, "https://ai.example").unwrap();
|
||||||
|
assert_eq!(g.account.token, "lmb_app");
|
||||||
|
assert_eq!(g.account.email, "a@example.org");
|
||||||
|
assert_eq!(g.agent_token.as_deref(), Some("lmb_agent"));
|
||||||
|
let one = json!({"access_token": "lmb_app", "account": {}});
|
||||||
|
assert_eq!(
|
||||||
|
granted(&one, "https://ai.example").unwrap().agent_token,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(granted(&json!({"error": "authorization_pending"}), "x").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -561,11 +665,13 @@ mod tests {
|
|||||||
let why = check(&old, "https://ai.example").unwrap_err();
|
let why = check(&old, "https://ai.example").unwrap_err();
|
||||||
assert!(why.contains("LLeMbas 1.7.3"), "{why}");
|
assert!(why.contains("LLeMbas 1.7.3"), "{why}");
|
||||||
assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}");
|
assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}");
|
||||||
// Protocol 3 alone is not enough: the version must say 2.
|
// Protocol 3 alone is not enough: the app door must be version 2 (1.7 serves 1).
|
||||||
let mut early = two_oh();
|
let mut early = two_oh();
|
||||||
early["version"] = json!("1.7.2");
|
early["version"] = json!("1.7.3");
|
||||||
assert!(check(&early, "https://ai.example").is_err());
|
early["app_api"] = json!(1);
|
||||||
// Version 2 without protocol 3 is not one either.
|
let why = check(&early, "https://ai.example").unwrap_err();
|
||||||
|
assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}");
|
||||||
|
// The app door without protocol 3 is not one either.
|
||||||
let mut odd = two_oh();
|
let mut odd = two_oh();
|
||||||
odd["protocols"] = json!([1, 2]);
|
odd["protocols"] = json!([1, 2]);
|
||||||
assert!(check(&odd, "https://ai.example").is_err());
|
assert!(check(&odd, "https://ai.example").is_err());
|
||||||
@@ -585,14 +691,6 @@ mod tests {
|
|||||||
assert!(check(¬_ours, "https://ai.example").is_err());
|
assert!(check(¬_ours, "https://ai.example").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn versions_read_their_major() {
|
|
||||||
assert_eq!(major("2.0.0-beta.1"), Some(2));
|
|
||||||
assert_eq!(major("v10.1.0"), Some(10));
|
|
||||||
assert_eq!(major("1.7.3"), Some(1));
|
|
||||||
assert_eq!(major(""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_page_reaches_only_the_app_door() {
|
fn a_page_reaches_only_the_app_door() {
|
||||||
assert!(checked_path("/chats?kind=agent&limit=50").is_ok());
|
assert!(checked_path("/chats?kind=agent&limit=50").is_ok());
|
||||||
|
|||||||
+8
-8
@@ -109,6 +109,7 @@ const LEMBAS_I18N = {
|
|||||||
"The agent is starting…": "Agent se spouští…",
|
"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 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 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 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 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í",
|
"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 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 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 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",
|
"Thought": "Přemýšlel",
|
||||||
"Try again": "Zkusit znovu",
|
"Try again": "Zkusit znovu",
|
||||||
"Waiting for the browser…": "Čeká se na prohlížeč…",
|
"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 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 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 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 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 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",
|
"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 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 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 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",
|
"Thought": "Nachgedacht",
|
||||||
"Try again": "Erneut versuchen",
|
"Try again": "Erneut versuchen",
|
||||||
"Waiting for the browser…": "Warte auf den Browser…",
|
"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 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 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 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 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 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í",
|
"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 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 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 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",
|
"Thought": "Pensamiento",
|
||||||
"Try again": "Reintentar",
|
"Try again": "Reintentar",
|
||||||
"Waiting for the browser…": "Esperando al navegador…",
|
"Waiting for the browser…": "Esperando al navegador…",
|
||||||
@@ -487,6 +487,7 @@ const LEMBAS_I18N = {
|
|||||||
"The agent is starting…": "L'agent démarre…",
|
"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 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 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 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 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",
|
"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 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 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 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",
|
"Thought": "Réflexion",
|
||||||
"Try again": "Réessayer",
|
"Try again": "Réessayer",
|
||||||
"Waiting for the browser…": "En attente du navigateur…",
|
"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 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 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 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 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 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",
|
"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 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 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 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",
|
"Thought": "Gondolkodott",
|
||||||
"Try again": "Újrapróbálás",
|
"Try again": "Újrapróbálás",
|
||||||
"Waiting for the browser…": "Várakozás a böngészőre…",
|
"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 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 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 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 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 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",
|
"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 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 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 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",
|
"Thought": "Ha ragionato",
|
||||||
"Try again": "Riprova",
|
"Try again": "Riprova",
|
||||||
"Waiting for the browser…": "In attesa del browser…",
|
"Waiting for the browser…": "In attesa del browser…",
|
||||||
@@ -865,6 +865,7 @@ const LEMBAS_I18N = {
|
|||||||
"The agent is starting…": "Agent się uruchamia…",
|
"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 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 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 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 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ć",
|
"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 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 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 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",
|
"Thought": "Rozumowanie",
|
||||||
"Try again": "Spróbuj ponownie",
|
"Try again": "Spróbuj ponownie",
|
||||||
"Waiting for the browser…": "Czekam na przeglądarkę…",
|
"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 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 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 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 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 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",
|
"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 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 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 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",
|
"Thought": "Premýšľal",
|
||||||
"Try again": "Skúsiť znova",
|
"Try again": "Skúsiť znova",
|
||||||
"Waiting for the browser…": "Čaká sa na prehliadač…",
|
"Waiting for the browser…": "Čaká sa na prehliadač…",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const get = (path) => api("GET", path)
|
|||||||
export const post = (path, body) => api("POST", path, body ?? {})
|
export const post = (path, body) => api("POST", path, body ?? {})
|
||||||
export const patch = (path, body) => api("PATCH", path, body)
|
export const patch = (path, body) => api("PATCH", path, body)
|
||||||
export const del = (path) => api("DELETE", path)
|
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. */
|
/** An error's words, whatever shape it came in. */
|
||||||
export function why(err) {
|
export function why(err) {
|
||||||
|
|||||||
+33
-17
@@ -7,10 +7,10 @@
|
|||||||
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
|
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
|
||||||
|
|
||||||
import { $, h, clear, append } from "./dom.js"
|
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 { markdown } from "./markdown.js"
|
||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal, readServer } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
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) {
|
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.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.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.") }))
|
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(
|
const summary = h(
|
||||||
"summary",
|
"summary",
|
||||||
{ class: "call__head" },
|
{ 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. */
|
/** A file the agent showed: from this computer when the chat runs here, else from the server. */
|
||||||
async function openShown(call, chat) {
|
async function openShown(call, chat) {
|
||||||
const path = shownPath(call)
|
const src = shownSource(call, chat)
|
||||||
const kind = call.show?.kind
|
if (!src) return
|
||||||
|
const path = src.path
|
||||||
const { language } = showKind(path)
|
const { language } = showKind(path)
|
||||||
const line = call.show?.line || call.args?.line || 0
|
const line = call.show?.line || (src.local ? call.args?.line : 0) || 0
|
||||||
const base = { name: path.split("/").pop(), path, kind, language: call.show?.language || language, line }
|
const base = { name: call.show?.name || path.split("/").pop(), path, kind: call.show?.kind, language: call.show?.language || language, line }
|
||||||
try {
|
try {
|
||||||
if (onThisComputer(chat) && chat.device.directory) {
|
if (src.local) {
|
||||||
const dir = chat.device.directory.replace(/\/+$/, "")
|
const dir = chat.device.directory.replace(/\/+$/, "")
|
||||||
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
|
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
|
||||||
const f = await readLocal(dir, rel)
|
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 })
|
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 {
|
} else {
|
||||||
const f = await readServer(path)
|
// Plain text, at most 1 MB; the server's 404 (no longer the chat's), 413 (too large) and
|
||||||
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text: f.text, withheld: f.withheld, size: f.size })
|
// 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) {
|
} catch (err) {
|
||||||
openViewer({ ...base, where: t("Shown by the agent"), error: why(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 { invoke, get, why } from "./bridge.js"
|
||||||
import { openViewer } from "./viewer.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
|
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
||||||
// /files pages take the session cookie and answer HTML); these are the routes this pane is written
|
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
|
||||||
// against, and a 404 says "not on this server yet" instead of failing:
|
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
|
||||||
// GET /files?path=/a -> { path, entries: [{ name, path, kind: "dir"|"file", size, mime }] }
|
// A path the person cannot read is the server's 404, in its words.
|
||||||
// GET /files/content?path= -> { path, name, size, text | null, withheld?: "binary"|"too_big" }
|
|
||||||
export const SERVER_FILES = {
|
export const SERVER_FILES = {
|
||||||
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
||||||
read: (path) => `/files/content?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. */
|
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
||||||
function tree(box, { list, open, rootPath }) {
|
function tree(box, { list, open, rootPath }) {
|
||||||
clear(box)
|
clear(box)
|
||||||
@@ -33,15 +35,15 @@ function tree(box, { list, open, rootPath }) {
|
|||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const row = h(
|
const row = h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": e.kind === "dir" ? "false" : null },
|
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
|
||||||
h("span", { class: "tree__chevron" }, e.kind === "dir" ? icon(ICONS.chevron) : null),
|
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
|
||||||
icon(e.kind === "dir" ? ICONS.folder : ICONS.file),
|
icon(isDir(e) ? ICONS.folder : ICONS.file),
|
||||||
h("span", { class: "tree__name", text: e.name }),
|
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))
|
row.style.setProperty("--depth", String(depth))
|
||||||
const li = h("li", { role: "treeitem" }, row)
|
const li = h("li", { role: "treeitem" }, row)
|
||||||
if (e.kind === "dir") {
|
if (isDir(e)) {
|
||||||
let kids = null
|
let kids = null
|
||||||
row.addEventListener("click", async () => {
|
row.addEventListener("click", async () => {
|
||||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
const opening = row.getAttribute("aria-expanded") !== "true"
|
||||||
@@ -127,22 +129,22 @@ export function renderServer() {
|
|||||||
const box = $("server-tree")
|
const box = $("server-tree")
|
||||||
tree(box, {
|
tree(box, {
|
||||||
rootPath: "/",
|
rootPath: "/",
|
||||||
list: async (path) => {
|
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||||
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) => {
|
open: async (e) => {
|
||||||
const where = t("Server")
|
const where = t("Server")
|
||||||
try {
|
try {
|
||||||
const f = await readServer(e.path)
|
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) {
|
} catch (err) {
|
||||||
openViewer({ name: e.name, path: e.path, where, error: why(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
|
// 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
|
// 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
|
// 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 { $, h, clear } from "./dom.js"
|
||||||
import { invoke, listen, why } from "./bridge.js"
|
import { invoke, listen, why } from "./bridge.js"
|
||||||
@@ -60,7 +61,8 @@ export function renderSetup(s) {
|
|||||||
|
|
||||||
if (s.discovery && !s.account) {
|
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 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) {
|
if (s.signing_in && code) {
|
||||||
sign.append(
|
sign.append(
|
||||||
h("p", null, t("Approve this code in the page that opened in your browser:")),
|
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)
|
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?,
|
* 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) {
|
export async function openViewer(file) {
|
||||||
teardown()
|
teardown()
|
||||||
@@ -68,15 +81,7 @@ export async function openViewer(file) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (file.withheld) {
|
if (file.withheld) {
|
||||||
body.append(
|
body.append(h("p", { class: "viewer__note", text: withheldWords(file.withheld, file.size) }))
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
const text = file.text ?? ""
|
const text = file.text ?? ""
|
||||||
|
|||||||
Reference in new issue
Block a user