Daily use: attachments, edit and regenerate, the chat's menu, model avatars, a palette
ci / check (push) Failing after 4s
ci / start (push) Failing after 3s

- Attachments from the file dialog, a drop on the window (Rust holds the dropped paths; the page
  never names one) or a paste (the bytes as the raw body of the call), sent at once to
  /api/v2/attachments as multipart and shown above the composer.
- Edit message (rewind), Regenerate reply on the last reply, Copy on both.
- The chat's menu (header and a right click in the sidebar): rename, open in the web interface,
  archive, delete.
- The chat's model and effort in its header; effort for a new chat. Model avatars coloured as
  the server's stable_hue colours them (MD5 of the id, checked against Python), mixed into the
  theme's surface so no colour is hard-coded.
- Ctrl+K palette over commands and every chat's title; Ctrl+N, Ctrl+,.
- The agent's new states in the page; 34 new sentences in all eight languages, the server's own
  words where it has them; a Czech row that was Slovak; two untranslated strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 08:15:31 +00:00
1 parent 7ee394b549
commit 2a51b4f6d0
16 files changed
+1840 -72

No files matched your search

+38
View File
@@ -6,6 +6,44 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased]
### Added
- **Attachments.** A message can carry files: the attach button beside the composer (the
system's file dialog), files dropped anywhere on the window, or an image pasted into the
composer. Each is sent to the server at once and shown above the composer with its size, or
with the server's reason when it refused it; one taken off before sending is deleted on the
server. The app sends only files you chose, dropped or pasted -- the page never names a path.
Agent chats get them too.
- **Edit a message, regenerate a reply, copy either.** Under your messages, *Edit message*
rewrites one in place and starts the reply again from there (everything after it is dropped,
as the app says before you save; on the agent's device too); under the last reply,
*Regenerate reply*. *Copy* puts a message, or a reply's text as Markdown, on the clipboard.
- **The chat's own menu** (⋯ in its header, or a right click on it in the sidebar): rename it,
open it in the web interface in your browser, archive it, delete it (asked first).
- **Change a chat's model** from its header, and its reasoning effort where the model offers
more than one level; a new chat can choose its effort too.
- **Model avatars**: every model in the pickers and under each reply shows its initial on a
colour of its own -- the same colour the web interface gives it.
- **A command palette** (Ctrl+K): new chats, settings, the agent and updates by name, and every
chat of both kinds by its title (accents optional). Ctrl+N starts a new chat of the tab you are
on; Ctrl+, opens the settings.
### Changed
- **The agent's status is the CLI's own word.** The title bar and Settings no longer say
*running* because a process runs: they say what the built-in LLeMbas CLI says of its link to
the server -- connecting, running (linked), waiting and why (the server cannot be reached, the
link dropped, a terminal on this computer holds the link), or refused and why. The same for a
`lembas service` you run yourself. When the CLI says trying again will not help (the server
refused this computer's token), the app no longer restarts it every second: it waits until you
link the computer again. When the agent stops with an error, its last words are the reason
shown, not just an exit status.
### Fixed
- A Czech sentence in the viewer was in Slovak; "Server files" and the composer's placeholder
were not translated.
## [1.0.0-beta.2] — 2026-11-07
Carries LLeMbas CLI 2.0.0-beta.2 and the look of LLeMbas 2.0.0-beta.4.
+7
View File
@@ -12,6 +12,11 @@ an app of its own.
on the server.
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
- **A command palette** (Ctrl+K) for commands and every chat by its title.
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
waiting and why -- not whether a process runs.
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
- **Its own window and tray**: close the window and the agent keeps running in the tray.
@@ -73,6 +78,8 @@ device.
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
holds the token.
- The explorer reads only inside the agent's folders.
- A file goes to the server as an attachment only when you choose it in the file dialog, drop it
on the window or paste it: the page cannot name a path for the app to send.
- HTML is shown in a sealed frame on an address of its own: scripts in it run, but it has no
access to the app, no network and no way to navigate anywhere.
- Links in a reply open in your browser, and only web and mail links.
+195 -9
View File
@@ -156,7 +156,6 @@ KEYS = (
"agent running (service)",
"agent stopped",
"agent: no folders",
"Agent stopped: %(why)s",
"update %(version)s",
"update",
"LLeMbas %(version)s is available (this is %(current)s)",
@@ -167,6 +166,41 @@ KEYS = (
"Page",
"Preview",
"Source",
# 1.0 for daily use: attachments, message and chat actions, the palette, the agent's word
"This is a folder; attach the files in it.",
"This file is too large to send.",
"Sending…",
"Saving starts the reply again; everything after this message is dropped.",
"Rename…",
"Open in the web interface",
"Delete this chat?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.",
"Wait until the files are sent.",
"Type a command or a chat's title",
"The agent is connecting to the server…",
"The agent is not connected: %(why)s. It keeps trying.",
"The agent will not connect: %(why)s. Link this computer again.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s",
"agent connecting…",
"agent waiting",
"agent refused",
"Agent: %(why)s",
"Server files",
"Drop the files to attach them",
"Attach files",
"Edit message",
"Save",
"Rename chat",
"Rename",
"Delete",
"Search",
"Nothing matches.",
"Message",
"Archive chat",
"Regenerate reply",
"Delete chat",
"Reasoning effort",
"Commands",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -265,7 +299,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agent běží (služba)",
"agent stopped": "agent zastaven",
"agent: no folders": "agent: žádné složky",
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
"update %(version)s": "aktualizace %(version)s",
"update": "aktualizace",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
@@ -273,6 +306,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "Toto není textový soubor; nelze ho zde zobrazit.",
"Page": "Stránka",
"Source": "Zdroj",
"This is a folder; attach the files in it.": "Toto je složka; přiložte soubory v ní.",
"This file is too large to send.": "Tento soubor je příliš velký na odeslání.",
"Sending…": "Odesílá se…",
"Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.",
"Rename…": "Přejmenovat…",
"Open in the web interface": "Otevřít ve webovém rozhraní",
"Delete this chat?": "Smazat tuto konverzaci?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ se smaže na serveru i se svými zprávami a soubory. Nelze to vrátit.",
"Wait until the files are sent.": "Počkejte, až se soubory odešlou.",
"Type a command or a chat's title": "Napište příkaz nebo název konverzace",
"The agent is connecting to the server…": "Agent se připojuje k serveru…",
"The agent is not connected: %(why)s. It keeps trying.": "Agent není připojený: %(why)s. Zkouší to dál.",
"The agent will not connect: %(why)s. Link this computer again.": "Agent se nepřipojí: %(why)s. Propojte tento počítač znovu.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI už tu běží (pid %(pid)s), ale není připojená: %(why)s",
"agent connecting…": "agent se připojuje…",
"agent waiting": "agent čeká",
"agent refused": "agent odmítnut",
"Agent: %(why)s": "Agent: %(why)s",
"Server files": "Soubory na serveru",
"Drop the files to attach them": "Pusťte soubory a přiloží se",
},
"de": {
"Menu": "Menü",
@@ -366,7 +419,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "Agent läuft (Dienst)",
"agent stopped": "Agent angehalten",
"agent: no folders": "Agent: keine Ordner",
"Agent stopped: %(why)s": "Agent angehalten: %(why)s",
"update %(version)s": "Update %(version)s",
"update": "Update",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
@@ -374,6 +426,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "Das ist keine Textdatei; sie kann hier nicht angezeigt werden.",
"Page": "Seite",
"Source": "Quelltext",
"This is a folder; attach the files in it.": "Das ist ein Ordner; hängen Sie die Dateien darin an.",
"This file is too large to send.": "Diese Datei ist zu groß zum Senden.",
"Sending…": "Wird gesendet…",
"Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.",
"Rename…": "Umbenennen…",
"Open in the web interface": "In der Weboberfläche öffnen",
"Delete this chat?": "Diesen Chat löschen?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ wird auf dem Server gelöscht, mit seinen Nachrichten und Dateien. Das lässt sich nicht rückgängig machen.",
"Wait until the files are sent.": "Warten Sie, bis die Dateien gesendet sind.",
"Type a command or a chat's title": "Befehl oder Chat-Titel eingeben",
"The agent is connecting to the server…": "Der Agent verbindet sich mit dem Server…",
"The agent is not connected: %(why)s. It keeps trying.": "Der Agent ist nicht verbunden: %(why)s. Er versucht es weiter.",
"The agent will not connect: %(why)s. Link this computer again.": "Der Agent verbindet sich nicht: %(why)s. Verknüpfen Sie diesen Computer erneut.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s), ist aber nicht verbunden: %(why)s",
"agent connecting…": "Agent verbindet…",
"agent waiting": "Agent wartet",
"agent refused": "Agent abgewiesen",
"Agent: %(why)s": "Agent: %(why)s",
"Server files": "Dateien auf dem Server",
"Drop the files to attach them": "Dateien ablegen, um sie anzuhängen",
},
"es": {
"Menu": "Menú",
@@ -467,7 +539,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agente en marcha (servicio)",
"agent stopped": "agente detenido",
"agent: no folders": "agente: sin carpetas",
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
"update %(version)s": "actualizar a %(version)s",
"update": "actualizar",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
@@ -475,6 +546,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "No es un archivo de texto; no se puede mostrar aquí.",
"Page": "Página",
"Source": "Código fuente",
"This is a folder; attach the files in it.": "Esto es una carpeta; adjunte los archivos que contiene.",
"This file is too large to send.": "Este archivo es demasiado grande para enviarlo.",
"Sending…": "Enviando…",
"Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.",
"Rename…": "Renombrar…",
"Open in the web interface": "Abrir en la interfaz web",
"Delete this chat?": "¿Eliminar este chat?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» se elimina del servidor, con sus mensajes y archivos. No se puede deshacer.",
"Wait until the files are sent.": "Espere a que se envíen los archivos.",
"Type a command or a chat's title": "Escriba un comando o el título de un chat",
"The agent is connecting to the server…": "El agente se está conectando al servidor…",
"The agent is not connected: %(why)s. It keeps trying.": "El agente no está conectado: %(why)s. Sigue intentándolo.",
"The agent will not connect: %(why)s. Link this computer again.": "El agente no se conectará: %(why)s. Vuelva a vincular este equipo.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ya hay un servicio de LLeMbas CLI aquí (pid %(pid)s), pero no está conectado: %(why)s",
"agent connecting…": "agente conectando…",
"agent waiting": "agente en espera",
"agent refused": "agente rechazado",
"Agent: %(why)s": "Agente: %(why)s",
"Server files": "Archivos del servidor",
"Drop the files to attach them": "Suelte los archivos para adjuntarlos",
},
"fr": {
"Menu": "Menu",
@@ -568,7 +659,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agent actif (service)",
"agent stopped": "agent interrompu",
"agent: no folders": "agent : aucun dossier",
"Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s",
"update %(version)s": "mise à jour %(version)s",
"update": "mise à jour",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
@@ -576,6 +666,26 @@ OWN: dict[str, dict[str, str]] = {
"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.",
"Page": "Page",
"Source": "Source",
"This is a folder; attach the files in it.": "C'est un dossier ; joignez les fichiers qu'il contient.",
"This file is too large to send.": "Ce fichier est trop volumineux pour être envoyé.",
"Sending…": "Envoi…",
"Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.",
"Rename…": "Renommer…",
"Open in the web interface": "Ouvrir dans l'interface web",
"Delete this chat?": "Supprimer cette conversation ?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "« %(title)s » est supprimée du serveur, avec ses messages et ses fichiers. C'est définitif.",
"Wait until the files are sent.": "Attendez que les fichiers soient envoyés.",
"Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation",
"The agent is connecting to the server…": "L'agent se connecte au serveur…",
"The agent is not connected: %(why)s. It keeps trying.": "L'agent n'est pas connecté : %(why)s. Il réessaie.",
"The agent will not connect: %(why)s. Link this computer again.": "L'agent ne se connectera pas : %(why)s. Reliez à nouveau cet ordinateur.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s), mais n'est pas connecté : %(why)s",
"agent connecting…": "agent en connexion…",
"agent waiting": "agent en attente",
"agent refused": "agent refusé",
"Agent: %(why)s": "Agent : %(why)s",
"Server files": "Fichiers du serveur",
"Drop the files to attach them": "Déposez les fichiers pour les joindre",
},
"hu": {
"Menu": "Menü",
@@ -669,7 +779,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "ügynök fut (szolgáltatás)",
"agent stopped": "ügynök leállt",
"agent: no folders": "ügynök: nincs mappa",
"Agent stopped: %(why)s": "Az ügynök leállt: %(why)s",
"update %(version)s": "frissítés: %(version)s",
"update": "frissítés",
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
@@ -677,6 +786,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "Ez nem szövegfájl; itt nem jeleníthető meg.",
"Page": "Oldal",
"Source": "Forrás",
"This is a folder; attach the files in it.": "Ez egy mappa; a benne lévő fájlokat csatolja.",
"This file is too large to send.": "Ez a fájl túl nagy a küldéshez.",
"Sending…": "Küldés…",
"Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.",
"Rename…": "Átnevezés…",
"Open in the web interface": "Megnyitás a webes felületen",
"Delete this chat?": "Törli ezt a csevegést?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” törlődik a szerverről, az üzeneteivel és fájljaival együtt. Ez nem vonható vissza.",
"Wait until the files are sent.": "Várjon, amíg a fájlok elküldődnek.",
"Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét",
"The agent is connecting to the server…": "Az ügynök csatlakozik a szerverhez…",
"The agent is not connected: %(why)s. It keeps trying.": "Az ügynök nincs csatlakozva: %(why)s. Tovább próbálkozik.",
"The agent will not connect: %(why)s. Link this computer again.": "Az ügynök nem fog csatlakozni: %(why)s. Kapcsolja össze újra ezt a számítógépet.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Egy LLeMbas CLI szolgáltatás már fut itt (pid %(pid)s), de nincs csatlakozva: %(why)s",
"agent connecting…": "ügynök csatlakozik…",
"agent waiting": "ügynök vár",
"agent refused": "ügynök elutasítva",
"Agent: %(why)s": "Ügynök: %(why)s",
"Server files": "Fájlok a szerveren",
"Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz",
},
"it": {
"Menu": "Menu",
@@ -770,7 +899,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agente attivo (servizio)",
"agent stopped": "agente fermato",
"agent: no folders": "agente: nessuna cartella",
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
"update %(version)s": "aggiorna a %(version)s",
"update": "aggiorna",
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
@@ -778,6 +906,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "Questo non è un file di testo; non può essere mostrato qui.",
"Page": "Pagina",
"Source": "Sorgente",
"This is a folder; attach the files in it.": "Questa è una cartella; alleghi i file che contiene.",
"This file is too large to send.": "Questo file è troppo grande per essere inviato.",
"Sending…": "Invio…",
"Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.",
"Rename…": "Rinomina…",
"Open in the web interface": "Apri nell'interfaccia web",
"Delete this chat?": "Eliminare questa chat?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» viene eliminata dal server, con i suoi messaggi e file. Non si può annullare.",
"Wait until the files are sent.": "Attenda che i file siano inviati.",
"Type a command or a chat's title": "Digiti un comando o il titolo di una chat",
"The agent is connecting to the server…": "L'agente si sta collegando al server…",
"The agent is not connected: %(why)s. It keeps trying.": "L'agente non è collegato: %(why)s. Continua a riprovare.",
"The agent will not connect: %(why)s. Link this computer again.": "L'agente non si collegherà: %(why)s. Colleghi di nuovo questo computer.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s), ma non è collegato: %(why)s",
"agent connecting…": "agente in collegamento…",
"agent waiting": "agente in attesa",
"agent refused": "agente rifiutato",
"Agent: %(why)s": "Agente: %(why)s",
"Server files": "File sul server",
"Drop the files to attach them": "Rilasci i file per allegarli",
},
"pl": {
"Menu": "Menu",
@@ -871,7 +1019,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agent działa (usługa)",
"agent stopped": "agent zatrzymany",
"agent: no folders": "agent: brak folderów",
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
"update %(version)s": "aktualizacja %(version)s",
"update": "aktualizacja",
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
@@ -879,6 +1026,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "To nie jest plik tekstowy; nie można go tu pokazać.",
"Page": "Strona",
"Source": "Źródło",
"This is a folder; attach the files in it.": "To jest folder; dołącz pliki, które zawiera.",
"This file is too large to send.": "Ten plik jest za duży, aby go wysłać.",
"Sending…": "Wysyłanie…",
"Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.",
"Rename…": "Zmień nazwę…",
"Open in the web interface": "Otwórz w interfejsie WWW",
"Delete this chat?": "Usunąć ten czat?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” zostanie usunięty z serwera wraz z wiadomościami i plikami. Tego nie można cofnąć.",
"Wait until the files are sent.": "Poczekaj, aż pliki zostaną wysłane.",
"Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu",
"The agent is connecting to the server…": "Agent łączy się z serwerem…",
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie jest połączony: %(why)s. Próbuje dalej.",
"The agent will not connect: %(why)s. Link this computer again.": "Agent się nie połączy: %(why)s. Połącz ten komputer ponownie.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Usługa LLeMbas CLI już tu działa (pid %(pid)s), ale nie jest połączona: %(why)s",
"agent connecting…": "agent łączy się…",
"agent waiting": "agent czeka",
"agent refused": "agent odrzucony",
"Agent: %(why)s": "Agent: %(why)s",
"Server files": "Pliki na serwerze",
"Drop the files to attach them": "Upuść pliki, aby je dołączyć",
},
"sk": {
"Menu": "Ponuka",
@@ -972,7 +1139,6 @@ OWN: dict[str, dict[str, str]] = {
"agent running (service)": "agent beží (služba)",
"agent stopped": "agent zastavený",
"agent: no folders": "agent: žiadne priečinky",
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
"update %(version)s": "aktualizácia %(version)s",
"update": "aktualizácia",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
@@ -980,6 +1146,26 @@ OWN: dict[str, dict[str, str]] = {
"This is not a text file; it cannot be shown here.": "Toto nie je textový súbor; nedá sa tu zobraziť.",
"Page": "Stránka",
"Source": "Zdroj",
"This is a folder; attach the files in it.": "Toto je priečinok; priložte súbory v ňom.",
"This file is too large to send.": "Tento súbor je príliš veľký na odoslanie.",
"Sending…": "Odosiela sa…",
"Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.",
"Rename…": "Premenovať…",
"Open in the web interface": "Otvoriť vo webovom rozhraní",
"Delete this chat?": "Zmazať túto konverzáciu?",
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ sa zmaže na serveri aj so svojimi správami a súbormi. Nedá sa to vrátiť.",
"Wait until the files are sent.": "Počkajte, kým sa súbory odošlú.",
"Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie",
"The agent is connecting to the server…": "Agent sa pripája k serveru…",
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie je pripojený: %(why)s. Skúša to ďalej.",
"The agent will not connect: %(why)s. Link this computer again.": "Agent sa nepripojí: %(why)s. Prepojte tento počítač znova.",
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI tu už beží (pid %(pid)s), ale nie je pripojená: %(why)s",
"agent connecting…": "agent sa pripája…",
"agent waiting": "agent čaká",
"agent refused": "agent odmietnutý",
"Agent: %(why)s": "Agent: %(why)s",
"Server files": "Súbory na serveri",
"Drop the files to attach them": "Pustite súbory a priložia sa",
},
}
+207 -2
View File
@@ -36,8 +36,8 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::window::Color;
use tauri::{
AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, WebviewWindow,
WebviewWindowBuilder, Window, WindowEvent, Wry,
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_notification::NotificationExt;
@@ -114,6 +114,8 @@ struct App {
next_stream: AtomicU64,
sealed: sealed::Pages,
watch: watch::Watch,
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
dropped: Mutex<Vec<PathBuf>>,
}
struct TrayItems {
@@ -481,6 +483,173 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
}
}
// --- Attachments ----------------------------------------------------------------------------------
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
/// names the refusals made here, so the page can say them in its language; the server's own
/// refusals come in the account's language already.
#[derive(Clone, Serialize)]
struct Uploaded {
name: String,
size: u64,
attachment: Option<Value>,
error: Option<String>,
reason: Option<&'static str>,
}
fn read_for_upload(path: &std::path::Path) -> Result<Vec<u8>, (&'static str, String)> {
let meta = std::fs::metadata(path).map_err(|e| ("unreadable", e.to_string()))?;
if meta.is_dir() {
return Err(("folder", "This is a folder.".into()));
}
if meta.len() > server::UPLOAD_LIMIT {
return Err(("too_large", "This file is too large to send.".into()));
}
std::fs::read(path).map_err(|e| ("unreadable", e.to_string()))
}
/// Send these files, one by one, as attachments for `chat` ("" for a chat not made yet). Only
/// ever called with paths the person chose: in the file dialog, or by dropping them on the window.
fn upload_paths(
handle: &AppHandle,
paths: Vec<PathBuf>,
chat: &str,
) -> Result<Vec<Uploaded>, ApiError> {
let Some((app_url, token)) = handle.state::<App>().door() else {
return Err(ApiError::new(401, "Not signed in."));
};
let mut out = Vec::new();
for path in paths {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into());
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let sent = match read_for_upload(&path) {
Ok(bytes) => server::upload(&app_url, &token, chat, &name, &bytes).map_err(|e| {
if e.status == 401 {
token_refused(handle);
}
(None, e.detail)
}),
Err((reason, message)) => Err((Some(reason), message)),
};
out.push(match sent {
Ok(a) => Uploaded {
name,
size,
attachment: Some(a),
error: None,
reason: None,
},
Err((reason, message)) => Uploaded {
name,
size,
attachment: None,
error: Some(message),
reason,
},
});
}
Ok(out)
}
/// Choose files with the system's dialog and send them as attachments for `chat`.
#[tauri::command]
async fn attach_pick(
handle: AppHandle,
chat: Option<String>,
title: String,
) -> Result<Vec<Uploaded>, ApiError> {
let h = handle.clone();
tauri::async_runtime::spawn_blocking(move || {
let picked = h.dialog().file().set_title(title).blocking_pick_files();
let paths: Vec<PathBuf> = picked
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.into_path().ok())
.collect();
upload_paths(&h, paths, chat.as_deref().unwrap_or(""))
})
.await
.map_err(|e| ApiError::new(0, e.to_string()))?
}
/// Send the files just dropped on the window as attachments for `chat`. Each drop can be sent
/// once.
#[tauri::command]
async fn attach_dropped(
handle: AppHandle,
chat: Option<String>,
) -> Result<Vec<Uploaded>, ApiError> {
let paths = std::mem::take(&mut *handle.state::<App>().dropped.lock().unwrap());
let h = handle.clone();
tauri::async_runtime::spawn_blocking(move || {
upload_paths(&h, paths, chat.as_deref().unwrap_or(""))
})
.await
.map_err(|e| ApiError::new(0, e.to_string()))?
}
/// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
fn percent_decode(text: &str) -> String {
let b = text.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
let hex = |c: u8| (c as char).to_digit(16);
if b[i] == b'%' && i + 2 < b.len() {
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// A file pasted into the composer (an image from the clipboard): its bytes as the raw body of
/// the call, its name in `x-name` and the chat in `x-chat`. The page has these bytes only because
/// the person pasted them.
#[tauri::command]
async fn attach_bytes(
handle: AppHandle,
request: tauri::ipc::Request<'_>,
) -> Result<Value, ApiError> {
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
return Err(ApiError::new(400, "No file."));
};
if bytes.len() as u64 > server::UPLOAD_LIMIT {
return Err(ApiError::new(413, "This file is too large to send."));
}
let header = |k: &str| {
request
.headers()
.get(k)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
let name = percent_decode(&header("x-name"));
let chat = header("x-chat");
let bytes = bytes.clone();
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::upload(&app_url, &token, &chat, &name, &bytes)
})
.await
.map_err(|e| ApiError::new(0, e.to_string()))?;
if matches!(&result, Err(e) if e.status == 401) {
token_refused(&handle);
}
result
}
// --- This computer's files ----------------------------------------------------------------------
/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder
@@ -1260,6 +1429,9 @@ pub fn run() {
local_read,
sealed_open,
sealed_close,
attach_pick,
attach_dropped,
attach_bytes,
set_theme,
set_notify,
set_open_chat,
@@ -1314,6 +1486,7 @@ pub fn run() {
next_stream: AtomicU64::new(0),
sealed: sealed::Pages::default(),
watch: watch::Watch::default(),
dropped: Mutex::new(Vec::new()),
});
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
// starts: the title bar's menu has every item the tray has.
@@ -1345,6 +1518,30 @@ pub fn run() {
WindowEvent::Resized(_) if window.label() == MAIN => {
let _ = window.emit("maximized", window.is_maximized().unwrap_or(false));
}
// Files dragged onto the window: the page shows where they go, and a drop is held
// here for `attach_dropped` -- the page itself never names a path to send.
WindowEvent::DragDrop(ev) if window.label() == MAIN => match ev {
DragDropEvent::Enter { .. } => {
let _ = window.emit("drag", true);
}
DragDropEvent::Leave => {
let _ = window.emit("drag", false);
}
DragDropEvent::Drop { paths, .. } => {
let names: Vec<String> = paths
.iter()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
})
.collect();
*window.state::<App>().dropped.lock().unwrap() = paths.clone();
let _ = window.emit("drag", false);
let _ = window.emit("drop", names);
}
_ => {}
},
_ => {}
})
.build(tauri::generate_context!());
@@ -1407,6 +1604,14 @@ mod tests {
}
}
#[test]
fn a_pasted_files_name_comes_back_whole() {
assert_eq!(percent_decode("Sch%C3%B6n%20bild.png"), "Schön bild.png");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("a%zzb%4"), "a%zzb%4");
assert_eq!(percent_decode("ž%41"), "žA");
}
#[test]
fn the_log_lives_in_the_apps_own_directory() {
let f = log::file().unwrap();
+91
View File
@@ -507,6 +507,85 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
Ok(text)
}
/// The most a file sent as an attachment may be here. The server has its own limit (20 MB by
/// default, an administrator may change it) and says so in its words; this one only keeps a
/// file far beyond any server's limit from being read into memory at all.
pub const UPLOAD_LIMIT: u64 = 64 * 1024 * 1024;
/// A file's name as it may stand in a multipart header: no quotes, backslashes or line breaks.
fn header_name(name: &str) -> String {
let clean: String = name
.chars()
.map(|c| match c {
'"' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
if clean.trim().is_empty() {
"file".into()
} else {
clean
}
}
/// The body of a `multipart/form-data` request with one file in the field `file`.
fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(bytes.len() + 256);
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
header_name(name)
)
.as_bytes(),
);
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
body
}
/// Send one file to `/api/v2/attachments` (for the chat `chat`, or a chat not made yet): stored
/// now, bound to the message that names it when that is sent. The server's attachment, as JSON.
pub fn upload(
app: &str,
token: &str,
chat: &str,
name: &str,
bytes: &[u8],
) -> Result<Value, ApiError> {
if !chat
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(ApiError::new(400, format!("Not a chat: {chat}")));
}
let path = if chat.is_empty() {
"/attachments".to_string()
} else {
format!("/attachments?chat={chat}")
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let boundary = format!("llembas-desktop-{nanos:x}");
let res = agent(Duration::from_secs(300))
.post(&format!("{app}{path}"))
.set("authorization", &format!("Bearer {token}"))
.set("accept", "application/json")
.set(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.send_bytes(&multipart(&boundary, name, bytes))?;
let v: Value = res
.into_json()
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
v.get("attachment")
.cloned()
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
}
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
#[derive(Default)]
pub struct Sse {
@@ -709,6 +788,18 @@ mod tests {
}
}
#[test]
fn a_file_goes_as_one_multipart_field() {
let body = multipart("XYZ", "a\"b\r\n.txt", b"hello");
let text = String::from_utf8(body).unwrap();
assert!(text.starts_with(
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n"
));
assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
assert_eq!(header_name(" "), "file");
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf");
}
#[test]
fn server_sent_events_come_out_whole() {
let mut sse = Sse::default();
+185 -9
View File
@@ -64,6 +64,8 @@ bdi { unicode-bidi: isolate; }
.btn--primary { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
.btn--primary:hover { background: var(--accent-hover); }
.btn--sm { height: var(--control-h-sm); padding: 0 var(--sp-3); box-shadow: none; }
.btn--danger { color: var(--ink-inverse); background: var(--danger); border-color: var(--danger); }
.btn--danger:hover { background: var(--danger-hover); }
input[type="text"], select, textarea {
min-width: 0;
@@ -180,8 +182,9 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.bar__side { overflow: hidden; visibility: hidden; }
.bar__agent { display: grid; grid-template-columns: var(--dot-size) auto; gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3) !important; font-weight: 400 !important; }
.bar__agent[data-state="running"] .dot, .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="not_linked"] .dot, .bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
.bar__agent[data-state="starting"] .dot, .bar__agent[data-state="connecting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="not_linked"] .dot, .bar__agent[data-state="no_folders"] .dot,
.bar__agent[data-state="waiting"] .dot, .bar__agent[data-state="refused"] .dot, .bar__agent[data-state="elsewhere_waiting"] .dot { background: var(--warning); }
.bar__update { padding: 0 var(--sp-3) !important; color: var(--accent-ink) !important; background: var(--accent) !important; }
.bar__update:hover { background: var(--accent-hover) !important; }
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
@@ -417,6 +420,56 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.msg__files { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: var(--sp-1); font-size: var(--text-xs); }
.msg__note { font-size: var(--text-sm); color: var(--ink-muted); }
.msg__foot { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.msg__files .chip { max-width: var(--w-chip); overflow: hidden; text-overflow: ellipsis; }
/* Under a message: small icon buttons, all one size. */
.msg__actions { display: grid; grid-auto-flow: column; grid-auto-columns: var(--control-h-sm); justify-content: start; }
.msg--user .msg__actions { justify-content: end; margin-top: calc(-1 * var(--sp-2)); }
.msg__act { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); padding: 0; color: var(--ink-faint); background: none; border: 0; border-radius: var(--radius); cursor: pointer; }
.msg__act:hover, .msg__act:focus-visible { color: var(--ink); background: var(--surface-hover); }
/* Regenerate: on the last reply only, and not while it is being written. */
.msg__regen { display: none; }
.messages > .msg--assistant:last-child:not(.is-live) .msg__regen { display: grid; }
.msg__editor { display: grid; gap: var(--sp-2); }
.msg__edit { width: 100%; min-height: var(--control-h); font: var(--text-base) / var(--leading-normal) var(--font-body); resize: vertical; }
.msg__editbar { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(100%, 6rem), auto); gap: var(--sp-2); justify-content: end; }
.chip--model { display: inline-grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1); align-items: center; max-width: var(--w-chip); padding-left: var(--sp-1); }
.chip--model > span:last-child { overflow: hidden; text-overflow: ellipsis; }
/* A model's avatar: its initial on its own colour. Only the hue is the model's (models.js, the
server's stable_hue); how strong it is comes from the theme, by mixing it into the surface. */
.avatar {
display: inline-grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
font: 700 var(--text-xs) / 1 var(--font-ui);
color: var(--ink);
background: color-mix(in oklab, hsl(var(--avatar-hue, 40) 100% 50%) 32%, var(--surface));
border-radius: var(--radius-sm);
flex: none;
}
.avatar--none { background: var(--surface-active); }
/* The model picker: a button the height of a control, avatar · name · chevron. */
.picker {
display: inline-grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
height: var(--control-h);
max-width: 100%;
padding: 0 var(--control-px-sm);
font: var(--text-sm) var(--font-ui);
color: var(--ink);
text-align: left;
background: var(--bg);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
cursor: pointer;
}
.picker__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.head__model { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
.head__model .picker { height: var(--control-h-sm); max-width: var(--w-chip); }
.new .picker { width: 100%; }
.msg__metrics { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
.msg__steps { display: grid; gap: var(--sp-3); min-width: 0; }
.msg__status, .msg__thinking { font: var(--text-xs) var(--font-ui); }
@@ -548,17 +601,57 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.plan__phase { font-weight: 600; }
.plan__task.is-done { text-decoration: line-through; color: var(--ink-muted); }
/* The composer: the text and its buttons, on two tracks; the buttons hold their own row. */
/* The composer: the files it carries on a row of their own, then three tracks -- the attach
button, the text, the send buttons. The buttons are one control high and sit at the foot of
the row the text sets the height of: their size is known, the text's is not. */
.composer {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: end;
grid-template-columns: var(--control-h) minmax(0, 1fr) auto;
grid-template-areas: "files files files" "attach text actions";
/* No gap between the rows: the files' row has its own margin, and none while it is empty. */
gap: 0 var(--sp-2);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
}
.composer[data-attach="false"] { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "files files" "text actions"; }
.composer > * { min-width: 0; }
.composer textarea { width: 100%; min-height: var(--control-h); max-height: 40vh; font: var(--text-base) / var(--leading-normal) var(--font-body); }
.composer__actions { display: grid; grid-auto-flow: column; gap: var(--sp-2); }
.composer textarea { grid-area: text; width: 100%; min-height: var(--control-h); max-height: 40vh; font: var(--text-base) / var(--leading-normal) var(--font-body); }
.composer__attach { grid-area: attach; align-self: end; width: var(--control-h); padding: 0; }
.composer__actions { grid-area: actions; align-self: end; display: grid; grid-auto-flow: column; gap: var(--sp-2); }
.composer__files { grid-area: files; margin: 0 0 var(--sp-2); padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--w-chip)), 1fr)); gap: var(--sp-2); }
/* One file: name · size or state · remove, the same tracks on every row. */
.file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
gap: var(--sp-2);
align-items: center;
height: var(--control-h-sm);
padding-left: var(--sp-2);
font: var(--text-xs) var(--font-ui);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
}
.file__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
.file__state { color: var(--ink-muted); max-width: var(--w-chip); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.file.is-failed { border-color: var(--warning); }
.file.is-failed .file__state { color: var(--warning); }
.file__remove { display: grid; place-items: center; height: 100%; padding: 0; color: var(--ink-muted); background: none; border: 0; border-left: var(--border-w) solid var(--border); cursor: pointer; }
.file__remove:hover { color: var(--ink); background: var(--surface-hover); }
/* Files dragged over the window: where they will go, over the whole composer. */
.composer__drop { display: none; }
.composer[data-drag="true"] .composer__drop {
display: grid;
place-items: center;
position: absolute;
inset: var(--sp-2) var(--sp-4);
z-index: var(--z-raised);
font: 600 var(--text-sm) var(--font-ui);
color: var(--accent);
background: var(--accent-soft);
border: var(--border-w-thick) dashed var(--accent);
border-radius: var(--radius);
}
/* --- Settings ------------------------------------------------------------------------------ */
@@ -625,6 +718,83 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.viewer__note { padding: var(--sp-4); font-size: var(--text-sm); color: var(--ink-muted); }
.viewer__note.is-error { color: var(--warning); }
/* --- Menus, dialogs and the palette ----------------------------------------------------------- */
.menu {
position: fixed;
z-index: var(--z-dropdown);
min-width: 12rem;
max-width: min(var(--w-menu), calc(100vw - 2 * var(--sp-2)));
max-height: min(var(--h-list), calc(100vh - 2 * var(--sp-2)));
overflow: auto;
padding: var(--sp-1) 0;
background: var(--surface-raised);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
/* Every item: lead · label · check, on the same tracks, so labels line up with or without a lead. */
.menu__item {
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr) 1rem;
gap: var(--sp-2);
align-items: center;
width: 100%;
min-height: var(--row-h);
padding: 0 var(--sp-3);
font: var(--text-sm) var(--font-ui);
color: var(--ink);
text-align: left;
background: none;
border: 0;
cursor: pointer;
}
/* A menu where no item has a lead: no lead track either. */
.menu--plain .menu__item { grid-template-columns: minmax(0, 1fr) 1rem; }
.menu--plain .menu__lead { display: none; }
.menu__item:hover, .menu__item:focus-visible { background: var(--surface-hover); outline: none; }
.menu__item:disabled { opacity: var(--opacity-dim); cursor: default; }
.menu__item.is-danger { color: var(--danger); }
.menu__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.menu__check { color: var(--accent); }
.menu__sep { height: var(--border-w); margin: var(--sp-1) 0; background: var(--border); }
.dialog {
width: min(var(--w-dialog), calc(100vw - 2 * var(--sp-4)));
padding: 0;
color: var(--ink);
background: var(--surface-raised);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.dialog::backdrop { background: var(--scrim); }
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
.dialog__input { width: 100%; }
.dialog__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); }
.palette { align-self: start; margin-top: 12vh; }
.palette__line { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3); border-bottom: var(--border-w) solid var(--border); color: var(--ink-muted); }
.palette__input { height: var(--control-h-lg) !important; border: 0 !important; background: none !important; outline: none; font-size: var(--text-base); }
.palette__list { list-style: none; margin: 0; padding: var(--sp-1) 0; max-height: var(--h-list); overflow: auto; }
/* A result: icon · label · hint, the same tracks on every row. */
.palette__item {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) auto;
gap: var(--sp-3);
align-items: center;
min-height: var(--row-h);
padding: 0 var(--sp-3);
font: var(--text-sm) var(--font-ui);
cursor: pointer;
}
.palette__item[aria-selected="true"] { background: var(--surface-active); }
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
/* --- Narrower windows ---------------------------------------------------------------------- */
/* 64rem (--bp-wide): the viewer goes over the chat instead of beside it. */
@@ -651,13 +821,19 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.shell[data-side="true"] .side { transform: none; }
.shell[data-side="true"] .scrim { display: block; position: absolute; inset: 0; z-index: calc(var(--z-panel) - 1); background: var(--scrim); }
.viewer { z-index: var(--z-panel); }
.head { padding: 0 var(--sp-3); grid-template-columns: minmax(0, 1fr) auto; }
.head { padding: var(--sp-2) var(--sp-3) 0; grid-template-columns: minmax(0, 1fr) auto; }
.head__place { grid-column: 1 / -1; grid-row: 2; padding-bottom: var(--sp-2); }
/* The chat's controls get a row of their own: beside the title they would squeeze it out. */
.head__tools { grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; padding-bottom: var(--sp-2); }
.head__tools:empty { display: none; }
.thread { padding: var(--sp-3); }
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.cards { padding: var(--sp-2) var(--sp-3) 0; }
.card { padding: var(--sp-4); gap: var(--sp-4); }
.msg--user { max-width: 100%; }
.palette__hint { display: none; }
.palette__item { grid-template-columns: 1rem minmax(0, 1fr); }
.head__model .picker { max-width: 8rem; }
}
/* Very narrow: the agent's words go, its dot stays; the row's button goes under its field. */
+280 -16
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -87,8 +87,11 @@
<div class="messages" id="messages"></div>
</div>
<div class="cards" id="cards" hidden></div>
<form class="composer" id="composer" hidden data-running="false">
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
<ul class="composer__files" id="composer-files" hidden></ul>
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
<div class="composer__actions">
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
+147
View File
@@ -0,0 +1,147 @@
// The files the next message carries. Three ways in, each the person's own act: the attach
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
// with the message that is sent next; one taken off before that is deleted on the server.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, listen, del, why } from "./bridge.js"
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error? }
let seq = 0
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
const REASONS = {
folder: () => t("This is a folder; attach the files in it."),
too_large: () => t("This file is too large to send."),
}
/** The ids of the files sent and ready to go with the message. */
export function readyIds() {
return pending.filter((p) => p.state === "ready").map((p) => p.id)
}
/** A file is still being sent. */
export function sending() {
return pending.some((p) => p.state === "sending")
}
/** After a send: the files belong to the message now. */
export function sent() {
pending = []
render()
}
/** Another chat opened, or a new one: files never sent are deleted on the server. */
export function discard() {
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = []
render()
}
function remove(p) {
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = pending.filter((x) => x !== p)
render()
}
/** One file as the composer shows it: the same three tracks for every row. */
function row(p) {
const status =
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
return h(
"li",
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
h("span", { class: "file__state", text: status }),
h(
"button",
{
type: "button",
class: "file__remove",
"aria-label": t("Remove %(dir)s", { dir: p.name }),
title: t("Remove"),
disabled: p.state === "sending",
onclick: () => remove(p),
},
icon(ICONS.close),
),
)
}
function render() {
const list = clear($("composer-files"))
list.hidden = !pending.length
for (const p of pending) list.append(row(p))
}
/** Show `names` as being sent while `call` sends them; then what became of each. */
async function track(names, call) {
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
pending.push(...placeholders)
render()
let results
try {
results = await call()
} catch (err) {
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
}
pending = pending.filter((p) => !placeholders.includes(p))
for (const r of results || []) {
pending.push(
r.attachment
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
)
}
render()
}
/** Choose files in the system's dialog. */
async function pick() {
const b = $("attach")
b.disabled = true
try {
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
} finally {
b.disabled = false
}
}
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
async function paste(e) {
const files = [...(e.clipboardData?.files || [])]
if (!files.length || !ctx.allowed()) return
e.preventDefault()
for (const f of files) {
const name = f.name || "pasted"
track([name], async () => {
const data = new Uint8Array(await f.arrayBuffer())
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
return [{ name, size: f.size, attachment }]
})
}
}
export function initAttach(context) {
ctx = context
$("attach").replaceChildren(icon(ICONS.attach))
$("attach").addEventListener("click", pick)
$("composer-text").addEventListener("paste", paste)
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
listen("drag", (e) => {
$("composer").dataset.drag = String(!!e.payload && usable())
})
listen("drop", (e) => {
$("composer").dataset.drag = "false"
if (!usable()) return
const names = Array.isArray(e.payload) ? e.payload : []
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
})
}
/** Whether this account may attach files at all: the button shows only then. */
export function allowAttach(on) {
$("attach").hidden = !on
$("composer").dataset.attach = String(on)
}
+254 -20
View File
@@ -6,15 +6,18 @@
// repository is the contract): a person's message is their text as typed; a reply is its steps --
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
import { $, h, clear, append } from "./dom.js"
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
import { $, h, clear, append, icon, ICONS, bytes } from "./dom.js"
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask } from "./popup.js"
import { avatar, modelPicker } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
const MODES = ["manual", "edit", "auto", "plan"]
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
@@ -37,14 +40,101 @@ function toBottom(force) {
if (force || nearBottom()) th.scrollTop = th.scrollHeight
}
/** A small icon button under a message. */
function action(ico, label, onclick, cls = "") {
return h("button", { type: "button", class: `msg__act ${cls}`, "aria-label": label, title: label, onclick }, icon(ico))
}
function copyText(text) {
navigator.clipboard?.writeText(text).catch(() => {})
}
function userMessage(m) {
return h(
const el = h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
h("div", { class: "msg__text", text: m.text || "" }),
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
m.attachments?.length
? h(
"ul",
{ class: "msg__files" },
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
)
: null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
// A background job's report is not the person's to rewrite (the server refuses it too).
if (!m.machine) {
el.append(
h(
"div",
{ class: "msg__actions" },
action(ICONS.edit, t("Edit message"), () => startEdit(el, m)),
action(ICONS.copy, t("Copy"), () => copyText(m.text || "")),
),
)
}
return el
}
/**
* Edit one of the person's messages in place. Saving rewrites it, drops everything after it and
* starts the reply again (on the device too, for an agent chat): the server's rule, said first.
*/
function startEdit(el, m) {
if (!open?.chat?.id || el.querySelector(".msg__edit")) return
const box = h("textarea", { class: "msg__edit", rows: "3", value: m.text || "", "aria-label": t("Edit message") })
const note = h("p", { class: "hint", text: t("Saving starts the reply again; everything after this message is dropped.") })
const said = h("p", { class: "msg__note is-error", hidden: true })
const save = h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save") })
const cancel = h("button", { type: "button", class: "btn btn--sm", text: t("Cancel") })
const form = h("div", { class: "msg__editor" }, box, note, said, h("div", { class: "msg__editbar" }, cancel, save))
const parts = [...el.children]
for (const c of parts) c.hidden = true
el.append(form)
const close = () => {
form.remove()
for (const c of parts) c.hidden = false
}
cancel.addEventListener("click", close)
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") close()
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) save.click()
})
save.addEventListener("click", async () => {
const text = box.value.trim()
if (!text && !m.attachments?.length) return box.focus()
save.disabled = cancel.disabled = true
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/edit`, { text })
reload()
} catch (err) {
said.textContent = why(err)
said.hidden = false
save.disabled = cancel.disabled = false
}
})
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/** The reply's words, as Markdown: what Copy puts on the clipboard. */
function replyText(m) {
return (m.steps || [])
.filter((s) => s.kind === "text")
.map((s) => s.markdown || "")
.join("\n\n")
}
async function regenerate(m) {
if (!open?.chat?.id) return
alertIn("")
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/regenerate`)
reload()
} catch (err) {
alertIn(why(err))
}
}
function metricsLine(x) {
@@ -65,9 +155,22 @@ function assistantMessage(m, chat) {
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
const meta = metricsLine(m.metrics)
if (meta || m.model) {
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
}
const model = m.model ? ctx.models().find((x) => x.id === m.model) || { id: m.model, name: m.model } : null
el.append(
h(
"footer",
{ class: "msg__foot" },
h(
"div",
{ class: "msg__actions" },
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
),
model ? h("span", { class: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
meta,
),
)
return el
}
@@ -423,8 +526,120 @@ function renderHead(chat) {
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
)
tools.append(mode)
}
const slot = h("span", { class: "head__model" })
tools.append(slot)
chatModels(chat).then((models) => {
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
})
tools.append(h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
async function chatModels(chat) {
try {
return (await get(`/models?chat=${encodeURIComponent(chat.id)}`)).models || []
} catch {
return ctx.models()
}
}
/** The chat's model, and its effort where the model offers more than one. */
function headModel(slot, chat, models) {
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
const parts = []
if (mayPick && models.length > 1) {
parts.push(
modelPicker({
models,
value: chat.model,
label: t("Model"),
onChange: async (id) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
if (open?.chat?.id === chat.id) {
open.chat = r.chat
headModel(slot, r.chat, models)
}
} catch (err) {
alertIn(why(err))
headModel(slot, chat, models)
}
},
}),
)
} else if (chat.model) {
place.append(h("span", { class: "chip", text: chat.model }))
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
}
const efforts = current.efforts || []
if (efforts.length > 1) {
parts.push(
h(
"select",
{
class: "head__mode",
"aria-label": t("Reasoning effort"),
title: t("Reasoning effort"),
onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: e.target.value }).catch((err) => alertIn(why(err))),
},
chat.effort ? null : h("option", { value: "", text: "—", selected: true }),
efforts.map((x) => h("option", { value: x, text: x, selected: chat.effort === x })),
),
)
}
slot.replaceChildren(...parts)
}
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { title })
if (open?.chat?.id === chat.id) {
open.chat = r.chat
setTitle(r.chat.title)
}
ctx.changed(r.chat)
} catch (err) {
alertIn(why(err))
}
}
async function archiveChat(chat) {
try {
await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: true })
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
async function deleteChat(chat) {
const yes = await ask({
title: t("Delete this chat?"),
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
ok: t("Delete"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
try {
await del(`/chats/${encodeURIComponent(chat.id)}`)
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
@@ -440,6 +655,7 @@ function alertIn(text) {
/** Open a chat by id: its newest page, and the reply being written, if one is. */
export async function openChat(id) {
if (open?.chat?.id !== id) discard()
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
invoke("set_open_chat", { chat: id })
@@ -517,6 +733,7 @@ let draft = null // { kind }
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
export function newChat(kind) {
closeChat()
discard()
draft = { kind }
clear($("messages"))
renderCards({ id: "" }, [])
@@ -528,15 +745,20 @@ export function newChat(kind) {
form.hidden = false
const s = ctx.status()
const models = ctx.models()
const start = (models.find((m) => m.default) || models[0])?.id || ""
const effort = h("select", { id: "new-effort" })
const effortField = field(t("Reasoning effort"), effort, "")
const showEfforts = (id) => {
const efforts = models.find((m) => m.id === id)?.efforts || []
effort.replaceChildren(h("option", { value: "", text: "—" }), ...efforts.map((x) => h("option", { value: x, text: x })))
effortField.hidden = efforts.length < 2
}
const modelField = field(
t("Model"),
h(
"select",
{ id: "new-model" },
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
),
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showEfforts }),
models.length ? "" : t("No model is available to this account."),
)
showEfforts(start)
if (kind === "agent") {
const roots = s.remote?.enabled ? s.remote.roots : []
const devices = ctx.thisDevices()
@@ -567,10 +789,11 @@ export function newChat(kind) {
t("A chat asking for more than the folder allows is lowered to it."),
),
modelField,
effortField,
),
)
} else {
form.append(h("div", { class: "fields" }, modelField))
form.append(h("div", { class: "fields" }, modelField, effortField))
}
showComposer(true)
setRunning(false)
@@ -585,10 +808,13 @@ function field(label, control, hint) {
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
}
async function createChat(text) {
async function createChat(text, attachments) {
const body = { text }
if (attachments.length) body.attachments = attachments
const model = $("new-model")?.value
if (model) body.model = model
const effort = $("new-effort")?.value
if (effort) body.effort = effort
if (draft.kind === "agent") {
const device = ctx.thisDevices()[0]
const dir = $("new-dir")?.value
@@ -618,16 +844,24 @@ function showComposer(on) {
async function submit() {
const box = $("composer-text")
const text = box.value.trim()
if (!text) return
if (sending()) {
alertIn(t("Wait until the files are sent."))
return
}
const files = readyIds()
if (!text && !files.length) return
$("send").disabled = true
alertIn("")
try {
if (draft) await createChat(text)
if (draft) await createChat(text, files)
else if (open?.chat?.id) {
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
const body = { text }
if (files.length) body.attachments = files
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
}
} else return
sent()
box.value = ""
grow()
toBottom(true)
+6
View File
@@ -74,6 +74,12 @@ export const ICONS = {
min: "M4 10h8v1.5H4z",
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
}
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
+6 -1
View File
@@ -5,7 +5,7 @@
import { $, h, clear, when, append } from "./dom.js"
import { get, why } from "./bridge.js"
let ctx = null // { kind(), thisDevices(), open(id), current() }
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
let rows = []
let more = false
@@ -49,6 +49,11 @@ function row(c) {
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
)
b.addEventListener("click", () => ctx.open(c.id))
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
b.addEventListener("contextmenu", (e) => {
e.preventDefault()
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, c)
})
return h("li", null, b)
}
+65 -14
View File
@@ -11,7 +11,9 @@ import { renderSettings, initSettings } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, renderServer } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
let status = null
let view = "" // "setup" | "app"
@@ -90,6 +92,7 @@ async function startApp() {
}
await Promise.all([loadModels(), loadDevices()])
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
allowAttach(me?.permissions?.["files.upload"] !== false)
setKind(kind)
renderServer()
invoke("remote_refresh")
@@ -142,6 +145,40 @@ function localPane(chat) {
else showLocal({ roots, root: "", fixed: false })
}
/** A new chat of `k`'s kind, in the main view. */
function startNew(k) {
showSettings(false)
if (k !== kind) setKind(k)
currentChat = null
if (narrow()) sideOpen(false)
newChat(k)
markCurrent()
localPane()
}
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
function showChat(id, k) {
showSettings(false)
if (k && k !== kind) setKind(k)
if (narrow()) sideOpen(false)
openChat(id).then(markCurrent)
}
/** The palette's commands: what the menus and buttons do, by name. */
function commands() {
const out = [
{ label: t("New agent chat"), run: () => startNew("agent") },
{ label: t("New chat"), run: () => startNew("chat") },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
out.push(
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
)
return out
}
function narrow() {
return window.matchMedia("(max-width: 48rem)").matches
}
@@ -175,11 +212,8 @@ async function boot() {
kind: () => kind,
thisDevices,
current: () => openChatId(),
open: (id) => {
showSettings(false)
if (narrow()) sideOpen(false)
openChat(id).then(markCurrent)
},
open: (id) => showChat(id),
menu: (anchor, c) => chatMenu(anchor, c),
})
initChat({
status: () => status,
@@ -192,21 +226,25 @@ async function boot() {
localPane(chat)
markCurrent()
},
// Archived or deleted: the list without it, and a new chat where it was open.
gone: (chat) => {
if (!openChatId() || openChatId() === chat.id) {
currentChat = null
newChat(kind)
localPane()
}
refreshList()
},
})
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
$("new-chat-btn").prepend(icon(ICONS.plus))
$("new-chat-btn").addEventListener("click", () => {
showSettings(false)
currentChat = null
if (narrow()) sideOpen(false)
newChat(kind)
markCurrent()
localPane()
})
$("new-chat-btn").addEventListener("click", () => startNew(kind))
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
$("settings-close").replaceChildren(icon(ICONS.close))
@@ -215,6 +253,19 @@ async function boot() {
$("scrim").addEventListener("click", () => sideOpen(false))
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
if (view !== "app" || document.querySelector("dialog[open]")) return
const mod = e.ctrlKey || e.metaKey
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
e.preventDefault()
openPalette()
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
e.preventDefault()
startNew(kind)
} else if (mod && e.key === ",") {
e.preventDefault()
showSettings(true)
}
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
+105
View File
@@ -0,0 +1,105 @@
// Models as the person picks them: each with its avatar, as the server's web interface draws a
// model without an uploaded image -- its initial on a colour of its own, the hue worked out from
// the model's id exactly as the server does it (`stable_hue`: the first two bytes of the id's
// MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
// lightness are the theme's (app.css), only the hue is the model's.
import { h, icon, ICONS } from "./dom.js"
import { menu } from "./popup.js"
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
function md5(text) {
const bytes = new TextEncoder().encode(text)
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
const len = bytes.length
const words = (((len + 8) >>> 6) + 1) * 16
const w = new Uint32Array(words)
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
w[len >> 2] |= 0x80 << ((len % 4) * 8)
w[words - 2] = (len * 8) >>> 0
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
let a0 = 0x67452301
let b0 = 0xefcdab89
let c0 = 0x98badcfe
let d0 = 0x10325476
for (let off = 0; off < words; off += 16) {
let [A, B, C, D] = [a0, b0, c0, d0]
for (let i = 0; i < 64; i++) {
let F
let g
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
F = (F + A + K[i] + w[off + g]) >>> 0
const s = S[(i >> 4) * 4 + (i % 4)]
A = D
D = C
C = B
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
}
a0 = (a0 + A) >>> 0
b0 = (b0 + B) >>> 0
c0 = (c0 + C) >>> 0
d0 = (d0 + D) >>> 0
}
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
}
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
export function stableHue(id) {
const d = md5(String(id))
return ((d[0] << 8) | d[1]) % 360
}
/** A model's avatar: its initial on its own colour. */
export function avatar(model) {
const label = String(model?.name || model?.id || "?").trim() || "?"
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
return el
}
/**
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
*/
export function modelPicker({ id, models, value, onChange, label }) {
let current = value
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
const draw = () => {
const m = models.find((x) => x.id === current)
btn.replaceChildren(
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
icon(ICONS.chevronDown),
)
btn.title = m ? `${m.name} (${m.id})` : current || ""
}
btn.addEventListener("click", () => {
menu(
btn,
models.map((m) => ({
label: m.name,
lead: avatar(m),
checked: m.id === current,
run: () => {
if (m.id === current) return
current = m.id
draw()
onChange?.(m.id)
},
})),
)
})
Object.defineProperty(btn, "value", {
get: () => current,
set: (v) => {
current = v
draw()
},
})
draw()
return btn
}
+124
View File
@@ -0,0 +1,124 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
// is there.
import { h, clear, icon, ICONS, when } from "./dom.js"
import { get } from "./bridge.js"
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
let dlg = null
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) {
return String(text || "")
.normalize("NFD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
}
function matches(text, words) {
const f = fold(text)
return words.every((w) => f.includes(w))
}
export async function openPalette() {
if (dlg) return
const input = h("input", { type: "text", class: "palette__input", placeholder: t("Type a command or a chat's title"), "aria-label": t("Search"), role: "combobox", "aria-expanded": "true", "aria-controls": "palette-list", autocomplete: "off" })
const list = h("ul", { class: "palette__list", id: "palette-list", role: "listbox" })
dlg = h("dialog", { class: "dialog palette", "aria-label": t("Commands") }, h("div", { class: "palette__line" }, icon(ICONS.search), input), list)
document.body.append(dlg)
let chats = []
let shown = []
let at = 0
const close = () => {
if (!dlg) return
dlg.close()
dlg.remove()
dlg = null
}
const choose = (item) => {
close()
item?.run()
}
const draw = () => {
const words = fold(input.value).split(/\s+/).filter(Boolean)
const commands = ctx.commands().filter((c) => matches(c.label, words))
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
shown = [
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
...found.map((c) => ({
kind: c.kind,
label: c.title || t("New chat"),
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
run: () => ctx.open(c),
})),
]
at = Math.min(at, Math.max(0, shown.length - 1))
clear(list)
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
shown.forEach((item, i) => {
list.append(
h(
"li",
{
id: `palette-${i}`,
role: "option",
class: "palette__item",
"aria-selected": String(i === at),
onclick: () => choose(item),
onpointermove: () => {
if (at !== i) {
at = i
mark()
}
},
},
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__hint", text: item.hint }),
),
)
})
mark()
}
const mark = () => {
list.querySelectorAll(".palette__item").forEach((el, i) => el.setAttribute("aria-selected", String(i === at)))
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
}
input.addEventListener("input", () => {
at = 0
draw()
})
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
if (!shown.length) return
at = (at + (e.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length
mark()
} else if (e.key === "Enter") {
e.preventDefault()
choose(shown[at])
}
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
close()
})
dlg.addEventListener("click", (e) => {
if (e.target === dlg) close()
})
dlg.showModal()
input.focus()
draw()
try {
const r = await get("/chats?limit=100")
chats = (r.chats || []).filter((c) => !c.archived && !c.temporary)
if (dlg) draw()
} catch {}
}
export function initPalette(context) {
ctx = context
}
+126
View File
@@ -0,0 +1,126 @@
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
// click outside close them; the focus goes back where it came from.
import { h } from "./dom.js"
let openMenu = null
/** Close the menu that is open, if any. */
export function closeMenu() {
if (!openMenu) return
const { el, back } = openMenu
openMenu = null
el.remove()
document.removeEventListener("pointerdown", outside, true)
back?.focus?.()
}
function outside(e) {
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
}
/**
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
*/
export function menu(anchor, items) {
closeMenu()
const plain = !items.some((i) => i?.lead)
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
for (const item of items) {
if (!item) {
el.append(h("div", { class: "menu__sep", role: "separator" }))
continue
}
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
// labels line up whether or not an item has a lead.
el.append(
h(
"button",
{
type: "button",
role: item.checked === undefined ? "menuitem" : "menuitemradio",
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
class: `menu__item${item.danger ? " is-danger" : ""}`,
disabled: !!item.disabled,
onclick: () => {
closeMenu()
item.run()
},
},
h("span", { class: "menu__lead" }, item.lead || null),
h("span", { class: "menu__label", text: item.label }),
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
),
)
}
el.addEventListener("keydown", (e) => {
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
const i = all.indexOf(document.activeElement)
if (e.key === "Escape") {
e.preventDefault()
closeMenu()
} else if (e.key === "ArrowDown") {
e.preventDefault()
all[(i + 1) % all.length]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
all[(i - 1 + all.length) % all.length]?.focus()
}
})
document.body.append(el)
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
const r = el.getBoundingClientRect()
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
el.style.width = `${Math.ceil(r.width)}px`
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
document.addEventListener("pointerdown", outside, true)
el.querySelector(".menu__item:not(:disabled)")?.focus()
}
/**
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
* buttons. Resolves with the line (or true) on OK, null on cancel.
*/
export function ask({ title, text, value, ok, cancel, danger }) {
return new Promise((resolve) => {
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
const form = h(
"form",
{ class: "dialog__body", method: "dialog" },
h("h2", { class: "dialog__title", text: title }),
text ? h("p", { class: "muted", text }) : null,
input,
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
)
const dlg = h("dialog", { class: "dialog" }, form)
let settled = false
const done = (v) => {
if (settled) return
settled = true
dlg.close()
dlg.remove()
resolve(v)
}
form.addEventListener("submit", (e) => {
e.preventDefault()
if (input && !input.value.trim()) return input.focus()
done(input ? input.value.trim() : true)
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
done(null)
})
document.body.append(dlg)
dlg.showModal()
if (input) {
input.focus()
input.select()
} else okBtn.focus()
})
}