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]
|
## [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
|
## [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.
|
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.
|
on the server.
|
||||||
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
- **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.
|
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.
|
- **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.
|
- **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
|
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
|
||||||
holds the token.
|
holds the token.
|
||||||
- The explorer reads only inside the agent's folders.
|
- 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
|
- 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.
|
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.
|
- 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 running (service)",
|
||||||
"agent stopped",
|
"agent stopped",
|
||||||
"agent: no folders",
|
"agent: no folders",
|
||||||
"Agent stopped: %(why)s",
|
|
||||||
"update %(version)s",
|
"update %(version)s",
|
||||||
"update",
|
"update",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)",
|
"LLeMbas %(version)s is available (this is %(current)s)",
|
||||||
@@ -167,6 +166,41 @@ KEYS = (
|
|||||||
"Page",
|
"Page",
|
||||||
"Preview",
|
"Preview",
|
||||||
"Source",
|
"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
|
# 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 running (service)": "agent běží (služba)",
|
||||||
"agent stopped": "agent zastaven",
|
"agent stopped": "agent zastaven",
|
||||||
"agent: no folders": "agent: žádné složky",
|
"agent: no folders": "agent: žádné složky",
|
||||||
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
|
|
||||||
"update %(version)s": "aktualizace %(version)s",
|
"update %(version)s": "aktualizace %(version)s",
|
||||||
"update": "aktualizace",
|
"update": "aktualizace",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
|
"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.",
|
"This is not a text file; it cannot be shown here.": "Toto není textový soubor; nelze ho zde zobrazit.",
|
||||||
"Page": "Stránka",
|
"Page": "Stránka",
|
||||||
"Source": "Zdroj",
|
"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": {
|
"de": {
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
@@ -366,7 +419,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "Agent läuft (Dienst)",
|
"agent running (service)": "Agent läuft (Dienst)",
|
||||||
"agent stopped": "Agent angehalten",
|
"agent stopped": "Agent angehalten",
|
||||||
"agent: no folders": "Agent: keine Ordner",
|
"agent: no folders": "Agent: keine Ordner",
|
||||||
"Agent stopped: %(why)s": "Agent angehalten: %(why)s",
|
|
||||||
"update %(version)s": "Update %(version)s",
|
"update %(version)s": "Update %(version)s",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
|
"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.",
|
"This is not a text file; it cannot be shown here.": "Das ist keine Textdatei; sie kann hier nicht angezeigt werden.",
|
||||||
"Page": "Seite",
|
"Page": "Seite",
|
||||||
"Source": "Quelltext",
|
"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": {
|
"es": {
|
||||||
"Menu": "Menú",
|
"Menu": "Menú",
|
||||||
@@ -467,7 +539,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "agente en marcha (servicio)",
|
"agent running (service)": "agente en marcha (servicio)",
|
||||||
"agent stopped": "agente detenido",
|
"agent stopped": "agente detenido",
|
||||||
"agent: no folders": "agente: sin carpetas",
|
"agent: no folders": "agente: sin carpetas",
|
||||||
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
|
|
||||||
"update %(version)s": "actualizar a %(version)s",
|
"update %(version)s": "actualizar a %(version)s",
|
||||||
"update": "actualizar",
|
"update": "actualizar",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
|
"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í.",
|
"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",
|
"Page": "Página",
|
||||||
"Source": "Código fuente",
|
"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": {
|
"fr": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -568,7 +659,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "agent actif (service)",
|
"agent running (service)": "agent actif (service)",
|
||||||
"agent stopped": "agent interrompu",
|
"agent stopped": "agent interrompu",
|
||||||
"agent: no folders": "agent : aucun dossier",
|
"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 %(version)s": "mise à jour %(version)s",
|
||||||
"update": "mise à jour",
|
"update": "mise à jour",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
|
"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.",
|
"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",
|
"Page": "Page",
|
||||||
"Source": "Source",
|
"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": {
|
"hu": {
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
@@ -669,7 +779,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "ügynök fut (szolgáltatás)",
|
"agent running (service)": "ügynök fut (szolgáltatás)",
|
||||||
"agent stopped": "ügynök leállt",
|
"agent stopped": "ügynök leállt",
|
||||||
"agent: no folders": "ügynök: nincs mappa",
|
"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 %(version)s": "frissítés: %(version)s",
|
||||||
"update": "frissítés",
|
"update": "frissítés",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)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.",
|
"This is not a text file; it cannot be shown here.": "Ez nem szövegfájl; itt nem jeleníthető meg.",
|
||||||
"Page": "Oldal",
|
"Page": "Oldal",
|
||||||
"Source": "Forrás",
|
"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": {
|
"it": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -770,7 +899,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "agente attivo (servizio)",
|
"agent running (service)": "agente attivo (servizio)",
|
||||||
"agent stopped": "agente fermato",
|
"agent stopped": "agente fermato",
|
||||||
"agent: no folders": "agente: nessuna cartella",
|
"agent: no folders": "agente: nessuna cartella",
|
||||||
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
|
|
||||||
"update %(version)s": "aggiorna a %(version)s",
|
"update %(version)s": "aggiorna a %(version)s",
|
||||||
"update": "aggiorna",
|
"update": "aggiorna",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
|
"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.",
|
"This is not a text file; it cannot be shown here.": "Questo non è un file di testo; non può essere mostrato qui.",
|
||||||
"Page": "Pagina",
|
"Page": "Pagina",
|
||||||
"Source": "Sorgente",
|
"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": {
|
"pl": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -871,7 +1019,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "agent działa (usługa)",
|
"agent running (service)": "agent działa (usługa)",
|
||||||
"agent stopped": "agent zatrzymany",
|
"agent stopped": "agent zatrzymany",
|
||||||
"agent: no folders": "agent: brak folderów",
|
"agent: no folders": "agent: brak folderów",
|
||||||
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
|
|
||||||
"update %(version)s": "aktualizacja %(version)s",
|
"update %(version)s": "aktualizacja %(version)s",
|
||||||
"update": "aktualizacja",
|
"update": "aktualizacja",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
|
"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ć.",
|
"This is not a text file; it cannot be shown here.": "To nie jest plik tekstowy; nie można go tu pokazać.",
|
||||||
"Page": "Strona",
|
"Page": "Strona",
|
||||||
"Source": "Źródło",
|
"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": {
|
"sk": {
|
||||||
"Menu": "Ponuka",
|
"Menu": "Ponuka",
|
||||||
@@ -972,7 +1139,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"agent running (service)": "agent beží (služba)",
|
"agent running (service)": "agent beží (služba)",
|
||||||
"agent stopped": "agent zastavený",
|
"agent stopped": "agent zastavený",
|
||||||
"agent: no folders": "agent: žiadne priečinky",
|
"agent: no folders": "agent: žiadne priečinky",
|
||||||
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
|
|
||||||
"update %(version)s": "aktualizácia %(version)s",
|
"update %(version)s": "aktualizácia %(version)s",
|
||||||
"update": "aktualizácia",
|
"update": "aktualizácia",
|
||||||
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
|
"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ť.",
|
"This is not a text file; it cannot be shown here.": "Toto nie je textový súbor; nedá sa tu zobraziť.",
|
||||||
"Page": "Stránka",
|
"Page": "Stránka",
|
||||||
"Source": "Zdroj",
|
"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::tray::TrayIconBuilder;
|
||||||
use tauri::window::Color;
|
use tauri::window::Color;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, WebviewWindow,
|
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||||
WebviewWindowBuilder, Window, WindowEvent, Wry,
|
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
@@ -114,6 +114,8 @@ struct App {
|
|||||||
next_stream: AtomicU64,
|
next_stream: AtomicU64,
|
||||||
sealed: sealed::Pages,
|
sealed: sealed::Pages,
|
||||||
watch: watch::Watch,
|
watch: watch::Watch,
|
||||||
|
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||||
|
dropped: Mutex<Vec<PathBuf>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrayItems {
|
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 ----------------------------------------------------------------------
|
// --- This computer's files ----------------------------------------------------------------------
|
||||||
|
|
||||||
/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder
|
/// 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,
|
local_read,
|
||||||
sealed_open,
|
sealed_open,
|
||||||
sealed_close,
|
sealed_close,
|
||||||
|
attach_pick,
|
||||||
|
attach_dropped,
|
||||||
|
attach_bytes,
|
||||||
set_theme,
|
set_theme,
|
||||||
set_notify,
|
set_notify,
|
||||||
set_open_chat,
|
set_open_chat,
|
||||||
@@ -1314,6 +1486,7 @@ pub fn run() {
|
|||||||
next_stream: AtomicU64::new(0),
|
next_stream: AtomicU64::new(0),
|
||||||
sealed: sealed::Pages::default(),
|
sealed: sealed::Pages::default(),
|
||||||
watch: watch::Watch::default(),
|
watch: watch::Watch::default(),
|
||||||
|
dropped: Mutex::new(Vec::new()),
|
||||||
});
|
});
|
||||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||||
// starts: the title bar's menu has every item the tray has.
|
// 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 => {
|
WindowEvent::Resized(_) if window.label() == MAIN => {
|
||||||
let _ = window.emit("maximized", window.is_maximized().unwrap_or(false));
|
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!());
|
.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]
|
#[test]
|
||||||
fn the_log_lives_in_the_apps_own_directory() {
|
fn the_log_lives_in_the_apps_own_directory() {
|
||||||
let f = log::file().unwrap();
|
let f = log::file().unwrap();
|
||||||
|
|||||||
@@ -507,6 +507,85 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
|
|||||||
Ok(text)
|
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.
|
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Sse {
|
pub struct Sse {
|
||||||
@@ -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]
|
#[test]
|
||||||
fn server_sent_events_come_out_whole() {
|
fn server_sent_events_come_out_whole() {
|
||||||
let mut sse = Sse::default();
|
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 { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
|
||||||
.btn--primary:hover { background: var(--accent-hover); }
|
.btn--primary:hover { background: var(--accent-hover); }
|
||||||
.btn--sm { height: var(--control-h-sm); padding: 0 var(--sp-3); box-shadow: none; }
|
.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 {
|
input[type="text"], select, textarea {
|
||||||
min-width: 0;
|
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__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 { 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="running"] .dot, .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
|
||||||
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
|
.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 { background: var(--warning); }
|
.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 { padding: 0 var(--sp-3) !important; color: var(--accent-ink) !important; background: var(--accent) !important; }
|
||||||
.bar__update:hover { background: var(--accent-hover) !important; }
|
.bar__update:hover { background: var(--accent-hover) !important; }
|
||||||
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
|
/* 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__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__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__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__metrics { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
.msg__steps { display: grid; gap: var(--sp-3); min-width: 0; }
|
.msg__steps { display: grid; gap: var(--sp-3); min-width: 0; }
|
||||||
.msg__status, .msg__thinking { font: var(--text-xs) var(--font-ui); }
|
.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__phase { font-weight: 600; }
|
||||||
.plan__task.is-done { text-decoration: line-through; color: var(--ink-muted); }
|
.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 {
|
.composer {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: var(--control-h) minmax(0, 1fr) auto;
|
||||||
gap: var(--sp-2);
|
grid-template-areas: "files files files" "attach text actions";
|
||||||
align-items: end;
|
/* 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);
|
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 > * { 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 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__actions { display: grid; grid-auto-flow: column; gap: var(--sp-2); }
|
.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 ------------------------------------------------------------------------------ */
|
/* --- 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 { padding: var(--sp-4); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||||
.viewer__note.is-error { color: var(--warning); }
|
.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 ---------------------------------------------------------------------- */
|
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
/* 64rem (--bp-wide): the viewer goes over the chat instead of beside it. */
|
/* 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"] .side { transform: none; }
|
||||||
.shell[data-side="true"] .scrim { display: block; position: absolute; inset: 0; z-index: calc(var(--z-panel) - 1); background: var(--scrim); }
|
.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); }
|
.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); }
|
.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); }
|
.thread { padding: var(--sp-3); }
|
||||||
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
|
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
|
||||||
.cards { padding: var(--sp-2) var(--sp-3) 0; }
|
.cards { padding: var(--sp-2) var(--sp-3) 0; }
|
||||||
.card { padding: var(--sp-4); gap: var(--sp-4); }
|
.card { padding: var(--sp-4); gap: var(--sp-4); }
|
||||||
.msg--user { max-width: 100%; }
|
.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. */
|
/* 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 class="messages" id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cards" id="cards" hidden></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>
|
<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">
|
<div class="composer__actions">
|
||||||
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
|
<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>
|
<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 --
|
// 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.
|
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
|
||||||
|
|
||||||
import { $, h, clear, append } from "./dom.js"
|
import { $, h, clear, append, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
|
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
|
||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.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"]
|
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 }
|
let open = null // { chat, live, more, first }
|
||||||
|
|
||||||
export function openChatId() {
|
export function openChatId() {
|
||||||
@@ -37,14 +40,101 @@ function toBottom(force) {
|
|||||||
if (force || nearBottom()) th.scrollTop = th.scrollHeight
|
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) {
|
function userMessage(m) {
|
||||||
return h(
|
const el = h(
|
||||||
"article",
|
"article",
|
||||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
||||||
h("div", { class: "msg__text", text: m.text || "" }),
|
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,
|
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) {
|
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.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.") }))
|
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
|
||||||
const meta = metricsLine(m.metrics)
|
const meta = metricsLine(m.metrics)
|
||||||
if (meta || m.model) {
|
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" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
|
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
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,8 +526,120 @@ function renderHead(chat) {
|
|||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
||||||
)
|
)
|
||||||
tools.append(mode)
|
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) {
|
} 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. */
|
/** Open a chat by id: its newest page, and the reply being written, if one is. */
|
||||||
export async function openChat(id) {
|
export async function openChat(id) {
|
||||||
|
if (open?.chat?.id !== id) discard()
|
||||||
if (open?.live) open.live.stop()
|
if (open?.live) open.live.stop()
|
||||||
open = { chat: { id }, live: null, more: false }
|
open = { chat: { id }, live: null, more: false }
|
||||||
invoke("set_open_chat", { chat: id })
|
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). */
|
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
||||||
export function newChat(kind) {
|
export function newChat(kind) {
|
||||||
closeChat()
|
closeChat()
|
||||||
|
discard()
|
||||||
draft = { kind }
|
draft = { kind }
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
renderCards({ id: "" }, [])
|
renderCards({ id: "" }, [])
|
||||||
@@ -528,15 +745,20 @@ export function newChat(kind) {
|
|||||||
form.hidden = false
|
form.hidden = false
|
||||||
const s = ctx.status()
|
const s = ctx.status()
|
||||||
const models = ctx.models()
|
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(
|
const modelField = field(
|
||||||
t("Model"),
|
t("Model"),
|
||||||
h(
|
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showEfforts }),
|
||||||
"select",
|
|
||||||
{ id: "new-model" },
|
|
||||||
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
|
|
||||||
),
|
|
||||||
models.length ? "" : t("No model is available to this account."),
|
models.length ? "" : t("No model is available to this account."),
|
||||||
)
|
)
|
||||||
|
showEfforts(start)
|
||||||
if (kind === "agent") {
|
if (kind === "agent") {
|
||||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
const roots = s.remote?.enabled ? s.remote.roots : []
|
||||||
const devices = ctx.thisDevices()
|
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."),
|
t("A chat asking for more than the folder allows is lowered to it."),
|
||||||
),
|
),
|
||||||
modelField,
|
modelField,
|
||||||
|
effortField,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
form.append(h("div", { class: "fields" }, modelField))
|
form.append(h("div", { class: "fields" }, modelField, effortField))
|
||||||
}
|
}
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
setRunning(false)
|
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 || "" }))
|
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 }
|
const body = { text }
|
||||||
|
if (attachments.length) body.attachments = attachments
|
||||||
const model = $("new-model")?.value
|
const model = $("new-model")?.value
|
||||||
if (model) body.model = model
|
if (model) body.model = model
|
||||||
|
const effort = $("new-effort")?.value
|
||||||
|
if (effort) body.effort = effort
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = ctx.thisDevices()[0]
|
||||||
const dir = $("new-dir")?.value
|
const dir = $("new-dir")?.value
|
||||||
@@ -618,16 +844,24 @@ function showComposer(on) {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
const box = $("composer-text")
|
const box = $("composer-text")
|
||||||
const text = box.value.trim()
|
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
|
$("send").disabled = true
|
||||||
alertIn("")
|
alertIn("")
|
||||||
try {
|
try {
|
||||||
if (draft) await createChat(text)
|
if (draft) await createChat(text, files)
|
||||||
else if (open?.chat?.id) {
|
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.message) $("messages").append(userMessage(r.message))
|
||||||
if (r.reply) follower(open.chat, r.reply.id)
|
if (r.reply) follower(open.chat, r.reply.id)
|
||||||
}
|
} else return
|
||||||
|
sent()
|
||||||
box.value = ""
|
box.value = ""
|
||||||
grow()
|
grow()
|
||||||
toBottom(true)
|
toBottom(true)
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export const ICONS = {
|
|||||||
min: "M4 10h8v1.5H4z",
|
min: "M4 10h8v1.5H4z",
|
||||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
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. */
|
/** "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 { $, h, clear, when, append } from "./dom.js"
|
||||||
import { get, why } from "./bridge.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 rows = []
|
||||||
let more = false
|
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,
|
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))
|
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)
|
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 { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
||||||
import { initList, refreshList, markCurrent } from "./list.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 status = null
|
||||||
let view = "" // "setup" | "app"
|
let view = "" // "setup" | "app"
|
||||||
@@ -90,6 +92,7 @@ async function startApp() {
|
|||||||
}
|
}
|
||||||
await Promise.all([loadModels(), loadDevices()])
|
await Promise.all([loadModels(), loadDevices()])
|
||||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
||||||
|
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||||
setKind(kind)
|
setKind(kind)
|
||||||
renderServer()
|
renderServer()
|
||||||
invoke("remote_refresh")
|
invoke("remote_refresh")
|
||||||
@@ -142,6 +145,40 @@ function localPane(chat) {
|
|||||||
else showLocal({ roots, root: "", fixed: false })
|
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() {
|
function narrow() {
|
||||||
return window.matchMedia("(max-width: 48rem)").matches
|
return window.matchMedia("(max-width: 48rem)").matches
|
||||||
}
|
}
|
||||||
@@ -175,11 +212,8 @@ async function boot() {
|
|||||||
kind: () => kind,
|
kind: () => kind,
|
||||||
thisDevices,
|
thisDevices,
|
||||||
current: () => openChatId(),
|
current: () => openChatId(),
|
||||||
open: (id) => {
|
open: (id) => showChat(id),
|
||||||
showSettings(false)
|
menu: (anchor, c) => chatMenu(anchor, c),
|
||||||
if (narrow()) sideOpen(false)
|
|
||||||
openChat(id).then(markCurrent)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
initChat({
|
initChat({
|
||||||
status: () => status,
|
status: () => status,
|
||||||
@@ -192,21 +226,25 @@ async function boot() {
|
|||||||
localPane(chat)
|
localPane(chat)
|
||||||
markCurrent()
|
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) => {
|
onViewer((on) => {
|
||||||
$("shell").dataset.viewer = String(on)
|
$("shell").dataset.viewer = String(on)
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
|
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").prepend(icon(ICONS.plus))
|
||||||
$("new-chat-btn").addEventListener("click", () => {
|
$("new-chat-btn").addEventListener("click", () => startNew(kind))
|
||||||
showSettings(false)
|
|
||||||
currentChat = null
|
|
||||||
if (narrow()) sideOpen(false)
|
|
||||||
newChat(kind)
|
|
||||||
markCurrent()
|
|
||||||
localPane()
|
|
||||||
})
|
|
||||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
||||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
||||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||||
@@ -215,6 +253,19 @@ async function boot() {
|
|||||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
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.") } })
|
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