Daily use: attachments, edit and regenerate, the chat's menu, model avatars, a palette
- 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:
16 files changed
+1840
-72
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+4
-1
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user