Compare commits
14
Commits
v1.0.0-beta.6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cdd4e5355
|
||
|
|
df27ca82fa
|
||
|
|
3101552858
|
||
|
|
9b07527c3a
|
||
|
|
2b87fa4c29
|
||
|
|
67ce6076c5
|
||
|
|
d691a4dcfa
|
||
|
|
be70e1a628
|
||
|
|
04736db057
|
||
|
|
151fc5fcca
|
||
|
|
d4a1f13cf1
|
||
|
|
effcc2c2fd
|
||
|
|
97c2301bab
|
||
|
|
94e903ef70
|
No files matched your search
@@ -4,6 +4,68 @@ All notable changes to LLeMbas Desktop. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Archived chats.** Archive chat was one-way: an archived chat left the sidebar and could not be
|
||||
found again in the app. Archived, under Files in the sidebar, lists them newest first; each opens
|
||||
as any chat does, goes back into the sidebar with Restore chat, or is deleted. An archived chat
|
||||
that is open says so in its header, with Restore chat beside it and in its menu.
|
||||
- **An agent chat on another computer.** A new agent chat could only work on this computer. Where
|
||||
the account has another computer linked, a chip before the folder chooses it (this computer is
|
||||
the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal
|
||||
before the first message opens there too.
|
||||
- **Language and theme in Settings.** Under Appearance. The language is the account's, as the
|
||||
server says it, unless this computer's or another of the app's nine is chosen (kept in the app;
|
||||
the window opens again in it). The theme is the account's, and choosing one saves it there, so
|
||||
the web interface and the phone follow; on a server that cannot be told, it is kept in the app.
|
||||
- **Pinned chats and chat folders.** The sidebar lists the pinned chats first, then the account's
|
||||
folders, nested and folded as the server keeps them, then the rest by day. A chat's menu pins
|
||||
it and moves it into a folder or a new one; a folder's menu renames it, makes one inside it, or
|
||||
deletes it (its chats stay, back in the list). The search line still lists every match.
|
||||
- **Memory.** A page in the sidebar (and in Ctrl+K) lists what the account's models remember about
|
||||
the person, as the web's Settings → Memory does: add a record (into a data group, where there is
|
||||
more than one), change it, remove it. A record a model noted says so.
|
||||
- **Search inside.** Ctrl+K finds more than titles: once two letters are typed it asks the
|
||||
server's search, and lists below the titles what it found inside chats, in the file library and
|
||||
in memory, each with the words around the match, the match marked. A chat opens, a file opens
|
||||
in the viewer, a memory opens the Memory page at it. A server without search finds titles only.
|
||||
- **The server's files can be changed.** On Files → Server, Upload (files chosen in the system's
|
||||
dialog) and New folder work in the home folder, and each folder's menu does the same inside it;
|
||||
a file's or folder's menu renames it or moves it to the trash. What the server refuses is said
|
||||
in the pane, in its words.
|
||||
- **A chat's tools and skills.** Tools and skills… in a chat's menu switches the tool families
|
||||
(web search among them) and the skills on or off for that chat only, as the web's chat settings
|
||||
do, and lists the commands it was told to always allow, with Ask me about these again.
|
||||
- **Voice.** Dictate, beside Send, records a message and writes down what was said where the
|
||||
cursor is, through the server's speech to text; a reply's Read this aloud reads it in the
|
||||
account's own voice (without its code), and stops when pressed again. Each shows only where the
|
||||
server has it on for the account. The microphone is allowed only right after Dictate is pressed.
|
||||
- **Draw a picture.** From Ctrl+K, where the server can draw for the account: a prompt and a
|
||||
shape, and the picture under it. Only the account's own pictures on its own server are shown.
|
||||
- **A scheduled task's notification says what it found.** Where the server makes the run's
|
||||
notification, it carries the task's name and the result's first lines (or the failure said),
|
||||
with Open and Run again (and Pause, when a task has failed three times in a row), labelled in
|
||||
the account's language. Open, or a click on the notification, opens the task; Run again and
|
||||
Pause do it without opening the window. A chat's notification, clicked, opens that chat.
|
||||
- **The release your server recommends.** Settings › About names the Desktop release that goes
|
||||
with the server's version when it is newer than this one (a beta only to a beta install), with
|
||||
a link to its release page.
|
||||
- **Scheduled tasks on a linked computer.** A task's page says where it runs: on the server, or
|
||||
on a computer, in a folder and mode, with how long an approval waits, and whether that computer
|
||||
is away or does not accept scheduled work. A run waiting for its computer says so. The form
|
||||
offers "Where it runs" where the server can: this computer or another linked one that accepts
|
||||
scheduled work, its folder (this computer's agent folder to begin with), mode and wait.
|
||||
- **A picture as the reply, in a chat.** Where the server draws for the account, a Draw a picture
|
||||
switch beside the model asks for the next message's reply to be a picture drawn from it — in a
|
||||
new chat (its first message) as in an open one. It goes off once the message is sent.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A terminal that ended could come back by itself.** Shown again and ended by the server within
|
||||
one frame, the panel opened a new shell in its place; an ended shell is now only said.
|
||||
|
||||
## [1.0.0-beta.6] — 2026-11-10
|
||||
|
||||
LLeMbas CLI 2.0.0-beta.6 inside, the look and words of LLeMbas 2.0.0-beta.8.
|
||||
|
||||
@@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||
FROM="tests/fixtures/api_v2"
|
||||
OUT="tests/ui/fixtures"
|
||||
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
||||
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
|
||||
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json
|
||||
me.json folders.json folder.json search.json memories.json memory.json scope.json push_schedule.json push_schedule_failed.json
|
||||
apps.json apps_stable.json event_schedules.json schedule.json schedule_on_device.json schedule_run.json chat.json chats_listed.json files_listing.json"
|
||||
|
||||
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
||||
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
||||
|
||||
+547
-3
@@ -445,6 +445,104 @@ KEYS = (
|
||||
"Stop %(name)s and the subagents under it?",
|
||||
"This device is not linked right now.",
|
||||
"Subagent %(name)s (%(id)s), %(position)s, asks:",
|
||||
# archived.js
|
||||
"Archived",
|
||||
"Restore chat",
|
||||
"No archived chats.",
|
||||
# chat.js: a new agent chat on another computer
|
||||
"Computer",
|
||||
"Folder on %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.",
|
||||
"Choose",
|
||||
"Choose a folder…",
|
||||
"Type a folder…",
|
||||
"Choose a folder first.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.",
|
||||
# settings.js: language and theme
|
||||
"Appearance",
|
||||
"Language",
|
||||
"Theme",
|
||||
"dark",
|
||||
"light",
|
||||
"The interface only. Models answer in the language you write to them in, whatever this says.",
|
||||
"This computer's (%(language)s)",
|
||||
"The account's (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.",
|
||||
"The account's (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.",
|
||||
# list.js, chat.js: pinned chats and chat folders
|
||||
"Pinned chats",
|
||||
"Pin chat",
|
||||
"Unpin chat",
|
||||
"New folder",
|
||||
"Rename folder",
|
||||
"Delete folder",
|
||||
"Folder name",
|
||||
"Move to folder…",
|
||||
"No folder",
|
||||
"New folder…",
|
||||
"Create",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.",
|
||||
# palette.js, memory.js: search inside, and the memory page
|
||||
"Memory",
|
||||
"Add",
|
||||
"Data group",
|
||||
"Remove this memory?",
|
||||
"Noted by a model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.",
|
||||
"Something to remember…",
|
||||
"Nothing remembered yet.",
|
||||
# explorer.js, scope.js: the library's writes, and a chat's tools and skills
|
||||
"Upload",
|
||||
"Moved to the trash.",
|
||||
"Move to the trash",
|
||||
"Move “%(name)s” to the trash? It can be restored from there.",
|
||||
"Tools and skills…",
|
||||
"Skills",
|
||||
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.",
|
||||
"Always allowed here",
|
||||
"Ask me about these again",
|
||||
"Nothing to switch in this chat.",
|
||||
# voice.js, draw.js: dictation, read aloud, and drawing a picture
|
||||
"Dictate a message",
|
||||
"Stop dictating",
|
||||
"Writing down…",
|
||||
"The microphone could not be opened: %(why)s",
|
||||
"Read this aloud",
|
||||
"The server's answer was not audio.",
|
||||
"Draw a picture",
|
||||
"What to draw…",
|
||||
"Shape",
|
||||
"Square",
|
||||
"Wide",
|
||||
"Tall",
|
||||
"Draw",
|
||||
"Drawing… a picture can take a minute or two.",
|
||||
"The server's answer had no picture in it.",
|
||||
# settings.js: the release the server recommends
|
||||
"Your server recommends LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.",
|
||||
"Open the release page",
|
||||
# scheduled.js: where a task runs
|
||||
"Where it runs",
|
||||
"On this server",
|
||||
"does not accept scheduled work",
|
||||
"not linked now",
|
||||
"A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).",
|
||||
"Folder on the computer",
|
||||
"A full path, inside a folder the computer allows remote work in.",
|
||||
"Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it.",
|
||||
"An approval waits (minutes)",
|
||||
"A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied.",
|
||||
"A linked computer",
|
||||
"Away now: a run waits for it, then counts as missed.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.",
|
||||
"This computer (%(name)s)",
|
||||
"Waiting for the computer",
|
||||
"approvals wait %(n)s min",
|
||||
# chat.js: a picture as the next reply
|
||||
"The reply to the next message is a picture drawn from it.",
|
||||
)
|
||||
|
||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||
@@ -673,6 +771,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dny",
|
||||
"Time": "Čas",
|
||||
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.",
|
||||
"No archived chats.": "Žádné archivované konverzace.",
|
||||
"Computer": "Počítač",
|
||||
"Folder on %(device)s": "Složka na zařízení %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta ke složce na tom počítači. Jeho agent otevře jen složky, které dostal.",
|
||||
"Choose": "Vybrat",
|
||||
"Choose a folder…": "Vybrat složku…",
|
||||
"Type a folder…": "Zadat složku…",
|
||||
"Choose a folder first.": "Nejprve vyberte složku.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Podle tohoto počítače (%(language)s)",
|
||||
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.",
|
||||
"The account's (%(language)s)": "Podle účtu (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.",
|
||||
"Move to folder…": "Přesunout do složky…",
|
||||
"No folder": "Bez složky",
|
||||
"New folder…": "Nová složka…",
|
||||
"Create": "Vytvořit",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a složky v ní se smažou. Jejich konverzace zůstanou, zpět v seznamu.",
|
||||
"Add": "Přidat",
|
||||
"Data group": "Skupina dat",
|
||||
"Remove this memory?": "Odstranit tento záznam z paměti?",
|
||||
"Noted by a model": "Zaznamenal model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Co si o vás vaše modely pamatují napříč konverzacemi. Cokoli tu můžete změnit nebo odstranit.",
|
||||
"Something to remember…": "Něco k zapamatování…",
|
||||
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
|
||||
"Tools and skills…": "Nástroje a dovednosti…",
|
||||
"Nothing to switch in this chat.": "V této konverzaci není co přepnout.",
|
||||
"Stop dictating": "Zastavit diktování",
|
||||
"Writing down…": "Zapisuji…",
|
||||
"The microphone could not be opened: %(why)s": "Mikrofon se nepodařilo otevřít: %(why)s",
|
||||
"Read this aloud": "Přečíst nahlas",
|
||||
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
|
||||
"What to draw…": "Co nakreslit…",
|
||||
"Shape": "Tvar",
|
||||
"Square": "Čtverec",
|
||||
"Wide": "Široký",
|
||||
"Tall": "Vysoký",
|
||||
"Draw": "Nakreslit",
|
||||
"Drawing… a picture can take a minute or two.": "Kreslím… obrázek může trvat minutu nebo dvě.",
|
||||
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Váš server doporučuje LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Vydání, které patří k verzi serveru. Toto je %(current)s.",
|
||||
"Open the release page": "Otevřít stránku vydání",
|
||||
"A linked computer": "Propojený počítač",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teď je pryč: spuštění na něj počká, pak se počítá jako zmeškané.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nepřijímá plánovanou práci: zapněte tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Tento počítač (%(name)s)",
|
||||
"Waiting for the computer": "Čeká na počítač",
|
||||
"approvals wait %(n)s min": "schválení čeká %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
|
||||
},
|
||||
"de": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
||||
@@ -896,6 +1046,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Tage",
|
||||
"Time": "Uhrzeit",
|
||||
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.",
|
||||
"No archived chats.": "Keine archivierten Chats.",
|
||||
"Computer": "Computer",
|
||||
"Folder on %(device)s": "Ordner auf %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Der vollständige Pfad des Ordners auf diesem Computer. Sein Agent öffnet nur Ordner, die ihm freigegeben wurden.",
|
||||
"Choose": "Auswählen",
|
||||
"Choose a folder…": "Ordner wählen…",
|
||||
"Type a folder…": "Ordner eingeben…",
|
||||
"Choose a folder first.": "Wählen Sie zuerst einen Ordner.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.",
|
||||
"This computer's (%(language)s)": "Wie dieser Computer (%(language)s)",
|
||||
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.",
|
||||
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.",
|
||||
"Move to folder…": "In Ordner verschieben…",
|
||||
"No folder": "Kein Ordner",
|
||||
"New folder…": "Neuer Ordner…",
|
||||
"Create": "Erstellen",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ und die Ordner darin werden gelöscht. Ihre Chats bleiben, wieder in der Liste.",
|
||||
"Add": "Hinzufügen",
|
||||
"Data group": "Datengruppe",
|
||||
"Remove this memory?": "Diese Erinnerung entfernen?",
|
||||
"Noted by a model": "Von einem Modell notiert",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Was Ihre Modelle über Sie behalten, über alle Chats hinweg. Hier können Sie alles ändern oder entfernen.",
|
||||
"Something to remember…": "Etwas, das behalten werden soll…",
|
||||
"Nothing remembered yet.": "Noch nichts behalten.",
|
||||
"Tools and skills…": "Werkzeuge und Fähigkeiten…",
|
||||
"Nothing to switch in this chat.": "In diesem Chat gibt es nichts umzuschalten.",
|
||||
"Stop dictating": "Diktieren beenden",
|
||||
"Writing down…": "Wird aufgeschrieben…",
|
||||
"The microphone could not be opened: %(why)s": "Das Mikrofon konnte nicht geöffnet werden: %(why)s",
|
||||
"Read this aloud": "Vorlesen",
|
||||
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
|
||||
"What to draw…": "Was gezeichnet werden soll…",
|
||||
"Shape": "Form",
|
||||
"Square": "Quadratisch",
|
||||
"Wide": "Breit",
|
||||
"Tall": "Hoch",
|
||||
"Draw": "Zeichnen",
|
||||
"Drawing… a picture can take a minute or two.": "Wird gezeichnet… ein Bild kann ein bis zwei Minuten dauern.",
|
||||
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein Bild.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Ihr Server empfiehlt LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Die Version, die zur Version des Servers passt. Dies ist %(current)s.",
|
||||
"Open the release page": "Release-Seite öffnen",
|
||||
"A linked computer": "Ein verknüpfter Computer",
|
||||
"Away now: a run waits for it, then counts as missed.": "Gerade nicht da: Ein Lauf wartet auf ihn und zählt dann als verpasst.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Er nimmt keine geplante Arbeit an: Schalten Sie dort Accept scheduled work ein.",
|
||||
"This computer (%(name)s)": "Dieser Computer (%(name)s)",
|
||||
"Waiting for the computer": "Wartet auf den Computer",
|
||||
"approvals wait %(n)s min": "Freigaben warten %(n)s Min.",
|
||||
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
|
||||
},
|
||||
"es": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
||||
@@ -1119,6 +1321,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Días",
|
||||
"Time": "Hora",
|
||||
"No day ticked: every day.": "Ningún día marcado: todos los días.",
|
||||
"No archived chats.": "No hay chats archivados.",
|
||||
"Computer": "Equipo",
|
||||
"Folder on %(device)s": "Carpeta en %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "La ruta completa de la carpeta en ese equipo. Su agente solo abre las carpetas que se le han dado.",
|
||||
"Choose": "Elegir",
|
||||
"Choose a folder…": "Elegir una carpeta…",
|
||||
"Type a folder…": "Escribir una carpeta…",
|
||||
"Choose a folder first.": "Elija primero una carpeta.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.",
|
||||
"This computer's (%(language)s)": "La de este equipo (%(language)s)",
|
||||
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.",
|
||||
"The account's (%(language)s)": "El de la cuenta (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.",
|
||||
"Move to folder…": "Mover a una carpeta…",
|
||||
"No folder": "Sin carpeta",
|
||||
"New folder…": "Nueva carpeta…",
|
||||
"Create": "Crear",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "Se eliminan «%(name)s» y las carpetas que contiene. Sus chats se quedan, de vuelta en la lista.",
|
||||
"Add": "Añadir",
|
||||
"Data group": "Grupo de datos",
|
||||
"Remove this memory?": "¿Quitar este recuerdo?",
|
||||
"Noted by a model": "Anotado por un modelo",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Lo que sus modelos recuerdan de usted entre chats. Aquí puede cambiar o quitar cualquier cosa.",
|
||||
"Something to remember…": "Algo que recordar…",
|
||||
"Nothing remembered yet.": "Todavía no se recuerda nada.",
|
||||
"Tools and skills…": "Herramientas y habilidades…",
|
||||
"Nothing to switch in this chat.": "No hay nada que activar o desactivar en este chat.",
|
||||
"Stop dictating": "Dejar de dictar",
|
||||
"Writing down…": "Transcribiendo…",
|
||||
"The microphone could not be opened: %(why)s": "No se pudo abrir el micrófono: %(why)s",
|
||||
"Read this aloud": "Leer en voz alta",
|
||||
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
|
||||
"What to draw…": "Qué dibujar…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Cuadrada",
|
||||
"Wide": "Ancha",
|
||||
"Tall": "Alta",
|
||||
"Draw": "Dibujar",
|
||||
"Drawing… a picture can take a minute or two.": "Dibujando… una imagen puede tardar uno o dos minutos.",
|
||||
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Su servidor recomienda LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La versión que corresponde a la del servidor. Esta es %(current)s.",
|
||||
"Open the release page": "Abrir la página de la versión",
|
||||
"A linked computer": "Un ordenador vinculado",
|
||||
"Away now: a run waits for it, then counts as missed.": "Ausente ahora: una ejecución lo espera y luego cuenta como perdida.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "No acepta trabajo programado: active allí Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Este ordenador (%(name)s)",
|
||||
"Waiting for the computer": "Esperando al ordenador",
|
||||
"approvals wait %(n)s min": "las aprobaciones esperan %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
|
||||
},
|
||||
"fr": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
||||
@@ -1342,6 +1596,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Jours",
|
||||
"Time": "Heure",
|
||||
"No day ticked: every day.": "Aucun jour coché : tous les jours.",
|
||||
"No archived chats.": "Aucune conversation archivée.",
|
||||
"Computer": "Ordinateur",
|
||||
"Folder on %(device)s": "Dossier sur %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Le chemin complet du dossier sur cet ordinateur. Son agent n’ouvre que les dossiers qui lui ont été confiés.",
|
||||
"Choose": "Choisir",
|
||||
"Choose a folder…": "Choisir un dossier…",
|
||||
"Type a folder…": "Saisir un dossier…",
|
||||
"Choose a folder first.": "Choisissez d’abord un dossier.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.",
|
||||
"This computer's (%(language)s)": "Celle de cet ordinateur (%(language)s)",
|
||||
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.",
|
||||
"The account's (%(language)s)": "Celle du compte (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.",
|
||||
"Move to folder…": "Déplacer dans un dossier…",
|
||||
"No folder": "Aucun dossier",
|
||||
"New folder…": "Nouveau dossier…",
|
||||
"Create": "Créer",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "« %(name)s » et les dossiers qu’il contient sont supprimés. Leurs conversations restent, de retour dans la liste.",
|
||||
"Add": "Ajouter",
|
||||
"Data group": "Groupe de données",
|
||||
"Remove this memory?": "Supprimer ce souvenir ?",
|
||||
"Noted by a model": "Noté par un modèle",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Ce que vos modèles retiennent de vous, d’une conversation à l’autre. Modifiez ou supprimez ce que vous voulez ici.",
|
||||
"Something to remember…": "Quelque chose à retenir…",
|
||||
"Nothing remembered yet.": "Rien n’est encore retenu.",
|
||||
"Tools and skills…": "Outils et compétences…",
|
||||
"Nothing to switch in this chat.": "Rien à activer ou désactiver dans cette conversation.",
|
||||
"Stop dictating": "Arrêter la dictée",
|
||||
"Writing down…": "Transcription…",
|
||||
"The microphone could not be opened: %(why)s": "Le micro n’a pas pu être ouvert : %(why)s",
|
||||
"Read this aloud": "Lire à voix haute",
|
||||
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
|
||||
"What to draw…": "Que dessiner…",
|
||||
"Shape": "Forme",
|
||||
"Square": "Carrée",
|
||||
"Wide": "Large",
|
||||
"Tall": "Haute",
|
||||
"Draw": "Dessiner",
|
||||
"Drawing… a picture can take a minute or two.": "Dessin en cours… une image peut prendre une minute ou deux.",
|
||||
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Votre serveur recommande LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La version qui va avec celle du serveur. Celle-ci est la %(current)s.",
|
||||
"Open the release page": "Ouvrir la page de la version",
|
||||
"A linked computer": "Un ordinateur lié",
|
||||
"Away now: a run waits for it, then counts as missed.": "Absent pour l’instant : une exécution l’attend, puis compte comme manquée.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Il n’accepte pas le travail planifié : activez-y Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Cet ordinateur (%(name)s)",
|
||||
"Waiting for the computer": "En attente de l’ordinateur",
|
||||
"approvals wait %(n)s min": "les approbations attendent %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
|
||||
},
|
||||
"hu": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
||||
@@ -1565,6 +1871,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Napok",
|
||||
"Time": "Időpont",
|
||||
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.",
|
||||
"No archived chats.": "Nincs archivált beszélgetés.",
|
||||
"Computer": "Számítógép",
|
||||
"Folder on %(device)s": "Mappa ezen: %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "A mappa teljes elérési útja azon a számítógépen. Az ügynöke csak a neki megadott mappákat nyitja meg.",
|
||||
"Choose": "Kiválasztás",
|
||||
"Choose a folder…": "Mappa kiválasztása…",
|
||||
"Type a folder…": "Mappa megadása…",
|
||||
"Choose a folder first.": "Először válasszon mappát.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.",
|
||||
"This computer's (%(language)s)": "A számítógépé (%(language)s)",
|
||||
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.",
|
||||
"The account's (%(language)s)": "A fióké (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.",
|
||||
"Move to folder…": "Áthelyezés mappába…",
|
||||
"No folder": "Nincs mappa",
|
||||
"New folder…": "Új mappa…",
|
||||
"Create": "Létrehozás",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "A(z) „%(name)s” és a benne lévő mappák törlődnek. A beszélgetéseik megmaradnak, vissza a listába.",
|
||||
"Add": "Hozzáadás",
|
||||
"Data group": "Adatcsoport",
|
||||
"Remove this memory?": "Eltávolítja ezt az emléket?",
|
||||
"Noted by a model": "Egy modell jegyezte fel",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Amit a modelljei megjegyeznek Önről a beszélgetéseken át. Itt bármit módosíthat vagy eltávolíthat.",
|
||||
"Something to remember…": "Valami, amit meg kell jegyezni…",
|
||||
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
|
||||
"Tools and skills…": "Eszközök és készségek…",
|
||||
"Nothing to switch in this chat.": "Ebben a beszélgetésben nincs mit átkapcsolni.",
|
||||
"Stop dictating": "Diktálás leállítása",
|
||||
"Writing down…": "Leírás…",
|
||||
"The microphone could not be opened: %(why)s": "A mikrofont nem sikerült megnyitni: %(why)s",
|
||||
"Read this aloud": "Felolvasás",
|
||||
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
|
||||
"What to draw…": "Mit rajzoljon…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Négyzet",
|
||||
"Wide": "Széles",
|
||||
"Tall": "Magas",
|
||||
"Draw": "Rajzolás",
|
||||
"Drawing… a picture can take a minute or two.": "Rajzolás… egy kép egy-két percig is eltarthat.",
|
||||
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "A szervere a LLeMbas Desktop %(version)s verziót ajánlja",
|
||||
"The release that goes with the server's version. This is %(current)s.": "A szerver verziójához tartozó kiadás. Ez a %(current)s.",
|
||||
"Open the release page": "Kiadás oldalának megnyitása",
|
||||
"A linked computer": "Egy összekapcsolt számítógép",
|
||||
"Away now: a run waits for it, then counts as missed.": "Most nem elérhető: a futás megvárja, aztán kihagyottnak számít.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nem fogad ütemezett munkát: kapcsolja be ott az Accept scheduled work beállítást.",
|
||||
"This computer (%(name)s)": "Ez a számítógép (%(name)s)",
|
||||
"Waiting for the computer": "A számítógépre vár",
|
||||
"approvals wait %(n)s min": "a jóváhagyások %(n)s percig várnak",
|
||||
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
|
||||
},
|
||||
"it": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
||||
@@ -1788,6 +2146,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Giorni",
|
||||
"Time": "Ora",
|
||||
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.",
|
||||
"No archived chats.": "Nessuna chat archiviata.",
|
||||
"Computer": "Computer",
|
||||
"Folder on %(device)s": "Cartella su %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Il percorso completo della cartella su quel computer. Il suo agente apre solo le cartelle che gli sono state date.",
|
||||
"Choose": "Scegli",
|
||||
"Choose a folder…": "Scegli una cartella…",
|
||||
"Type a folder…": "Digita una cartella…",
|
||||
"Choose a folder first.": "Scegli prima una cartella.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.",
|
||||
"This computer's (%(language)s)": "Quella di questo computer (%(language)s)",
|
||||
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.",
|
||||
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.",
|
||||
"Move to folder…": "Sposta in una cartella…",
|
||||
"No folder": "Nessuna cartella",
|
||||
"New folder…": "Nuova cartella…",
|
||||
"Create": "Crea",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "«%(name)s» e le cartelle al suo interno vengono eliminate. Le loro chat restano, di nuovo nell’elenco.",
|
||||
"Add": "Aggiungi",
|
||||
"Data group": "Gruppo di dati",
|
||||
"Remove this memory?": "Rimuovere questo ricordo?",
|
||||
"Noted by a model": "Annotato da un modello",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Ciò che i tuoi modelli ricordano di te tra una chat e l’altra. Qui puoi cambiare o rimuovere qualsiasi cosa.",
|
||||
"Something to remember…": "Qualcosa da ricordare…",
|
||||
"Nothing remembered yet.": "Ancora niente da ricordare.",
|
||||
"Tools and skills…": "Strumenti e abilità…",
|
||||
"Nothing to switch in this chat.": "Non c’è niente da attivare o disattivare in questa chat.",
|
||||
"Stop dictating": "Interrompi la dettatura",
|
||||
"Writing down…": "Trascrizione…",
|
||||
"The microphone could not be opened: %(why)s": "Impossibile aprire il microfono: %(why)s",
|
||||
"Read this aloud": "Leggi ad alta voce",
|
||||
"The server's answer was not audio.": "La risposta del server non era audio.",
|
||||
"What to draw…": "Cosa disegnare…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Quadrata",
|
||||
"Wide": "Larga",
|
||||
"Tall": "Alta",
|
||||
"Draw": "Disegna",
|
||||
"Drawing… a picture can take a minute or two.": "Disegno in corso… un’immagine può richiedere un minuto o due.",
|
||||
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Il tuo server consiglia LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La versione che va con quella del server. Questa è la %(current)s.",
|
||||
"Open the release page": "Apri la pagina della versione",
|
||||
"A linked computer": "Un computer collegato",
|
||||
"Away now: a run waits for it, then counts as missed.": "Assente ora: un’esecuzione lo aspetta, poi conta come persa.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Non accetta lavoro pianificato: attiva lì Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Questo computer (%(name)s)",
|
||||
"Waiting for the computer": "In attesa del computer",
|
||||
"approvals wait %(n)s min": "le approvazioni attendono %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
|
||||
},
|
||||
"pl": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
||||
@@ -2011,6 +2421,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dni",
|
||||
"Time": "Godzina",
|
||||
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.",
|
||||
"No archived chats.": "Brak zarchiwizowanych czatów.",
|
||||
"Computer": "Komputer",
|
||||
"Folder on %(device)s": "Folder na %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Pełna ścieżka folderu na tym komputerze. Jego agent otwiera tylko foldery, które mu udostępniono.",
|
||||
"Choose": "Wybierz",
|
||||
"Choose a folder…": "Wybierz folder…",
|
||||
"Type a folder…": "Wpisz folder…",
|
||||
"Choose a folder first.": "Najpierw wybierz folder.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Jak na tym komputerze (%(language)s)",
|
||||
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.",
|
||||
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.",
|
||||
"Move to folder…": "Przenieś do folderu…",
|
||||
"No folder": "Bez folderu",
|
||||
"New folder…": "Nowy folder…",
|
||||
"Create": "Utwórz",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s” i foldery w nim zostaną usunięte. Ich czaty zostaną, z powrotem na liście.",
|
||||
"Add": "Dodaj",
|
||||
"Data group": "Grupa danych",
|
||||
"Remove this memory?": "Usunąć to wspomnienie?",
|
||||
"Noted by a model": "Zanotowane przez model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Co Twoje modele pamiętają o Tobie we wszystkich czatach. Tutaj możesz wszystko zmienić lub usunąć.",
|
||||
"Something to remember…": "Coś do zapamiętania…",
|
||||
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
|
||||
"Tools and skills…": "Narzędzia i umiejętności…",
|
||||
"Nothing to switch in this chat.": "W tym czacie nie ma czego przełączać.",
|
||||
"Stop dictating": "Zatrzymaj dyktowanie",
|
||||
"Writing down…": "Zapisywanie…",
|
||||
"The microphone could not be opened: %(why)s": "Nie udało się otworzyć mikrofonu: %(why)s",
|
||||
"Read this aloud": "Przeczytaj na głos",
|
||||
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
|
||||
"What to draw…": "Co narysować…",
|
||||
"Shape": "Kształt",
|
||||
"Square": "Kwadrat",
|
||||
"Wide": "Szeroki",
|
||||
"Tall": "Wysoki",
|
||||
"Draw": "Narysuj",
|
||||
"Drawing… a picture can take a minute or two.": "Rysowanie… obraz może zająć minutę lub dwie.",
|
||||
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Twój serwer zaleca LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Wydanie pasujące do wersji serwera. To jest %(current)s.",
|
||||
"Open the release page": "Otwórz stronę wydania",
|
||||
"A linked computer": "Połączony komputer",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teraz nieobecny: uruchomienie na niego czeka, potem liczy się jako pominięte.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nie przyjmuje zaplanowanej pracy: włącz tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Ten komputer (%(name)s)",
|
||||
"Waiting for the computer": "Czeka na komputer",
|
||||
"approvals wait %(n)s min": "zatwierdzenia czekają %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
|
||||
},
|
||||
"sk": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
||||
@@ -2234,6 +2696,58 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dni",
|
||||
"Time": "Čas",
|
||||
"No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.",
|
||||
"No archived chats.": "Žiadne archivované konverzácie.",
|
||||
"Computer": "Počítač",
|
||||
"Folder on %(device)s": "Priečinok na zariadení %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.",
|
||||
"Choose": "Vybrať",
|
||||
"Choose a folder…": "Vybrať priečinok…",
|
||||
"Type a folder…": "Zadať priečinok…",
|
||||
"Choose a folder first.": "Najprv vyberte priečinok.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Podľa tohto počítača (%(language)s)",
|
||||
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.",
|
||||
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
|
||||
"Move to folder…": "Presunúť do priečinka…",
|
||||
"No folder": "Bez priečinka",
|
||||
"New folder…": "Nový priečinok…",
|
||||
"Create": "Vytvoriť",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.",
|
||||
"Add": "Pridať",
|
||||
"Data group": "Skupina údajov",
|
||||
"Remove this memory?": "Odstrániť tento záznam z pamäte?",
|
||||
"Noted by a model": "Zaznamenal model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
|
||||
"Something to remember…": "Niečo na zapamätanie…",
|
||||
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
|
||||
"Tools and skills…": "Nástroje a schopnosti…",
|
||||
"Nothing to switch in this chat.": "V tejto konverzácii nie je čo prepnúť.",
|
||||
"Stop dictating": "Zastaviť diktovanie",
|
||||
"Writing down…": "Zapisujem…",
|
||||
"The microphone could not be opened: %(why)s": "Mikrofón sa nepodarilo otvoriť: %(why)s",
|
||||
"Read this aloud": "Prečítať nahlas",
|
||||
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
|
||||
"What to draw…": "Čo nakresliť…",
|
||||
"Shape": "Tvar",
|
||||
"Square": "Štvorec",
|
||||
"Wide": "Široký",
|
||||
"Tall": "Vysoký",
|
||||
"Draw": "Nakresliť",
|
||||
"Drawing… a picture can take a minute or two.": "Kreslím… obrázok môže trvať minútu alebo dve.",
|
||||
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Váš server odporúča LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Vydanie, ktoré patrí k verzii servera. Toto je %(current)s.",
|
||||
"Open the release page": "Otvoriť stránku vydania",
|
||||
"A linked computer": "Prepojený počítač",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teraz nie je k dispozícii: spustenie naň počká, potom sa počíta ako zmeškané.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Neprijíma plánovanú prácu: zapnite tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Tento počítač (%(name)s)",
|
||||
"Waiting for the computer": "Čaká na počítač",
|
||||
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2252,9 +2766,13 @@ def server_catalogue(server: Path, lang: str) -> dict[str, str]:
|
||||
messages = getattr(module, "MESSAGES", None)
|
||||
if not isinstance(messages, dict):
|
||||
sys.exit(f"gen-i18n: {path} has no MESSAGES")
|
||||
NAMES[lang] = str(getattr(module, "NAME", "") or lang)
|
||||
return messages
|
||||
|
||||
|
||||
NAMES: dict[str, str] = {} # each language's own name for itself, as the server's catalogue says
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas"))
|
||||
catalogue: dict[str, dict[str, str]] = {}
|
||||
@@ -2287,24 +2805,50 @@ def main() -> None:
|
||||
print(f"gen-i18n: {lang}: {len(table)} sentences, {from_server} of the page's {len(KEYS)} from the server")
|
||||
|
||||
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
names = json.dumps({"en": "English", **NAMES}, ensure_ascii=False, sort_keys=True)
|
||||
out = (
|
||||
"// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:\n"
|
||||
"// the sentences the server also has come from its web/i18n catalogues, the rest from the\n"
|
||||
"// script's own table. Do not edit; run the script.\n"
|
||||
"//\n"
|
||||
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n"
|
||||
"// missing entry is the English. The language is the system's (navigator.language),\n"
|
||||
"// English unless it is one of these. Elements marked `data-i18n` (their text) and\n"
|
||||
"// missing entry is the English. The language is the account's (kept from /me as\n"
|
||||
"// `lembas.account-language`) unless Settings chose this computer's or another\n"
|
||||
"// (`lembas.language`); the system's is navigator.language. English unless it is one of\n"
|
||||
"// these. Elements marked `data-i18n` (their text) and\n"
|
||||
"// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n"
|
||||
"// are translated once the page is parsed; this script is deferred, before the page's.\n"
|
||||
'"use strict"\n'
|
||||
"\n"
|
||||
f"const LEMBAS_I18N = {body}\n"
|
||||
"\n"
|
||||
"const LEMBAS_LANG = (() => {\n"
|
||||
"// Each language's name for itself, for the choice in Settings.\n"
|
||||
f"const LEMBAS_LANGS = {names}\n"
|
||||
"\n"
|
||||
"// What the system's language gives, and what was chosen here instead (\"\" for the system's).\n"
|
||||
"const LEMBAS_SYSTEM_LANG = (() => {\n"
|
||||
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
|
||||
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
|
||||
"})()\n"
|
||||
"// Settings' choice: \"\" for the account's language (as /me last said it), \"system\", or a code.\n"
|
||||
"const LEMBAS_CHOICE = (() => {\n"
|
||||
" try {\n"
|
||||
' return localStorage.getItem("lembas.language") || ""\n'
|
||||
" } catch {\n"
|
||||
' return ""\n'
|
||||
" }\n"
|
||||
"})()\n"
|
||||
"const LEMBAS_CHOSEN_LANG = (() => {\n"
|
||||
" const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)\n"
|
||||
' if (LEMBAS_CHOICE === "system") return ""\n'
|
||||
" if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE\n"
|
||||
' let account = ""\n'
|
||||
" try {\n"
|
||||
' account = localStorage.getItem("lembas.account-language") || ""\n'
|
||||
" } catch {}\n"
|
||||
' return has(account) ? account : ""\n'
|
||||
"})()\n"
|
||||
"const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n"
|
||||
"document.documentElement.lang = LEMBAS_LANG\n"
|
||||
"\n"
|
||||
"function t(text, fields) {\n"
|
||||
|
||||
Generated
+1
@@ -2149,6 +2149,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tungstenite",
|
||||
"ureq",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -32,6 +32,11 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The webview's own settings: getUserMedia is off in WebKitGTK until the app turns it on.
|
||||
# The same crate and version wry already builds on.
|
||||
webkit2gtk = { version = "2.0", features = ["v2_4"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
+287
-9
@@ -36,13 +36,14 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::webview::{PermissionKind, PermissionResponse};
|
||||
use tauri::window::Color;
|
||||
use tauri::{
|
||||
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
@@ -122,6 +123,9 @@ struct App {
|
||||
watch: watch::Watch,
|
||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||
dropped: Mutex<Vec<PathBuf>>,
|
||||
/// Until when the page's own Dictate button may have the microphone (`voice_arm`): a request
|
||||
/// for it at any other moment is refused.
|
||||
mic_until: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
struct TrayItems {
|
||||
@@ -153,6 +157,13 @@ impl App {
|
||||
Some((server.app, account.token))
|
||||
}
|
||||
|
||||
/// The server's own address and the app's token: for the `/v1` routes (voice, pictures).
|
||||
fn door_v1(&self) -> Option<(String, String)> {
|
||||
let server = self.server.lock().unwrap().clone()?;
|
||||
let account = self.account.lock().unwrap().clone()?;
|
||||
Some((server.base, account.token))
|
||||
}
|
||||
|
||||
/// The folders the agent may use: what the explorer and the viewer may read.
|
||||
fn roots(&self) -> Vec<String> {
|
||||
self.remote
|
||||
@@ -452,6 +463,77 @@ async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
// --- Voice and pictures (`/v1`, the app's token holds the `models` scope) -----------------------
|
||||
|
||||
/// Run a `/v1` call off the page's thread, as the signed-in account; a 401 signs the app out.
|
||||
async fn on_v1<T: Send + 'static>(
|
||||
handle: &AppHandle,
|
||||
f: impl FnOnce(&str, &str) -> Result<T, ApiError> + Send + 'static,
|
||||
) -> Result<T, ApiError> {
|
||||
let Some((base, token)) = handle.state::<App>().door_v1() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let result = tauri::async_runtime::spawn_blocking(move || f(&base, &token))
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||
if matches!(&result, Err(e) if e.status == 401) {
|
||||
token_refused(handle);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The page is about to ask for the microphone, because the person pressed Dictate: for the
|
||||
/// next few seconds a request for it is allowed (`permission`), and at no other time.
|
||||
#[tauri::command]
|
||||
fn voice_arm(app: tauri::State<'_, App>) {
|
||||
*app.mic_until.lock().unwrap() =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(10));
|
||||
}
|
||||
|
||||
/// A recording made in the composer, in words: its bytes as the raw body of the call, its media
|
||||
/// type in `x-type`.
|
||||
#[tauri::command]
|
||||
async fn transcribe(
|
||||
handle: AppHandle,
|
||||
request: tauri::ipc::Request<'_>,
|
||||
) -> Result<String, ApiError> {
|
||||
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
|
||||
return Err(ApiError::new(400, "No recording."));
|
||||
};
|
||||
if bytes.len() as u64 > server::RECORDING_LIMIT {
|
||||
return Err(ApiError::new(413, "That recording is too long."));
|
||||
}
|
||||
let kind = request
|
||||
.headers()
|
||||
.get("x-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = bytes.clone();
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::transcribe(base, token, &kind, &bytes)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// A reply read aloud, as a `data:` URL of its audio.
|
||||
#[tauri::command]
|
||||
async fn speak(handle: AppHandle, text: String) -> Result<String, ApiError> {
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::speak(base, token, &text)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pictures drawn by the instance for `prompt`, as `data:` URLs.
|
||||
#[tauri::command]
|
||||
async fn draw(handle: AppHandle, prompt: String, size: String) -> Result<Vec<String>, ApiError> {
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::draw(base, token, &prompt, &size)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
|
||||
/// first word of "continue in a terminal" for an agent chat on this computer.
|
||||
#[tauri::command]
|
||||
@@ -690,6 +772,75 @@ async fn attach_dropped(
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// What an upload into the library came to: the directory's listing after the last file the
|
||||
/// server took, and the files it did not take, each with the reason.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LibraryUpload {
|
||||
listing: Option<Value>,
|
||||
sent: usize,
|
||||
failed: Vec<Uploaded>,
|
||||
}
|
||||
|
||||
/// Choose files with the system's dialog and send them into the person's library, into the
|
||||
/// directory `dir`. Nothing is sent when the dialog is cancelled.
|
||||
#[tauri::command]
|
||||
async fn library_upload(
|
||||
handle: AppHandle,
|
||||
dir: String,
|
||||
title: String,
|
||||
) -> Result<LibraryUpload, 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();
|
||||
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let mut out = LibraryUpload {
|
||||
listing: None,
|
||||
sent: 0,
|
||||
failed: 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_to_library(&app_url, &token, &dir, &name, &bytes)
|
||||
.map_err(|e| {
|
||||
if e.status == 401 {
|
||||
token_refused(&h);
|
||||
}
|
||||
(None, e.detail)
|
||||
}),
|
||||
Err((reason, message)) => Err((Some(reason), message)),
|
||||
};
|
||||
match sent {
|
||||
Ok(listing) => {
|
||||
out.listing = Some(listing);
|
||||
out.sent += 1;
|
||||
}
|
||||
Err((reason, message)) => out.failed.push(Uploaded {
|
||||
name,
|
||||
size,
|
||||
attachment: None,
|
||||
error: Some(message),
|
||||
reason,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.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();
|
||||
@@ -1180,7 +1331,39 @@ fn build_main(handle: &AppHandle) -> tauri::Result<WebviewWindow> {
|
||||
.decorations(false)
|
||||
.background_color(theme.background())
|
||||
.on_navigation(allowed)
|
||||
.on_permission_request(permission)
|
||||
.build()
|
||||
.inspect(media_stream)
|
||||
}
|
||||
|
||||
/// WebKitGTK answers no permission request its app does not, and has no prompt of its own. The
|
||||
/// microphone is allowed when the page's own Dictate button has just asked for it (`voice_arm`,
|
||||
/// which only the app's page can call: sealed frames have no bridge), once; nothing else is.
|
||||
fn permission(view: tauri::Webview, kind: PermissionKind) -> PermissionResponse {
|
||||
if kind != PermissionKind::Microphone {
|
||||
return PermissionResponse::Default;
|
||||
}
|
||||
let app = view.app_handle().state::<App>();
|
||||
let armed = app.mic_until.lock().unwrap().take();
|
||||
if armed.is_some_and(|until| std::time::Instant::now() <= until) {
|
||||
PermissionResponse::Allow
|
||||
} else {
|
||||
PermissionResponse::Deny
|
||||
}
|
||||
}
|
||||
|
||||
/// `getUserMedia` is off in WebKitGTK unless the app turns it on (it is on in other webviews).
|
||||
/// The settings are read with their own getter: the `settings` property is write-only.
|
||||
fn media_stream(window: &WebviewWindow) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = window.with_webview(|pv| {
|
||||
use webkit2gtk::{SettingsExt, WebViewExt};
|
||||
if let Some(settings) = pv.inner().settings() {
|
||||
settings.set_enable_media_stream(true);
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = window;
|
||||
}
|
||||
|
||||
/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever.
|
||||
@@ -1473,9 +1656,11 @@ fn watch_chats(handle: AppHandle) {
|
||||
} else {
|
||||
d.title.clone()
|
||||
};
|
||||
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body(if d.agent { &words.agent } else { &words.reply })
|
||||
.show();
|
||||
@@ -1483,6 +1668,56 @@ fn watch_chats(handle: AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A click on one of the app's notifications, or on one of its actions: a chat's opens that chat;
|
||||
/// a scheduled run's opens its task (`open`, or the notification itself), runs it again now
|
||||
/// (`run_again`) or pauses it (`pause`) -- the last two without opening the window.
|
||||
fn notification_action(handle: &AppHandle, done: &ActionPerformed) {
|
||||
let Some(id) = done.notification().map(|n| n.id()) else {
|
||||
return;
|
||||
};
|
||||
let Some(note) = handle.state::<App>().watch.notes.get(id) else {
|
||||
return;
|
||||
};
|
||||
match (note, done.action_id()) {
|
||||
(watch::Note::Chat(chat), "tap") => {
|
||||
show(handle);
|
||||
let _ = handle.emit("open-chat", chat);
|
||||
}
|
||||
(watch::Note::Run { schedule, run }, "tap" | "open") => {
|
||||
show(handle);
|
||||
let _ = handle.emit(
|
||||
"open-schedule",
|
||||
serde_json::json!({ "schedule": schedule, "run": run }),
|
||||
);
|
||||
}
|
||||
(watch::Note::Run { schedule, .. }, which @ ("run_again" | "pause")) => {
|
||||
let path = format!(
|
||||
"/schedules/{}/{}",
|
||||
terminal::encode(&schedule),
|
||||
if which == "pause" { "pause" } else { "run" }
|
||||
);
|
||||
let h = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||
return;
|
||||
};
|
||||
match server::request(
|
||||
&app_url,
|
||||
&token,
|
||||
"POST",
|
||||
&path,
|
||||
Some(&serde_json::json!({})),
|
||||
) {
|
||||
Err(e) if e.status == 401 => token_refused(&h),
|
||||
Err(e) => log::write(&format!("a notification's action failed: {}", e.detail)),
|
||||
Ok(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait before following the event stream again: after it closed or broke off, and
|
||||
/// (longer) on a server that has none.
|
||||
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
@@ -1520,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
|
||||
} else {
|
||||
r.title
|
||||
};
|
||||
let body = match r.told {
|
||||
watch::Told::Result => &words.scheduled,
|
||||
watch::Told::Failed => &words.scheduled_failed,
|
||||
watch::Told::Pause => &words.scheduled_pause,
|
||||
};
|
||||
let _ = handle
|
||||
let body = r.body.clone().unwrap_or_else(|| {
|
||||
match r.told {
|
||||
watch::Told::Result => &words.scheduled,
|
||||
watch::Told::Failed => &words.scheduled_failed,
|
||||
watch::Told::Pause => &words.scheduled_pause,
|
||||
}
|
||||
.clone()
|
||||
});
|
||||
let id = app.watch.notes.add(watch::Note::Run {
|
||||
schedule: r.schedule.clone(),
|
||||
run: r.run.clone(),
|
||||
});
|
||||
let mut note = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body(body)
|
||||
.show();
|
||||
.body(body);
|
||||
if !r.actions.is_empty() {
|
||||
// One action type per set of actions; registered again, it takes the labels
|
||||
// this run came with (the person's language may have changed).
|
||||
let kind = format!(
|
||||
"schedule-{}",
|
||||
r.actions
|
||||
.iter()
|
||||
.map(|(a, _)| a.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
);
|
||||
let actions = r
|
||||
.actions
|
||||
.iter()
|
||||
.map(|(a, label)| Action::builder(a, label).build())
|
||||
.collect();
|
||||
let _ = handle
|
||||
.notification()
|
||||
.register_action_types(vec![ActionType::builder(&kind)
|
||||
.actions(actions)
|
||||
.build()]);
|
||||
note = note.action_type_id(kind);
|
||||
}
|
||||
let _ = note.show();
|
||||
}
|
||||
true
|
||||
});
|
||||
@@ -1640,6 +1906,11 @@ pub fn run() {
|
||||
sealed_open,
|
||||
sealed_close,
|
||||
attach_pick,
|
||||
library_upload,
|
||||
voice_arm,
|
||||
transcribe,
|
||||
speak,
|
||||
draw,
|
||||
attach_dropped,
|
||||
attach_bytes,
|
||||
set_theme,
|
||||
@@ -1700,6 +1971,7 @@ pub fn run() {
|
||||
sealed: sealed::Pages::default(),
|
||||
watch: watch::Watch::default(),
|
||||
dropped: Mutex::new(Vec::new()),
|
||||
mic_until: Mutex::new(None),
|
||||
});
|
||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||
// starts: the title bar's menu has every item the tray has.
|
||||
@@ -1711,6 +1983,12 @@ pub fn run() {
|
||||
watch_updates(handle.clone());
|
||||
watch_chats(handle.clone());
|
||||
watch_events(handle.clone());
|
||||
{
|
||||
let h = handle.clone();
|
||||
let _ = handle
|
||||
.notification()
|
||||
.on_action(move |done| notification_action(&h, done));
|
||||
}
|
||||
{
|
||||
let h = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
+268
-11
@@ -650,12 +650,17 @@ fn header_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// The body of a `multipart/form-data` request with one file in the field `field`.
|
||||
fn multipart(boundary: &str, field: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
multipart_typed(boundary, field, name, "application/octet-stream", bytes)
|
||||
}
|
||||
|
||||
/// The same, with the file's own media type (a recording says what it is: `audio/webm`).
|
||||
fn multipart_typed(boundary: &str, field: &str, name: &str, kind: &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",
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: {kind}\r\n\r\n",
|
||||
header_name(name)
|
||||
)
|
||||
.as_bytes(),
|
||||
@@ -685,6 +690,224 @@ pub fn upload(
|
||||
} else {
|
||||
format!("/attachments?chat={chat}")
|
||||
};
|
||||
let v = send_file(app, token, &path, "file", name, bytes)?;
|
||||
v.get("attachment")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
||||
}
|
||||
|
||||
/// Send one file into the person's library, into the directory `dir` (`/files/upload`, the
|
||||
/// explorer's upload): the server's listing of that directory afterwards, as JSON.
|
||||
pub fn upload_to_library(
|
||||
app: &str,
|
||||
token: &str,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Value, ApiError> {
|
||||
if !dir.starts_with('/') || dir.chars().any(|c| c.is_control()) {
|
||||
return Err(ApiError::new(400, format!("Not a folder: {dir}")));
|
||||
}
|
||||
let path = format!("/files/upload?path={}", crate::terminal::encode(dir));
|
||||
send_file(app, token, &path, "files", name, bytes)
|
||||
}
|
||||
|
||||
// --- Voice and pictures: the OpenAI-compatible routes of `/v1`, under the `models` scope ------------
|
||||
|
||||
/// The most a recording may be here: the server's own limit is 25 MB.
|
||||
pub const RECORDING_LIMIT: u64 = 25 * 1024 * 1024;
|
||||
/// The most a reply read aloud may be, as audio.
|
||||
pub const SPEECH_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
/// The most a generated picture may be.
|
||||
pub const DRAWN_LIMIT: u64 = 24 * 1024 * 1024;
|
||||
/// The media types a recording may say it is; anything else goes as `audio/webm`.
|
||||
const RECORDING_TYPES: [&str; 5] = [
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
];
|
||||
|
||||
/// A recording's media type as it may be sent: one of ours, its parameters (`;codecs=opus`) off.
|
||||
pub fn recording_type(kind: &str) -> &'static str {
|
||||
let bare = kind
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
RECORDING_TYPES
|
||||
.iter()
|
||||
.find(|t| **t == bare)
|
||||
.copied()
|
||||
.unwrap_or("audio/webm")
|
||||
}
|
||||
|
||||
/// A recording, in words: `POST /v1/audio/transcriptions` (the instance's own model, the
|
||||
/// account's language). The text the server heard.
|
||||
pub fn transcribe(base: &str, token: &str, kind: &str, bytes: &[u8]) -> Result<String, ApiError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(ApiError::new(400, "The recording was empty."));
|
||||
}
|
||||
let kind = recording_type(kind);
|
||||
let ext = kind
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("webm")
|
||||
.replace("mpeg", "mp3");
|
||||
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!("{base}/v1/audio/transcriptions"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.set("accept", "application/json")
|
||||
.set(
|
||||
"content-type",
|
||||
&format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.send_bytes(&multipart_typed(
|
||||
&boundary,
|
||||
"file",
|
||||
&format!("speech.{ext}"),
|
||||
kind,
|
||||
bytes,
|
||||
))?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
Ok(v.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// `bytes` of a media type of `kinds` (by its prefix), as a `data:` URL; at most `limit`.
|
||||
fn as_data_url(res: ureq::Response, prefix: &str, limit: u64) -> Result<String, ApiError> {
|
||||
let kind = res
|
||||
.content_type()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let fits = if prefix == "image/" {
|
||||
IMAGE_TYPES.contains(&kind.as_str())
|
||||
} else {
|
||||
kind.starts_with(prefix)
|
||||
&& kind
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "/-+.".contains(c))
|
||||
};
|
||||
if !fits {
|
||||
return Err(ApiError::new(
|
||||
415,
|
||||
format!("Not what was asked for: {kind}"),
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
res.into_reader()
|
||||
.take(limit)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||
if bytes.len() as u64 >= limit {
|
||||
return Err(ApiError::new(413, "The server's answer is too large."));
|
||||
}
|
||||
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||
}
|
||||
|
||||
/// A reply read aloud: `POST /v1/audio/speech` in the account's own voice and speed. The audio,
|
||||
/// as a `data:` URL the page plays.
|
||||
pub fn speak(base: &str, token: &str, text: &str) -> Result<String, ApiError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(ApiError::new(400, "There is nothing to read aloud."));
|
||||
}
|
||||
let res = agent(Duration::from_secs(300))
|
||||
.post(&format!("{base}/v1/audio/speech"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.send_json(serde_json::json!({ "input": text, "response_format": "mp3" }))?;
|
||||
as_data_url(res, "audio/", SPEECH_LIMIT)
|
||||
}
|
||||
|
||||
/// The id of one of the person's own files in a URL `/v1/images/generations` answered with:
|
||||
/// `…/v1/files/<id>/content`, and nothing else. The picture is then fetched from this app's own
|
||||
/// server by that id -- never from the address the answer named.
|
||||
pub fn own_file_id(url: &str) -> Option<String> {
|
||||
let rest = url.split(['?', '#']).next()?;
|
||||
let tail = rest.strip_suffix("/content")?;
|
||||
let (head, id) = tail.rsplit_once('/')?;
|
||||
let ok = head.ends_with("/v1/files")
|
||||
&& !id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
ok.then(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Sizes a picture may be asked for: the instance rounds to what its workflow takes.
|
||||
const SIZES: [&str; 3] = ["1024x1024", "1344x768", "768x1344"];
|
||||
|
||||
/// Pictures drawn by the instance: `POST /v1/images/generations` (its own workflow and default
|
||||
/// checkpoint), each then read from the person's own files on this server. `data:` URLs.
|
||||
pub fn draw(base: &str, token: &str, prompt: &str, size: &str) -> Result<Vec<String>, ApiError> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err(ApiError::new(400, "Say what to draw."));
|
||||
}
|
||||
let mut body = serde_json::json!({ "prompt": prompt, "n": 1, "response_format": "url" });
|
||||
if SIZES.contains(&size) {
|
||||
body["size"] = Value::String(size.to_string());
|
||||
}
|
||||
let res = agent(Duration::from_secs(900))
|
||||
.post(&format!("{base}/v1/images/generations"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.set("accept", "application/json")
|
||||
.send_json(body)?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
let ids: Vec<String> = v
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|i| i.get("url").and_then(Value::as_str).and_then(own_file_id))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return Err(ApiError::new(
|
||||
0,
|
||||
"The server's answer had no picture of yours in it.",
|
||||
));
|
||||
}
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let res = agent(Duration::from_secs(60))
|
||||
.get(&format!("{base}/v1/files/{id}/content"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.call()?;
|
||||
as_data_url(res, "image/", DRAWN_LIMIT)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One `multipart/form-data` POST of one file to `path` on the app's door; the JSON answer.
|
||||
fn send_file(
|
||||
app: &str,
|
||||
token: &str,
|
||||
path: &str,
|
||||
field: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Value, ApiError> {
|
||||
let path = checked_path(path)?;
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
@@ -698,13 +921,9 @@ pub fn upload(
|
||||
"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()))
|
||||
.send_bytes(&multipart(&boundary, field, name, bytes))?;
|
||||
res.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
|
||||
}
|
||||
|
||||
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||
@@ -957,9 +1176,45 @@ mod tests {
|
||||
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_picture_is_read_only_as_one_of_the_persons_own_files() {
|
||||
assert_eq!(
|
||||
own_file_id("https://ai.example/v1/files/abc_12-3/content").as_deref(),
|
||||
Some("abc_12-3")
|
||||
);
|
||||
assert_eq!(
|
||||
own_file_id("http://other/v1/files/x/content?t=1").as_deref(),
|
||||
Some("x")
|
||||
);
|
||||
assert_eq!(own_file_id("https://ai.example/v1/files/../content"), None);
|
||||
assert_eq!(own_file_id("https://ai.example/v1/files//content"), None);
|
||||
assert_eq!(own_file_id("https://ai.example/uploads/x/content"), None);
|
||||
assert_eq!(
|
||||
own_file_id("https://ai.example/v1/files/a%2Fb/content"),
|
||||
None
|
||||
);
|
||||
assert_eq!(own_file_id("javascript:alert(1)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_says_one_of_the_known_types() {
|
||||
assert_eq!(recording_type("audio/webm;codecs=opus"), "audio/webm");
|
||||
assert_eq!(recording_type("Audio/OGG"), "audio/ogg");
|
||||
assert_eq!(recording_type("text/html"), "audio/webm");
|
||||
let body = String::from_utf8(multipart_typed(
|
||||
"B",
|
||||
"file",
|
||||
"speech.webm",
|
||||
"audio/webm",
|
||||
b"x",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(body.contains("filename=\"speech.webm\"\r\nContent-Type: audio/webm\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_goes_as_one_multipart_field() {
|
||||
let body = multipart("XYZ", "a\"b\r\n.txt", b"hello");
|
||||
let body = multipart("XYZ", "file", "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"
|
||||
@@ -967,6 +1222,8 @@ mod tests {
|
||||
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");
|
||||
let library = String::from_utf8(multipart("XYZ", "files", "b.md", b"x")).unwrap();
|
||||
assert!(library.contains("name=\"files\"; filename=\"b.md\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+172
-4
@@ -15,8 +15,15 @@
|
||||
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
||||
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
||||
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
||||
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
|
||||
//! the stream answers 404, and nothing is said.
|
||||
//! whether to pause it. A server that made the run's notification (`run.notification`, server
|
||||
//! 2.0.0-beta.9) gives its words: the task's name and the result's first lines -- shown here as on
|
||||
//! an unlocked phone, the desktop being the person's own -- and its actions with their labels in
|
||||
//! the person's language (`open`, `run_again`, and `pause` on a failure that asks). An older
|
||||
//! server's run is said by the schedule's title and the page's words, without actions. A server
|
||||
//! without the stream answers 404, and nothing is said.
|
||||
//!
|
||||
//! Every notification shown is remembered by its id (`Notes`), so a click on it or on one of its
|
||||
//! actions knows which chat or which run it was about.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -90,6 +97,8 @@ pub struct Watch {
|
||||
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
||||
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
||||
stop: Mutex<Arc<AtomicBool>>,
|
||||
/// The notifications shown, so a click on one knows what it was about.
|
||||
pub notes: Notes,
|
||||
}
|
||||
|
||||
/// How a scheduled task's run is told.
|
||||
@@ -108,6 +117,95 @@ pub enum Told {
|
||||
pub struct Ran {
|
||||
pub title: String,
|
||||
pub told: Told,
|
||||
pub schedule: String,
|
||||
pub run: String,
|
||||
/// The server's own words for it (`run.notification.body`): the result's first lines, or the
|
||||
/// failure said. None from a server that does not make them.
|
||||
pub body: Option<String>,
|
||||
/// The actions it offers, `(id, label)`, in the server's order: only those the app can do.
|
||||
pub actions: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The actions of a scheduled run's notification the app knows how to do.
|
||||
pub const RUN_ACTIONS: [&str; 3] = ["open", "run_again", "pause"];
|
||||
|
||||
/// The most of a result's first lines a notification carries: the server cuts them already.
|
||||
const BODY_MAX: usize = 400;
|
||||
|
||||
/// The words and actions of a run's own notification (`run.notification`), as far as they are
|
||||
/// usable: a body without control characters and not too long; actions the app knows, each with
|
||||
/// its label (the id when the server gave none).
|
||||
fn run_notification(run: &Value) -> (Option<String>, Vec<(String, String)>) {
|
||||
let Some(n) = run.get("notification").filter(|n| n.is_object()) else {
|
||||
return (None, Vec::new());
|
||||
};
|
||||
let body = n
|
||||
.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.map(|b| {
|
||||
b.chars()
|
||||
.map(|c| if c.is_control() && c != '\n' { ' ' } else { c })
|
||||
.take(BODY_MAX)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|b| !b.is_empty());
|
||||
let labels = n.get("labels");
|
||||
let actions = n
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|a| RUN_ACTIONS.contains(a))
|
||||
.map(|a| {
|
||||
let label = labels
|
||||
.and_then(|l| l.get(a))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.unwrap_or(a);
|
||||
(a.to_string(), label.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(body, actions)
|
||||
}
|
||||
|
||||
/// What a notification shown was about.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Note {
|
||||
/// A chat whose reply or work finished.
|
||||
Chat(String),
|
||||
/// A scheduled task's run.
|
||||
Run { schedule: String, run: String },
|
||||
}
|
||||
|
||||
/// The notifications shown, by id: the most recent `NOTES_KEPT`.
|
||||
#[derive(Default)]
|
||||
pub struct Notes {
|
||||
next: Mutex<i32>,
|
||||
by_id: Mutex<HashMap<i32, Note>>,
|
||||
}
|
||||
|
||||
const NOTES_KEPT: i32 = 200;
|
||||
|
||||
impl Notes {
|
||||
/// Remember a notification about to be shown; its id.
|
||||
pub fn add(&self, note: Note) -> i32 {
|
||||
let mut next = self.next.lock().unwrap();
|
||||
*next = next.checked_add(1).unwrap_or(1);
|
||||
let id = *next;
|
||||
let mut by_id = self.by_id.lock().unwrap();
|
||||
by_id.insert(id, note);
|
||||
by_id.retain(|k, _| *k > id - NOTES_KEPT);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i32) -> Option<Note> {
|
||||
self.by_id.lock().unwrap().get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
||||
@@ -212,6 +310,7 @@ impl Watch {
|
||||
.unwrap_or(0),
|
||||
);
|
||||
if let Some(told) = told {
|
||||
let (body, actions) = run_notification(run);
|
||||
out.push(Ran {
|
||||
title: row
|
||||
.get("title")
|
||||
@@ -219,6 +318,10 @@ impl Watch {
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
told,
|
||||
schedule: id.to_string(),
|
||||
run: run_id.to_string(),
|
||||
body,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -259,13 +362,75 @@ mod tests {
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn ran(id: &str, told: Told) -> Ran {
|
||||
fn ran(id: &str, run: &str, told: Told) -> Ran {
|
||||
Ran {
|
||||
title: format!("Task {id}"),
|
||||
told,
|
||||
schedule: id.into(),
|
||||
run: run.into(),
|
||||
body: None,
|
||||
actions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runs_own_notification_gives_its_first_lines_and_the_actions_the_app_can_do() {
|
||||
let w = Watch::default();
|
||||
let row = |run: Value| {
|
||||
json!({"version": "v", "schedules": [{
|
||||
"id": "s1", "title": "Waybread check", "notify": "matters", "failure_streak": 3,
|
||||
"last_run": run,
|
||||
}]})
|
||||
};
|
||||
w.look_schedules(&row(json!({"id": "r0", "status": "running"})));
|
||||
let out = w.look_schedules(&row(json!({"id": "r0", "status": "failed", "notified": true,
|
||||
"notification": {"title": "Waybread check", "body": "Failed 3 times\u{7} in a row. Pause it?",
|
||||
"actions": ["open", "run_again", "pause", "unknown"],
|
||||
"labels": {"open": "Otvoriť", "run_again": "Spustiť znova", "pause": ""}}})));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].told, Told::Pause);
|
||||
assert_eq!(
|
||||
(out[0].schedule.as_str(), out[0].run.as_str()),
|
||||
("s1", "r0")
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].body.as_deref(),
|
||||
Some("Failed 3 times in a row. Pause it?")
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].actions,
|
||||
vec![
|
||||
("open".to_string(), "Otvoriť".to_string()),
|
||||
("run_again".to_string(), "Spustiť znova".to_string()),
|
||||
("pause".to_string(), "pause".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notification_is_found_again_by_its_id() {
|
||||
let notes = Notes::default();
|
||||
let a = notes.add(Note::Chat("c1".into()));
|
||||
let b = notes.add(Note::Run {
|
||||
schedule: "s1".into(),
|
||||
run: "r1".into(),
|
||||
});
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(notes.get(a), Some(Note::Chat("c1".into())));
|
||||
assert_eq!(
|
||||
notes.get(b),
|
||||
Some(Note::Run {
|
||||
schedule: "s1".into(),
|
||||
run: "r1".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(notes.get(b + 1), None);
|
||||
for _ in 0..NOTES_KEPT {
|
||||
notes.add(Note::Chat("x".into()));
|
||||
}
|
||||
assert_eq!(notes.get(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
||||
let w = Watch::default();
|
||||
@@ -290,7 +455,10 @@ mod tests {
|
||||
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||
("d", "never", 0, Some(("r5", "ok", false))),
|
||||
]));
|
||||
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![ran("a", "r2", Told::Result), ran("b", "r3", Told::Result)]
|
||||
);
|
||||
// The same list again (the server told a device meanwhile): not said twice.
|
||||
assert!(w
|
||||
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
"csp": "default-src 'self'; img-src 'self' data:; media-src data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
||||
LLeMbas origin/dev (e0c2332d688f69e5a7efe157f8263eac02ac28d6) tests/fixtures/api_v2
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"server": {
|
||||
"version": "2.1.0-beta.3",
|
||||
"channel": "beta"
|
||||
},
|
||||
"apps": {
|
||||
"android": {
|
||||
"version": "1.1.0-beta.2",
|
||||
"tag": "v1.1.0-beta.2",
|
||||
"channel": "beta",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Android/releases/tag/v1.1.0-beta.2"
|
||||
},
|
||||
"desktop": {
|
||||
"version": "1.0.1",
|
||||
"tag": "v1.0.1",
|
||||
"channel": "stable",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"server": {
|
||||
"version": "2.0.0",
|
||||
"channel": "stable"
|
||||
},
|
||||
"apps": {
|
||||
"android": null,
|
||||
"desktop": {
|
||||
"version": "1.0.1",
|
||||
"tag": "v1.0.1",
|
||||
"channel": "stable",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "chat1",
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"model": "qwen",
|
||||
"folder_id": null,
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"temporary": false,
|
||||
"unread": true,
|
||||
"running": false,
|
||||
"mode": "",
|
||||
"effort": "low",
|
||||
"device": null,
|
||||
"device_session_id": null,
|
||||
"parent_chat_id": null,
|
||||
"agents": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false,
|
||||
"cap": 32
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"chats": [
|
||||
{
|
||||
"id": "chat1",
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"model": "qwen",
|
||||
"folder_id": null,
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"temporary": false,
|
||||
"unread": true,
|
||||
"running": false,
|
||||
"mode": "",
|
||||
"effort": "low",
|
||||
"device": null,
|
||||
"device_session_id": null,
|
||||
"parent_chat_id": null,
|
||||
"agents": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"subagents": null,
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
],
|
||||
"more": false
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"version": "8bee85746004dc19",
|
||||
"schedules": [
|
||||
{
|
||||
"id": "sched1",
|
||||
"title": "Waybread check",
|
||||
"instruction": "Check the waybread is wrapped.",
|
||||
"request": "every second Monday at 7:30",
|
||||
"rule": {
|
||||
"at": {
|
||||
"nth": [
|
||||
{
|
||||
"n": 2,
|
||||
"weekday": 0
|
||||
}
|
||||
],
|
||||
"times": [
|
||||
"07:30"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "On the second Monday of each month at 07:30",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-12T07:30:00+00:00",
|
||||
"next_runs": [
|
||||
"2026-01-12T07:30:00+00:00",
|
||||
"2026-02-09T07:30:00+00:00",
|
||||
"2026-03-09T07:30:00+00:00",
|
||||
"2026-04-13T07:30:00+00:00",
|
||||
"2026-05-11T07:30:00+00:00"
|
||||
],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": "qwen",
|
||||
"effort": "high",
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat1",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": null,
|
||||
"runs_on": null,
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"path": "/home/frodo",
|
||||
"parent": "/home",
|
||||
"entries": [
|
||||
{
|
||||
"name": "recipes",
|
||||
"path": "/home/frodo/recipes",
|
||||
"kind": "dir",
|
||||
"size": 0,
|
||||
"mime": "",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lembas.md",
|
||||
"path": "/home/frodo/lembas.md",
|
||||
"kind": "file",
|
||||
"size": 27,
|
||||
"mime": "text/markdown",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folder": {
|
||||
"id": "fold1",
|
||||
"name": "Waybread",
|
||||
"parent_id": null,
|
||||
"kind": "",
|
||||
"position": 0,
|
||||
"collapsed": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"id": "fold1",
|
||||
"name": "Waybread",
|
||||
"parent_id": null,
|
||||
"kind": "",
|
||||
"position": 0,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": "fold2",
|
||||
"name": "Recipes",
|
||||
"parent_id": "fold1",
|
||||
"kind": "chat",
|
||||
"position": 1,
|
||||
"collapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"api": 2,
|
||||
"version": "2.0.0",
|
||||
"account": {
|
||||
"id": "user1",
|
||||
"name": "Frodo",
|
||||
"email": "frodo@shire.test"
|
||||
},
|
||||
"language": "sk",
|
||||
"theme": "shire",
|
||||
"default_model": "qwen",
|
||||
"permissions": {
|
||||
"chat.create": true,
|
||||
"chat.delete": false,
|
||||
"chat.model_select": true,
|
||||
"chat.system_prompt": true,
|
||||
"chat.params": true,
|
||||
"files.upload": true,
|
||||
"tools.agent": true,
|
||||
"audio.transcribe": true,
|
||||
"audio.listen": true
|
||||
},
|
||||
"services": {
|
||||
"stt": true,
|
||||
"tts": false,
|
||||
"search": true,
|
||||
"fetch": true,
|
||||
"images": false
|
||||
},
|
||||
"scopes": [
|
||||
"app",
|
||||
"models"
|
||||
],
|
||||
"language_setting": "sk",
|
||||
"languages": [
|
||||
{
|
||||
"id": "en",
|
||||
"name": "English"
|
||||
},
|
||||
{
|
||||
"id": "sk",
|
||||
"name": "Slovenčina"
|
||||
}
|
||||
],
|
||||
"themes": [
|
||||
{
|
||||
"id": "moria",
|
||||
"label": "Moria",
|
||||
"scheme": "dark"
|
||||
},
|
||||
{
|
||||
"id": "shire",
|
||||
"label": "Shire",
|
||||
"scheme": "light"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"id": "mem1",
|
||||
"content": "Prefers lembas warm.",
|
||||
"author": "user",
|
||||
"data_group": {
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"memory": {
|
||||
"id": "mem1",
|
||||
"content": "Prefers lembas warm.",
|
||||
"author": "user",
|
||||
"data_group": {
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schedule_id": "sched1",
|
||||
"run_id": "run1",
|
||||
"chat_id": "chat1",
|
||||
"status": "ok",
|
||||
"visibility": "private",
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"ask": "",
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"pause_label": "Pause",
|
||||
"title": "LLeMbas",
|
||||
"body": "A scheduled task has finished.",
|
||||
"url": "/scheduled/sched1?run=run1",
|
||||
"kind": "schedule"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schedule_id": "sched1",
|
||||
"run_id": "run3",
|
||||
"chat_id": "chat1",
|
||||
"status": "failed",
|
||||
"visibility": "private",
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again",
|
||||
"pause"
|
||||
],
|
||||
"ask": "pause",
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "Failed 3 times in a row. Pause it?",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Failed"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again",
|
||||
"pause"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again",
|
||||
"pause": "Pause"
|
||||
},
|
||||
"ask": "pause"
|
||||
},
|
||||
"pause_label": "Pause",
|
||||
"title": "LLeMbas",
|
||||
"body": "A scheduled task failed 3 times in a row. Pause it?",
|
||||
"url": "/scheduled/sched1?run=run3",
|
||||
"kind": "schedule_failed"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"schedule": {
|
||||
"id": "sched1",
|
||||
"title": "Waybread check",
|
||||
"instruction": "Check the waybread is wrapped.",
|
||||
"request": "every second Monday at 7:30",
|
||||
"rule": {
|
||||
"at": {
|
||||
"nth": [
|
||||
{
|
||||
"n": 2,
|
||||
"weekday": 0
|
||||
}
|
||||
],
|
||||
"times": [
|
||||
"07:30"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "On the second Monday of each month at 07:30",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-12T07:30:00+00:00",
|
||||
"next_runs": [
|
||||
"2026-01-12T07:30:00+00:00",
|
||||
"2026-02-09T07:30:00+00:00",
|
||||
"2026-03-09T07:30:00+00:00",
|
||||
"2026-04-13T07:30:00+00:00",
|
||||
"2026-05-11T07:30:00+00:00"
|
||||
],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": "qwen",
|
||||
"effort": "high",
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat1",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": null,
|
||||
"runs_on": null,
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schedule": {
|
||||
"id": "sched3",
|
||||
"title": "Nightly tests",
|
||||
"instruction": "Run the tests.",
|
||||
"request": "every night at 02:00",
|
||||
"rule": {
|
||||
"every": {
|
||||
"minutes": 1440
|
||||
}
|
||||
},
|
||||
"description": "Every 1 day",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-03T02:00:00+00:00",
|
||||
"next_runs": [],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": null,
|
||||
"effort": null,
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat2",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": {
|
||||
"kind": "command",
|
||||
"command": "git log -1 --format=%H",
|
||||
"timeout_s": 30,
|
||||
"target": "git log -1 --format=%H",
|
||||
"changed_at": "2026-01-02T03:04:05+00:00"
|
||||
},
|
||||
"runs_on": {
|
||||
"device": "tok1",
|
||||
"device_name": "bag-end",
|
||||
"cwd": "/home/frodo/waybread",
|
||||
"mode": "edit",
|
||||
"approval_wait_minutes": 30
|
||||
},
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run5",
|
||||
"schedule_id": "sched3",
|
||||
"status": "waiting",
|
||||
"timing": "on_time",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": null,
|
||||
"finished_at": null,
|
||||
"waiting_for": "device",
|
||||
"retry_at": "2026-01-02T03:09:05+00:00",
|
||||
"progress": {
|
||||
"phase": "retrying",
|
||||
"since": 1767323045000,
|
||||
"reason": "Waiting for bag-end to be linked.",
|
||||
"in": 0,
|
||||
"attempt": 1
|
||||
},
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": null,
|
||||
"name": null,
|
||||
"reason": null,
|
||||
"switched_from": null
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 0,
|
||||
"completion": 0
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": null,
|
||||
"message_id": null,
|
||||
"report_path": null,
|
||||
"preview": "",
|
||||
"error": {
|
||||
"class": "device_away",
|
||||
"text": "Waiting for bag-end to be linked."
|
||||
},
|
||||
"notified": false,
|
||||
"notification": null,
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"text": "All the waybread is *wrapped*."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"scope": {
|
||||
"families": [
|
||||
{
|
||||
"gate": "notes",
|
||||
"label": "Notes",
|
||||
"on": true
|
||||
},
|
||||
{
|
||||
"gate": "web",
|
||||
"label": "Web search",
|
||||
"on": false
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"name": "baking",
|
||||
"description": "Bake waybread.",
|
||||
"on": true
|
||||
}
|
||||
],
|
||||
"allow": [
|
||||
"bash:ls *"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"q": "lembas",
|
||||
"in": "all",
|
||||
"offered": [
|
||||
"chats",
|
||||
"files",
|
||||
"memories"
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"kind": "chats",
|
||||
"more": true,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"snippet": "…how long does lembas keep & travel?",
|
||||
"marks": [
|
||||
[
|
||||
15,
|
||||
21
|
||||
]
|
||||
],
|
||||
"archived": false,
|
||||
"id": "chat1",
|
||||
"agent": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "files",
|
||||
"more": false,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "file",
|
||||
"title": "bread.md",
|
||||
"snippet": "/home/frodo/bread.md — Lembas recipe",
|
||||
"marks": [
|
||||
[
|
||||
23,
|
||||
29
|
||||
]
|
||||
],
|
||||
"archived": false,
|
||||
"path": "/home/frodo/bread.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "memories",
|
||||
"more": false,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "memory",
|
||||
"id": "mem1",
|
||||
"title": "",
|
||||
"snippet": "Prefers lembas warm.",
|
||||
"marks": [
|
||||
[
|
||||
8,
|
||||
14
|
||||
]
|
||||
],
|
||||
"archived": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -100,8 +100,12 @@
|
||||
return ""
|
||||
case "terminal_word":
|
||||
return "lembas"
|
||||
default:
|
||||
return null
|
||||
default: {
|
||||
// A command the test answers itself: `commands: { name: value }` (an `__error` rejects).
|
||||
const own = (config.commands || {})[cmd]
|
||||
if (own && own.__error) return Promise.reject(own.__error)
|
||||
return own === undefined ? null : JSON.parse(JSON.stringify(own))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -529,6 +529,25 @@ test("B3: an open agent chat opens its own shell from the header; Send to chat f
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("B3: a shell that ends while its panel is being shown again is not replaced by a new one", async () => {
|
||||
const p = await page(b3())
|
||||
await openChatRow(p, "chat2")
|
||||
await p.click('#chat-tools [data-terminal-toggle]')
|
||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "Connected.")
|
||||
await p.click("#terminal-close")
|
||||
// Shown again, and the shell ends before the panel has drawn its first frame.
|
||||
await p.evaluate(() => {
|
||||
document.querySelector("#chat-tools [data-terminal-toggle]").click()
|
||||
window.__mock.shell("control", { t: "closed", message: "The shell exited." })
|
||||
})
|
||||
await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))
|
||||
await p.waitForTimeout(200)
|
||||
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "no new shell in its place")
|
||||
assert.equal(await p.textContent("#terminal-message"), "The shell exited.")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("B3: the terminal and the activity panel take turns in the panel column", async () => {
|
||||
const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
|
||||
await openChatRow(p, "chat2")
|
||||
@@ -822,6 +841,886 @@ test("C4: the tree at 390 px stays inside the window", async () => {
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// --- D: parity (beta.9) ------------------------------------------------------------------------
|
||||
|
||||
const shot = (p, name) => (process.env.SHOTS ? p.screenshot({ path: join(process.env.SHOTS, `${name}.png`) }) : null)
|
||||
const noSideways = (p, width) => p.evaluate((w) => document.documentElement.scrollWidth <= w, width)
|
||||
|
||||
function archivedRoutes(over = {}) {
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /chats?archived=true&limit=50": {
|
||||
chats: [
|
||||
chatRow({ id: "old1", title: "Lembas for the road, a recipe with a title long enough to be cut short", archived: true }),
|
||||
agentChat({ id: "old2", title: "Old refactor", archived: true, running: false }),
|
||||
],
|
||||
more: false,
|
||||
},
|
||||
"PATCH /chats/old1": { chat: chatRow({ id: "old1", title: "Lembas for the road", archived: false }) },
|
||||
"GET /chats/old2": { chat: agentChat({ id: "old2", title: "Old refactor", archived: true, running: false }), messages: [userMessage("m9", "Tidy it")], more: false, live: null },
|
||||
"PATCH /chats/old2": { chat: agentChat({ id: "old2", title: "Old refactor", archived: false, running: false }) },
|
||||
...over,
|
||||
}),
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
test("D2: Archived lists the archived chats; Restore chat takes one back and the sidebar is read again", async () => {
|
||||
const p = await page(archivedRoutes())
|
||||
await p.click("#nav-archived")
|
||||
await p.waitForSelector("#page-archived:not([hidden]) .arow")
|
||||
assert.equal(await p.locator("#page-archived .arow").count(), 2)
|
||||
assert.match(await p.textContent('.arow[data-id="old2"]'), /project/, "an agent chat names its folder")
|
||||
await shot(p, "d2-archived-1280")
|
||||
const before = await p.evaluate(() => window.__mock.api("GET", "/chats?limit").length)
|
||||
await p.click('.arow[data-id="old1"] .arow__unarchive')
|
||||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/old1").length === 1)
|
||||
const [call] = await p.evaluate(() => window.__mock.api("PATCH", "/chats/old1"))
|
||||
assert.deepEqual(call.args.body, { archived: false })
|
||||
await p.waitForFunction(() => !document.querySelector('.arow[data-id="old1"]'))
|
||||
await p.waitForFunction((n) => window.__mock.api("GET", "/chats?limit").length > n, before)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("D2: an archived chat opens from the page and says so in its header; Restore chat there too", async () => {
|
||||
const p = await page(archivedRoutes())
|
||||
await p.click("#nav-archived")
|
||||
await p.waitForSelector('.arow[data-id="old2"] .arow__open')
|
||||
await p.click('.arow[data-id="old2"] .arow__open')
|
||||
await p.waitForSelector("#main:not([hidden]) #messages .msg")
|
||||
await p.click("#chat-tools .head__unarchive")
|
||||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/old2").length === 1)
|
||||
await p.waitForSelector("#chat-tools .head__unarchive", { state: "detached" })
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("D2: at 390 px the archived page stays inside the window, its buttons reachable", async () => {
|
||||
const p = await page(archivedRoutes(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("nav-archived").click())
|
||||
await p.waitForSelector("#page-archived:not([hidden]) .arow")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
const b = await p.locator('.arow[data-id="old1"] .arow__unarchive').boundingBox()
|
||||
assert.ok(b && b.x + b.width <= 390, `Unarchive is on the screen: ${JSON.stringify(b)}`)
|
||||
await shot(p, "d2-archived-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// D4: a new agent chat on another linked computer. `GET /devices` lists this one (its host is the
|
||||
// app's own) and another; folders on the other come from its agent chats in the sidebar, or typed.
|
||||
|
||||
const STUDIO = { id: "token9", name: "studio", host: "studio", platform: "linux", terminal: true }
|
||||
|
||||
function d4(over = {}) {
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }, STUDIO] },
|
||||
"GET /chats": { chats: [chatRow(), agentChat(), agentChat({ id: "chat7", title: "Site tweaks", running: false, device: { token_id: "token9", directory: "/srv/site" } })], more: false },
|
||||
"POST /chats": { chat: agentChat({ id: "chat8", running: false, device: { token_id: "token9", directory: "/srv/site" } }), message: userMessage("m9", "Check the logs"), reply: null },
|
||||
...over,
|
||||
}),
|
||||
streams: {},
|
||||
terminal: [READY, ["output", "dev@studio:/srv/site$ "]],
|
||||
}
|
||||
}
|
||||
|
||||
async function pickDevice(p, name) {
|
||||
await p.click("#composer-chips .cchip--device")
|
||||
await p.locator(".menu .menu__item", { hasText: name }).first().click()
|
||||
}
|
||||
|
||||
test("D4: a new agent chat defaults to this computer and can be sent to another linked one, in a folder it knows", async () => {
|
||||
const p = await page(d4())
|
||||
await p.click("#new-chat-btn")
|
||||
await p.click(".kind__tab >> text=Agent")
|
||||
await p.waitForSelector("#composer-chips .cchip--device")
|
||||
assert.match(await p.textContent("#composer-chips .cchip--device"), /This computer/)
|
||||
await pickDevice(p, "studio")
|
||||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
||||
assert.match(await p.textContent("#new-chat .hello__sub"), /studio/, "the greeting names the computer")
|
||||
assert.match(await p.textContent("#composer-chips"), /site/, "the folder its agent chats work in")
|
||||
assert.ok(await p.isHidden("#link-notice"), "this computer's agent is not in the way")
|
||||
await shot(p, "d4-device-1280")
|
||||
// The Terminal opens on that computer, in that folder, before the first message.
|
||||
await p.click("#composer-chips .cchip--terminal")
|
||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||||
assert.deepEqual(shell.target, { chat: null, device: "token9", directory: "/srv/site" })
|
||||
await p.fill("#composer-text", "Check the logs")
|
||||
await p.click("#send")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
|
||||
const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats"))
|
||||
assert.equal(made.args.body.kind, "agent")
|
||||
assert.equal(made.args.body.device, "token9")
|
||||
assert.equal(made.args.body.directory, "/srv/site")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("D4: on a computer with no known folder the path is typed; back to this computer gives its own folders", async () => {
|
||||
const p = await page(d4({ "GET /chats": { chats: [chatRow(), agentChat()], more: false } }))
|
||||
await p.click("#new-chat-btn")
|
||||
await p.click(".kind__tab >> text=Agent")
|
||||
await pickDevice(p, "studio")
|
||||
await p.waitForSelector("#composer-chips .cchip.is-warn")
|
||||
await p.fill("#composer-text", "Hello")
|
||||
await p.click("#send")
|
||||
await p.waitForSelector("#thread-note:not([hidden])")
|
||||
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats").length), 0, "nothing sent without a folder")
|
||||
await p.click("#composer-chips .cchip.is-warn")
|
||||
await p.waitForSelector("dialog[open] .dialog__input")
|
||||
await p.fill("dialog[open] .dialog__input", "/home/dev/other")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForFunction(() => /other/.test(document.querySelector("#composer-chips").textContent))
|
||||
await pickDevice(p, "This computer")
|
||||
await p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent))
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("D4: with no other computer linked there is no choice to make; at 390 px the chips stay inside", async () => {
|
||||
let p = await page(b3())
|
||||
await p.click("#new-chat-btn")
|
||||
await p.click(".kind__tab >> text=Agent")
|
||||
await p.waitForSelector("#composer-chips .cchip")
|
||||
assert.equal(await p.locator("#composer-chips .cchip--device").count(), 0)
|
||||
await p.context().close()
|
||||
p = await page(d4(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("new-chat-btn").click())
|
||||
await p.click(".kind__tab >> text=Agent")
|
||||
await pickDevice(p, "studio")
|
||||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "d4-device-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// E5: the language and the theme. The server's /me (fixture me.json) offers its themes and takes
|
||||
// one on PATCH /me; its language is the app's unless Settings chose this computer's or another.
|
||||
|
||||
const ME = fixture("me")
|
||||
const meAs = (over = {}) => ({ ...ME, account: { id: "u1", name: "Ada Example", email: "ada@example.org" }, permissions: {}, ...over })
|
||||
|
||||
async function openSettings(p) {
|
||||
await p.click("#settings-btn")
|
||||
await p.waitForSelector("#set-look")
|
||||
}
|
||||
|
||||
test("E5: the theme is the account's, as /me offers them; chosen, PATCH /me saves it and it is drawn at once", async () => {
|
||||
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": meAs({ language: "en", theme: "moria" }) }), streams: {} })
|
||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||
await openSettings(p)
|
||||
assert.equal(await p.inputValue("#set-theme"), "shire")
|
||||
assert.deepEqual(await p.$$eval("#set-theme option", (o) => o.map((x) => x.textContent)), ["Moria — dark", "Shire — light"])
|
||||
await p.selectOption("#set-theme", "moria")
|
||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
|
||||
const [saved] = await p.evaluate(() => window.__mock.api("PATCH", "/me"))
|
||||
assert.deepEqual(saved.args.body, { theme: "moria" })
|
||||
const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name))
|
||||
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
|
||||
assert.equal(await p.inputValue("#set-theme"), "moria")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("E5: a theme the server refuses is said, and the look stays", async () => {
|
||||
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": { __error: { status: 400, detail: "That theme is not offered." } } }), streams: {} })
|
||||
await openSettings(p)
|
||||
await p.selectOption("#set-theme", "moria")
|
||||
await p.waitForSelector("#set-look [role=alert]")
|
||||
assert.match(await p.textContent("#set-look [role=alert]"), /not offered/)
|
||||
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire")
|
||||
assert.equal(await p.inputValue("#set-theme"), "shire")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("E5: a server that cannot be told: the theme chosen is kept in the app, over the account's", async () => {
|
||||
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} })
|
||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||
await openSettings(p)
|
||||
assert.equal(await p.inputValue("#set-theme"), "")
|
||||
assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/)
|
||||
await p.selectOption("#set-theme", "moria")
|
||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
|
||||
assert.equal(await p.evaluate(() => window.__mock.api("PATCH", "/me").length), 0, "nothing sent")
|
||||
await p.reload()
|
||||
await p.waitForSelector("#shell:not([hidden])")
|
||||
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme"))
|
||||
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
|
||||
await openSettings(p)
|
||||
await p.selectOption("#set-theme", "")
|
||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("E5: the account's language is the app's; this computer's or another, chosen, opens the window again in it, the shells let go first", async () => {
|
||||
const p = await page({ ...b3(), routes: { ...b3().routes, "GET /me": meAs({ language: "de", theme: "moria" }) } })
|
||||
// The account says German: the window opens again in it, once.
|
||||
await p.waitForFunction(() => document.documentElement.lang === "de", null, { timeout: 15000 })
|
||||
await p.waitForSelector("#shell:not([hidden])")
|
||||
assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen")
|
||||
await openChatRow(p, "chat2")
|
||||
await p.click('#chat-tools [data-terminal-toggle]')
|
||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||
// What the page let go of before it loaded again, kept across the load.
|
||||
await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd)))))
|
||||
await openSettings(p)
|
||||
assert.equal(await p.inputValue("#set-language"), "")
|
||||
assert.match(await p.textContent("#set-language option[value='']"), /Deutsch/)
|
||||
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "system")])
|
||||
await p.waitForSelector("#shell:not([hidden])")
|
||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
|
||||
const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]"))
|
||||
assert.ok(before.includes("terminal_close"), "the shell was let go")
|
||||
await p.waitForTimeout(300)
|
||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "en", "the account's German does not take it back")
|
||||
await openSettings(p)
|
||||
assert.equal(await p.inputValue("#set-language"), "system")
|
||||
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "fr")])
|
||||
await p.waitForSelector("#shell:not([hidden])")
|
||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "fr")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("E5: at 390 px the Appearance group stays inside the window", async () => {
|
||||
const p = await page({ status: status(), routes: baseRoutes(), streams: {} }, { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("settings-btn").click())
|
||||
await p.waitForSelector("#set-look")
|
||||
await p.evaluate(() => document.getElementById("set-look").scrollIntoView())
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
const box = await p.locator("#set-theme").boundingBox()
|
||||
assert.ok(box.x >= 0 && box.x + box.width <= 390, "the theme's choice is inside")
|
||||
await shot(p, "e5-look-390")
|
||||
await p.setViewportSize({ width: 1280, height: 860 })
|
||||
await shot(p, "e5-look-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// F8: pinned chats and chat folders (fixture folders.json: Waybread, and Recipes inside it, folded).
|
||||
|
||||
function f8(over = {}) {
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /folders": fixture("folders"),
|
||||
"GET /chats": {
|
||||
chats: [
|
||||
chatRow({ id: "chat1", title: "Waybread", pinned: true }),
|
||||
agentChat({ id: "chat2", title: "Tidy the build", folder_id: "fold1" }),
|
||||
chatRow({ id: "chat3", title: "Seed cake", folder_id: "fold2" }),
|
||||
chatRow({ id: "chat4", title: "Loose ends" }),
|
||||
],
|
||||
more: false,
|
||||
},
|
||||
"PATCH /folders/fold2": { folder: { ...fixture("folders").folders[1], collapsed: false } },
|
||||
"PATCH /chats/chat4": { chat: chatRow({ id: "chat4", title: "Loose ends", pinned: true }) },
|
||||
"POST /folders": fixture("folder"),
|
||||
"DELETE /folders/fold1": null,
|
||||
...over,
|
||||
}),
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
const sideText = (p) => p.$$eval("#chat-list > li", (lis) => lis.map((li) => li.textContent.replace(/\s+/g, " ").trim()))
|
||||
|
||||
test("F8: the sidebar: pinned chats first, then the folders, nested and folded as kept, then the rest", async () => {
|
||||
const p = await page(f8())
|
||||
await p.waitForSelector("#chat-list .folderrow")
|
||||
const items = await sideText(p)
|
||||
assert.equal(items[0], "Pinned chats")
|
||||
assert.match(items[1], /Waybread/)
|
||||
assert.match(items[2], /Waybread\s*2/, "the folder counts the chats inside, its folders' too")
|
||||
assert.match(items[3], /Tidy the build/)
|
||||
assert.match(items[4], /Recipes/)
|
||||
assert.ok(!items.some((x) => /Seed cake/.test(x)), "a folded folder's chats are not shown")
|
||||
assert.ok(items.some((x) => /Loose ends/.test(x)))
|
||||
assert.equal(await p.getAttribute('.folderrow[data-folder="fold2"]', "aria-expanded"), "false")
|
||||
await p.click('.folderrow[data-folder="fold2"]')
|
||||
await p.waitForSelector('.chatrow[data-id="chat3"]')
|
||||
const [folded] = await p.evaluate(() => window.__mock.api("PATCH", "/folders/fold2"))
|
||||
assert.deepEqual(folded.args.body, { collapsed: false })
|
||||
await shot(p, "f8-folders-1280")
|
||||
// Searching: every match, flat.
|
||||
await p.fill("#side-search", "seed")
|
||||
assert.deepEqual((await sideText(p)).slice(1), ["Seed cake"])
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("F8: a chat's menu pins it and moves it into a folder, or a new one", async () => {
|
||||
const p = await page(f8())
|
||||
await p.waitForSelector('.chatrow[data-id="chat4"]')
|
||||
await p.click('.chatrow[data-id="chat4"]', { button: "right" })
|
||||
await p.click(".menu .menu__item >> text=Pin chat")
|
||||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 1)
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[0].args.body, { pinned: true })
|
||||
await p.click('.chatrow[data-id="chat4"]', { button: "right" })
|
||||
await p.click(".menu .menu__item >> text=Move to folder…")
|
||||
const labels = await p.$$eval(".menu .menu__item .menu__label", (l) => l.map((x) => x.textContent.trim()))
|
||||
assert.deepEqual(labels, ["No folder", "Waybread", "Recipes", "New folder…"])
|
||||
await p.click(".menu .menu__item >> text=Recipes")
|
||||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 2)
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[1].args.body, { folder_id: "fold2" })
|
||||
// A new one: named, made, and the chat moved into it.
|
||||
await p.click('.chatrow[data-id="chat4"]', { button: "right" })
|
||||
await p.click(".menu .menu__item >> text=Move to folder…")
|
||||
await p.click(".menu .menu__item >> text=New folder…")
|
||||
await p.fill("dialog[open] .dialog__input", "Lembas")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 3)
|
||||
const made = await p.evaluate(() => window.__mock.api("POST", "/folders"))
|
||||
assert.deepEqual(made[0].args.body, { name: "Lembas" })
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[2].args.body, { folder_id: "fold1" })
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("F8: a folder's menu renames it and deletes it, its chats kept; a refusal is said", async () => {
|
||||
const p = await page(f8({ "PATCH /folders/fold1": { __error: { status: 403, detail: "You may not manage folders." } } }))
|
||||
await p.waitForSelector('.folderrow[data-folder="fold1"]')
|
||||
await p.click('.folderrow[data-folder="fold1"]', { button: "right" })
|
||||
await p.click(".menu .menu__item >> text=Rename folder")
|
||||
await p.fill("dialog[open] .dialog__input", "Bread")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForSelector("#thread-note:not([hidden])")
|
||||
assert.match(await p.textContent("#thread-note"), /may not manage folders/)
|
||||
await p.click('.list__folder:has([data-folder="fold1"]) .folderrow__more')
|
||||
await p.click(".menu .menu__item >> text=Delete folder")
|
||||
assert.match(await p.textContent("dialog[open]"), /Their chats stay/)
|
||||
await p.click("dialog[open] .btn--danger")
|
||||
await p.waitForFunction(() => window.__mock.api("DELETE", "/folders/fold1").length === 1)
|
||||
assert.equal(await p.evaluate(() => window.__mock.api("DELETE", "/chats").length), 0, "no chat deleted")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("F8: at 390 px the folders stay inside the sidebar", async () => {
|
||||
const p = await page(f8(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("side-toggle").click())
|
||||
await p.waitForSelector('.folderrow[data-folder="fold1"]')
|
||||
await p.waitForTimeout(400) // the sidebar slides in
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "f8-folders-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// G8: search inside (GET /search, fixture search.json) and the memory page (memories.json).
|
||||
|
||||
function g8(over = {}) {
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /search": fixture("search"),
|
||||
"GET /memories": fixture("memories"),
|
||||
"POST /memories": { memory: { ...fixture("memory").memory, id: "mem2", content: "Walks to Bree on Sundays." } },
|
||||
"PATCH /memories/mem1": { memory: { ...fixture("memory").memory, content: "Prefers lembas cold." } },
|
||||
"DELETE /memories/mem1": null,
|
||||
"GET /files/content": { name: "bread.md", path: "/home/frodo/bread.md", text: "# Lembas recipe", kind: "markdown" },
|
||||
...over,
|
||||
}),
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
test("G8: the palette finds words inside chats, files and memory through the server, the matches marked as text", async () => {
|
||||
const p = await page(g8())
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "lembas")
|
||||
await p.waitForSelector(".palette__snippet")
|
||||
const [q] = await p.evaluate(() => window.__mock.api("GET", "/search"))
|
||||
assert.match(q.args.path, /^\/search\?q=lembas&in=all&limit=8$/)
|
||||
const hits = await p.$$eval(".palette__item:has(.palette__snippet)", (l) => l.map((x) => ({ title: x.querySelector(".palette__title").textContent, mark: x.querySelector("mark")?.textContent, hint: x.querySelector(".palette__hint").textContent })))
|
||||
assert.deepEqual(hits.map((x) => x.hint), ["Chats", "Files", "Memory"])
|
||||
assert.equal(hits[0].title, "Waybread")
|
||||
assert.equal(hits[0].mark, "lembas")
|
||||
assert.equal(hits[2].mark, "lembas")
|
||||
await shot(p, "g8-search-1280")
|
||||
// A memory opens the memory page at it.
|
||||
await p.click(".palette__item:has(.palette__hint:text('Memory'))")
|
||||
await p.waitForSelector('#page-memory:not([hidden]) .mrow.is-marked[data-id="mem1"]')
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("G8: a chat found inside opens; a server without search finds nothing more, and says nothing", async () => {
|
||||
let p = await page(g8())
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "lembas")
|
||||
await p.waitForSelector(".palette__snippet")
|
||||
await p.click(".palette__item:has(.palette__hint:text('Chats'))")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/chats/chat1").length > 0)
|
||||
await p.context().close()
|
||||
p = await page(g8({ "GET /search": { __error: { status: 404, detail: "Not Found" } } }))
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "lembas")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/search").length === 1)
|
||||
await p.waitForTimeout(100)
|
||||
assert.equal(await p.locator(".palette__snippet").count(), 0)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("G8: the memory page adds, changes and removes what the models remember", async () => {
|
||||
const p = await page(g8())
|
||||
await p.click("#nav-memory")
|
||||
await p.waitForSelector('.mrow[data-id="mem1"]')
|
||||
assert.match(await p.textContent('.mrow[data-id="mem1"]'), /Prefers lembas warm\./)
|
||||
await p.fill("#memory-new", "Walks to Bree on Sundays.")
|
||||
await p.click(".mnew button[type=submit]")
|
||||
await p.waitForSelector('.mrow[data-id="mem2"]')
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/memories")))[0].args.body, { content: "Walks to Bree on Sundays." })
|
||||
assert.equal(await p.inputValue("#memory-new"), "")
|
||||
await p.click('.mrow[data-id="mem1"] .btn >> text=Edit')
|
||||
await p.fill(".mrow.is-editing textarea", "Prefers lembas cold.")
|
||||
await p.click(".mrow.is-editing .btn--primary")
|
||||
await p.waitForFunction(() => /cold/.test(document.querySelector('.mrow[data-id="mem1"]').textContent))
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/memories/mem1")))[0].args.body, { content: "Prefers lembas cold." })
|
||||
await p.click('.mrow[data-id="mem1"] .tool')
|
||||
await p.click(".menu .menu__item >> text=Remove")
|
||||
await p.click("dialog[open] .btn--danger")
|
||||
await p.waitForFunction(() => !document.querySelector('.mrow[data-id="mem1"]'))
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("G8: at 390 px the memory page and the palette's hits stay inside the window", async () => {
|
||||
const p = await page(g8(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("nav-memory").click())
|
||||
await p.waitForSelector('.mrow[data-id="mem1"]')
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "g8-memory-390")
|
||||
await p.setViewportSize({ width: 1280, height: 860 })
|
||||
await shot(p, "g8-memory-1280")
|
||||
await p.setViewportSize({ width: 390, height: 844 })
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "lembas")
|
||||
await p.waitForSelector(".palette__snippet")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "g8-search-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// H8: the library's writes (files_listing.json) and a chat's tools and skills (scope.json).
|
||||
|
||||
function h8(over = {}, commands = {}) {
|
||||
const listing = fixture("files_listing")
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /files": listing,
|
||||
"GET /files?path=%2Fhome%2Ffrodo%2Frecipes": { path: "/home/frodo/recipes", parent: "/home/frodo", entries: [] },
|
||||
"POST /files/mkdir": listing,
|
||||
"POST /files/rename": listing,
|
||||
"DELETE /files": null,
|
||||
"GET /chats/chat1/scope": fixture("scope"),
|
||||
"POST /chats/chat1/scope": { scope: { ...fixture("scope").scope, families: [{ gate: "notes", label: "Notes", on: false }, { gate: "web", label: "Web search", on: false }] } },
|
||||
"POST /chats/chat1/allow/clear": { scope: { ...fixture("scope").scope, allow: [] } },
|
||||
...over,
|
||||
}),
|
||||
commands: { library_upload: { listing, sent: 1, failed: [] }, ...commands },
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
async function serverPane(p) {
|
||||
await p.click("#nav-files")
|
||||
await p.click('#files-tabs [data-tab="server"]')
|
||||
await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]')
|
||||
}
|
||||
|
||||
test("H8: the server's files: upload, a new folder, rename and delete, each read again from the server", async () => {
|
||||
const p = await page(h8())
|
||||
await serverPane(p)
|
||||
// Upload into the home the server answered with, through Rust's dialog.
|
||||
await p.click("#server-upload")
|
||||
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "library_upload"))
|
||||
assert.equal((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "library_upload").args.dir)), "/home/frodo")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/files?path=%2F").length === 2)
|
||||
// A new folder inside a folder, from its row's menu.
|
||||
await p.click('#server-tree li[data-path="/home/frodo/recipes"] > .tree__more')
|
||||
await p.click(".menu .menu__item >> text=New folder")
|
||||
await p.fill("dialog[open] .dialog__input", "Breads")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/files/mkdir").length === 1)
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/mkdir")))[0].args.body, { path: "/home/frodo/recipes", name: "Breads" })
|
||||
// Rename a file.
|
||||
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
|
||||
await p.click(".menu .menu__item >> text=Rename")
|
||||
await p.fill("dialog[open] .dialog__input", "waybread.md")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/files/rename").length === 1)
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/rename")))[0].args.body, { path: "/home/frodo/lembas.md", name: "waybread.md" })
|
||||
// Delete: to the trash, after asking.
|
||||
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
|
||||
await p.click(".menu .menu__item >> text=Delete")
|
||||
await p.click("dialog[open] .btn--danger")
|
||||
await p.waitForFunction(() => window.__mock.api("DELETE", "/files?path=%2Fhome%2Ffrodo%2Flembas.md").length === 1)
|
||||
await p.waitForSelector("#server-status:not([hidden])")
|
||||
assert.equal(await p.textContent("#server-status"), "Moved to the trash.")
|
||||
await shot(p, "h8-files-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("H8: a refusal or a file the server did not take is said in the pane", async () => {
|
||||
const p = await page(h8({ "POST /files/rename": { __error: { status: 400, detail: "That did not work: read-only" } } }, { library_upload: { listing: null, sent: 0, failed: [{ name: "big.iso", error: "This file is too large to send." }] } }))
|
||||
await serverPane(p)
|
||||
await p.click("#server-upload")
|
||||
await p.waitForSelector("#server-status.is-error")
|
||||
assert.equal(await p.textContent("#server-status"), "big.iso: This file is too large to send.")
|
||||
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
|
||||
await p.click(".menu .menu__item >> text=Rename")
|
||||
await p.fill("dialog[open] .dialog__input", "x.md")
|
||||
await p.click("dialog[open] .btn--primary")
|
||||
await p.waitForFunction(() => document.getElementById("server-status").textContent.includes("read-only"))
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("H8: a chat's tools and skills switch here only; the allowed commands can be forgotten", async () => {
|
||||
const p = await page(h8())
|
||||
await p.waitForSelector('.chatrow[data-id="chat1"]')
|
||||
await p.click('.chatrow[data-id="chat1"]', { button: "right" })
|
||||
await p.click(".menu .menu__item >> text=Tools and skills…")
|
||||
await p.waitForSelector('.scope [data-key="family:notes"]')
|
||||
const rows = await p.$$eval(".scope .scope__row", (l) => l.map((x) => [x.textContent, x.querySelector("input").checked]))
|
||||
assert.deepEqual(rows, [["Notes", true], ["Web search", false], ["baking", true]])
|
||||
assert.equal(await p.getAttribute('.scope .scope__row:has([data-key="skill:baking"])', "title"), "Bake waybread.")
|
||||
await p.click('.scope [data-key="family:notes"]')
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat1/scope").length === 1)
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/chats/chat1/scope")))[0].args.body, { kind: "family", name: "notes", on: false })
|
||||
await p.waitForFunction(() => document.querySelector('.scope [data-key="family:notes"]') && !document.querySelector('.scope [data-key="family:notes"]').checked && !document.querySelector('.scope [data-key="family:notes"]').disabled)
|
||||
assert.match(await p.textContent(".scope__allow"), /bash:ls \*/)
|
||||
await shot(p, "h8-scope-1280")
|
||||
await p.click(".scope .btn >> text=Ask me about these again")
|
||||
await p.waitForFunction(() => !document.querySelector(".scope__allow"))
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("H8: at 390 px the server's pane and the switches stay inside the window", async () => {
|
||||
const p = await page(h8(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.getElementById("nav-files").click())
|
||||
await p.click('#files-tabs [data-tab="server"]')
|
||||
await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]')
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "h8-files-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// I8: voice (/v1/audio, through Rust) and drawing a picture (/v1/images, through Rust).
|
||||
|
||||
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
function i8({ stt = true, tts = true, images = true, scopes = ["app", "models"] } = {}, commands = {}) {
|
||||
const reply = { id: "m6", role: "assistant", created_at: STAMP, complete: true, stopped: false, error: null, model: "qwen", metrics: null, steps: [{ kind: "text", markdown: "Lembas is **baked** in leaves.\n\n```\nsecret\n```" }] }
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {}, services: { stt, tts, images, search: false, fetch: false }, scopes },
|
||||
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?"), reply], more: false, live: null },
|
||||
}),
|
||||
commands: { transcribe: "baked in leaves", speak: "data:audio/mpeg;base64,SUQz", draw: [PNG, "https://elsewhere.example/x.png"], ...commands },
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
// A microphone and a recorder that record two bytes: the page's own, the browser's untouched.
|
||||
async function fakeMicrophone(p) {
|
||||
await p.evaluate(() => {
|
||||
const track = { stop() {} }
|
||||
// The test's page is not a secure context, so it has no mediaDevices of its own.
|
||||
if (!navigator.mediaDevices) Object.defineProperty(navigator, "mediaDevices", { value: {}, configurable: true })
|
||||
navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [track] })
|
||||
window.MediaRecorder = class extends EventTarget {
|
||||
static isTypeSupported(x) {
|
||||
return x === "audio/webm;codecs=opus"
|
||||
}
|
||||
constructor(stream, options) {
|
||||
super()
|
||||
this.mimeType = options?.mimeType || ""
|
||||
this.state = "inactive"
|
||||
}
|
||||
start() {
|
||||
this.state = "recording"
|
||||
}
|
||||
stop() {
|
||||
this.state = "inactive"
|
||||
const data = new Event("dataavailable")
|
||||
data.data = new Blob([new Uint8Array([1, 2])], { type: this.mimeType })
|
||||
this.dispatchEvent(data)
|
||||
this.dispatchEvent(new Event("stop"))
|
||||
}
|
||||
}
|
||||
HTMLMediaElement.prototype.play = async function () {}
|
||||
})
|
||||
}
|
||||
|
||||
test("I8: Dictate records, asks Rust for the microphone first, and puts the words where the cursor is", async () => {
|
||||
const p = await page(i8())
|
||||
await p.waitForSelector("#dictate:not([hidden])")
|
||||
await fakeMicrophone(p)
|
||||
await p.fill("#composer-text", "Lembas is")
|
||||
await p.click("#dictate")
|
||||
await p.waitForSelector('#dictate.is-recording[aria-pressed="true"]')
|
||||
const order = await p.evaluate(() => window.__mock.calls.map((c) => c.cmd).filter((c) => c === "voice_arm"))
|
||||
assert.deepEqual(order, ["voice_arm"])
|
||||
await shot(p, "i8-dictate-1280")
|
||||
await p.click("#dictate")
|
||||
await p.waitForFunction(() => document.getElementById("composer-text").value === "Lembas is baked in leaves")
|
||||
assert.equal(await p.getAttribute("#dictate", "aria-pressed"), "false")
|
||||
assert.ok(await p.evaluate(() => window.__mock.calls.some((c) => c.cmd === "transcribe")))
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("I8: no Dictate, Read aloud or Draw where the server has none of them, or the token lacks models", async () => {
|
||||
let p = await page(i8({ stt: false, tts: false, images: false }))
|
||||
await p.click('.chatrow[data-id="chat1"]')
|
||||
await p.waitForSelector('.msg--assistant[data-id="m6"]')
|
||||
assert.equal(await p.isHidden("#dictate"), true)
|
||||
assert.equal(await p.locator(".msg__speak").count(), 0)
|
||||
await p.keyboard.press("Control+k")
|
||||
assert.equal(await p.locator(".palette__item >> text=Draw a picture").count(), 0)
|
||||
await p.context().close()
|
||||
p = await page(i8({ scopes: ["app"] }))
|
||||
await p.waitForSelector("#composer")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
|
||||
assert.equal(await p.isHidden("#dictate"), true)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("I8: Read aloud speaks a reply's words without its markdown or code, and stops when pressed again", async () => {
|
||||
const p = await page(i8())
|
||||
await fakeMicrophone(p)
|
||||
await p.click('.chatrow[data-id="chat1"]')
|
||||
await p.waitForSelector('.msg--assistant[data-id="m6"] .msg__speak')
|
||||
await p.click('.msg--assistant[data-id="m6"] .msg__speak')
|
||||
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "speak"))
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "speak").args)), { text: "Lembas is baked in leaves." })
|
||||
await p.waitForSelector('.msg__speak[aria-pressed="true"]:not(.is-busy)')
|
||||
await p.click('.msg--assistant[data-id="m6"] .msg__speak')
|
||||
await p.waitForSelector('.msg__speak[aria-pressed="false"]')
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("I8: Draw a picture shows only the server's own pictures, as Rust read them", async () => {
|
||||
const p = await page(i8())
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "draw")
|
||||
await p.click(".palette__item >> text=Draw a picture")
|
||||
await p.fill(".draw__prompt", "A lembas loaf on a leaf")
|
||||
await p.selectOption(".draw__size", "1344x768")
|
||||
await p.click(".draw button[type=submit]")
|
||||
await p.waitForSelector(".draw__pic img")
|
||||
assert.deepEqual((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "draw").args)), { prompt: "A lembas loaf on a leaf", size: "1344x768" })
|
||||
const srcs = await p.$$eval(".draw__pic img", (l) => l.map((x) => x.getAttribute("src")))
|
||||
assert.deepEqual(srcs, [PNG])
|
||||
await shot(p, "i8-draw-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("I8: at 390 px the composer with Dictate and the drawing dialog stay inside the window", async () => {
|
||||
const p = await page(i8(), { width: 390, height: 844 })
|
||||
await p.waitForSelector("#dictate:not([hidden])")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "i8-composer-390")
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "draw")
|
||||
await p.click(".palette__item >> text=Draw a picture")
|
||||
await p.fill(".draw__prompt", "A lembas loaf")
|
||||
await p.click(".draw button[type=submit]")
|
||||
await p.waitForSelector(".draw__pic img")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "i8-draw-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// J8: a notification clicked (Rust: notification_action) opens the chat or the scheduled task.
|
||||
|
||||
test("J8: a scheduled run's notification opens its task, and a chat's opens the chat", async () => {
|
||||
const p = await page({
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
"GET /schedules": { schedules: fixture("event_schedules").schedules },
|
||||
"GET /schedules/sched1": fixture("schedule"),
|
||||
"GET /schedules/sched1/runs": { runs: [fixture("schedule_run").run || fixture("schedule_run")], more: false },
|
||||
"GET /schedules/models": { models: [], instance: [], mine: [] },
|
||||
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
|
||||
}),
|
||||
streams: {},
|
||||
})
|
||||
await p.waitForSelector("#composer")
|
||||
await p.evaluate(() => window.__mock.emit("open-schedule", { schedule: "sched1", run: "run1" }))
|
||||
await p.waitForSelector("#page-scheduled:not([hidden])")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/schedules/sched1").length >= 1)
|
||||
await p.waitForFunction(() => /Waybread check/.test(document.getElementById("scheduled-title").textContent))
|
||||
await p.evaluate(() => window.__mock.emit("open-chat", "chat1"))
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/chats/chat1").length === 1)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// K8: the release the server recommends (GET /apps), in Settings › About.
|
||||
|
||||
async function aboutWith(version, apps) {
|
||||
const p = await page({ status: status({ version }), routes: baseRoutes({ "GET /apps": apps }), streams: {} })
|
||||
await p.click("#settings-btn")
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/apps").length === 1)
|
||||
await p.waitForTimeout(100)
|
||||
return p
|
||||
}
|
||||
|
||||
test("K8: a newer release the server recommends is offered in About, with its release page", async () => {
|
||||
const p = await aboutWith("1.0.0", fixture("apps_stable"))
|
||||
await p.waitForSelector("#set-about >> text=Your server recommends LLeMbas Desktop 1.0.1")
|
||||
await p.click("#set-about .btn >> text=Open the release page")
|
||||
const opened = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "open_external").map((c) => c.args.url))
|
||||
assert.deepEqual(opened, ["https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"])
|
||||
await shot(p, "k8-about-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("K8: nothing is offered that is not newer, a beta to a stable install, or a page elsewhere", async () => {
|
||||
const beta = { apps: { desktop: { version: "1.1.0-beta.2", tag: "v1.1.0-beta.2", channel: "beta", page: "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.1.0-beta.2" } } }
|
||||
for (const [mine, apps, shown] of [
|
||||
["1.0.1", fixture("apps_stable"), false],
|
||||
["1.0.0", beta, false],
|
||||
["1.0.0-beta.6", beta, true],
|
||||
["1.0.0", { apps: { desktop: { version: "1.0.2", page: "https://elsewhere.example/releases/tag/v1.0.2" } } }, false],
|
||||
["1.0.0", { __error: { status: 404, detail: "Not Found" } }, false],
|
||||
]) {
|
||||
const p = await aboutWith(mine, apps)
|
||||
assert.equal(await p.locator("#set-about >> text=Your server recommends").count(), shown ? 1 : 0, `${mine} ← ${JSON.stringify(apps).slice(0, 60)}`)
|
||||
assert.deepEqual(p.errors, [])
|
||||
await p.context().close()
|
||||
}
|
||||
})
|
||||
|
||||
// L8: where a scheduled task runs (runs_on, fixture schedule_on_device.json; /devices `scheduled`).
|
||||
|
||||
function l8(devices) {
|
||||
const on = fixture("schedule_on_device")
|
||||
const id = on.schedule.id
|
||||
return {
|
||||
status: status({ host: "workbench" }),
|
||||
routes: baseRoutes({
|
||||
"GET /devices": { devices },
|
||||
"GET /schedules": { schedules: [on.schedule] },
|
||||
[`GET /schedules/${id}`]: on,
|
||||
[`GET /schedules/${id}/runs`]: { runs: [on.schedule.last_run], more: false },
|
||||
"GET /schedules/models": { models: [], instance: [], mine: [] },
|
||||
"POST /schedules/compile": { ok: true, reason: "", title: "Build check", instruction: "Check the build.", target: "chat", rule: { at: { times: ["07:30"] } }, description: "Every day at 07:30", next_runs: [], timezone: "UTC" },
|
||||
"POST /schedules": { schedule: { ...on.schedule, id: "sched9" } },
|
||||
"GET /schedules/sched9": { schedule: { ...on.schedule, id: "sched9" } },
|
||||
"GET /schedules/sched9/runs": { runs: [], more: false },
|
||||
}),
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
const bagEnd = (over = {}) => ({ id: "tok1", name: "bag-end", host: "bag-end", platform: "linux", terminal: true, scheduled: true, max_mode: "", ...over })
|
||||
|
||||
async function openTask(p) {
|
||||
await p.waitForSelector("#composer")
|
||||
await p.evaluate(() => window.__mock.emit("open-schedule", { schedule: "sched3", run: "run5" }))
|
||||
await p.waitForSelector(".fact >> text=Where it runs")
|
||||
}
|
||||
|
||||
test("L8: a task on a linked computer says the computer, the folder and mode; a run waits for it", async () => {
|
||||
const p = await page(l8([bagEnd()]))
|
||||
await openTask(p)
|
||||
const where = await p.textContent(".fact:has-text('Where it runs')")
|
||||
assert.match(where, /bag-end/)
|
||||
assert.match(where, /\/home\/frodo\/waybread/)
|
||||
assert.match(where, /Edit — asks before commands · approvals wait 30 min/)
|
||||
assert.equal(await p.locator(".fact.is-warning").count(), 0)
|
||||
assert.match(await p.textContent(".run[data-id='run5']"), /Waiting for the computer/)
|
||||
assert.match(await p.textContent(".run[data-id='run5']"), /Waiting for bag-end to be linked\./)
|
||||
await shot(p, "l8-task-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("L8: a computer away, or not accepting scheduled work, is said on the task", async () => {
|
||||
for (const [devices, words] of [
|
||||
[[], /Away now/],
|
||||
[[bagEnd({ scheduled: false })], /does not accept scheduled work/],
|
||||
]) {
|
||||
const p = await page(l8(devices))
|
||||
await openTask(p)
|
||||
assert.match(await p.textContent(".fact.is-warning"), words)
|
||||
assert.deepEqual(p.errors, [])
|
||||
await p.context().close()
|
||||
}
|
||||
})
|
||||
|
||||
test("L8: a new task can run on this computer, in a folder and mode; one not accepting cannot be chosen", async () => {
|
||||
const p = await page(l8([bagEnd({ scheduled: false }), bagEnd({ id: "token1", name: "workbench", host: "workbench" })]))
|
||||
await p.waitForSelector("#composer")
|
||||
await p.evaluate(() => window.__mock.emit("open-schedule", { schedule: "sched3", run: "run5" }))
|
||||
await p.waitForSelector("#page-scheduled:not([hidden])")
|
||||
await p.keyboard.press("Control+k")
|
||||
await p.fill(".palette__input", "New scheduled")
|
||||
await p.keyboard.press("Enter")
|
||||
await p.fill("#sched-words", "every day at 7:30 check the build")
|
||||
await p.click(".sched--new button[type=submit]")
|
||||
await p.waitForSelector("#sched-where")
|
||||
const options = await p.$$eval("#sched-where option", (l) => l.map((o) => [o.textContent, o.disabled]))
|
||||
assert.deepEqual(options, [["On this server", false], ["bag-end — does not accept scheduled work", true], ["This computer (workbench)", false]])
|
||||
await p.selectOption("#sched-where", "token1")
|
||||
assert.equal(await p.inputValue("#sched-cwd"), "/home/dev/project")
|
||||
await p.selectOption("#sched-runmode", "auto")
|
||||
await p.fill("#sched-wait", "45")
|
||||
await shot(p, "l8-form-1280")
|
||||
await p.click("#sched-save")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/schedules").some((c) => c.args.path === "/schedules"))
|
||||
const body = (await p.evaluate(() => window.__mock.api("POST", "/schedules").find((c) => c.args.path === "/schedules"))).args.body
|
||||
assert.deepEqual(body.runs_on, { device: "token1", cwd: "/home/dev/project", mode: "auto", approval_wait_minutes: 45 })
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("L8: at 390 px the form with a computer chosen stays inside the window", async () => {
|
||||
const p = await page(l8([bagEnd()]), { width: 390, height: 844 })
|
||||
await p.waitForSelector("#composer")
|
||||
await p.evaluate(() => window.__mock.emit("open-schedule", { schedule: "sched3", run: "run5" }))
|
||||
await p.waitForSelector(".fact >> text=Where it runs")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "l8-task-390")
|
||||
await p.click("#sched-edit")
|
||||
await p.waitForSelector("#sched-cwd")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
await shot(p, "l8-form-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// M8: a picture as the reply (force_tool "image_generate" on POST /chats and /messages).
|
||||
|
||||
test("M8: Draw a picture asks for a picture with the next message only, in a new chat and an open one", async () => {
|
||||
const cfg = i8()
|
||||
Object.assign(cfg.routes, {
|
||||
"POST /chats": { chat: chatRow({ id: "chat7" }), message: userMessage("m1", "a lembas loaf"), reply: null },
|
||||
"POST /chats/chat7/messages": { message: userMessage("m2", "and another"), reply: null },
|
||||
})
|
||||
const p = await page(cfg)
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
|
||||
await p.click(".kind__tab >> text=Chat")
|
||||
await p.click(".cchip--picture")
|
||||
assert.equal(await p.getAttribute(".cchip--picture", "aria-pressed"), "true")
|
||||
await shot(p, "m8-picture-1280")
|
||||
await p.fill("#composer-text", "a lembas loaf")
|
||||
await p.keyboard.press("Enter")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
|
||||
assert.equal((await p.evaluate(() => window.__mock.api("POST", "/chats")))[0].args.body.force_tool, "image_generate")
|
||||
await p.waitForSelector('.cchip--picture[aria-pressed="false"]')
|
||||
await p.fill("#composer-text", "and another")
|
||||
await p.keyboard.press("Enter")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat7/messages").length === 1)
|
||||
assert.equal((await p.evaluate(() => window.__mock.api("POST", "/chats/chat7/messages")))[0].args.body.force_tool, undefined)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("M8: no picture switch where the server cannot draw, or in an agent chat", async () => {
|
||||
const p = await page(i8({ images: false }))
|
||||
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
|
||||
await p.click(".kind__tab >> text=Chat")
|
||||
assert.equal(await p.locator(".cchip--picture").count(), 0)
|
||||
await p.context().close()
|
||||
const q = await page(i8())
|
||||
await q.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
|
||||
await q.click(".kind__tab >> text=Agent")
|
||||
assert.equal(await q.locator(".cchip--picture").count(), 0)
|
||||
assert.deepEqual(q.errors, [])
|
||||
})
|
||||
|
||||
// --- Run ----------------------------------------------------------------------------------------
|
||||
|
||||
const only = process.argv[2] || ""
|
||||
@@ -835,6 +1734,8 @@ for (const { name, fn } of tests) {
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`)
|
||||
// On GitHub Actions, also as an annotation: readable without the job's log.
|
||||
if (process.env.GITHUB_ACTIONS) console.log(`::error title=tests/ui::${`${name}: ${String(err?.message || err)} (${String(err?.stack || "").match(/run\.mjs:\d+/)?.[0] || ""})`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").slice(0, 900)}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
+131
-2
@@ -431,6 +431,34 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
||||
.chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); }
|
||||
.chatrow__state.is-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; }
|
||||
.chatrow__state.is-new { background: var(--leaf); }
|
||||
/* A chat folder's head: chevron · folder · name · count, and its own ⋯ beside it on one track. */
|
||||
.list__folder { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
|
||||
.folderrow {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 16px minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-2) 0 calc(var(--sp-2) + var(--depth, 0) * var(--sp-4));
|
||||
text-align: left;
|
||||
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.folderrow:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
.folderrow__chev, .folderrow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||
.folderrow__chev .icon { width: 12px; height: 12px; }
|
||||
.folderrow__icon .icon { width: 14px; height: 14px; }
|
||||
.folderrow__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.folderrow__count { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.folderrow__more { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); color: var(--ink-faint); background: transparent; border: 0; border-radius: var(--radius-lg); cursor: pointer; opacity: 0; }
|
||||
.list__folder:hover .folderrow__more, .folderrow__more:focus-visible { opacity: 1; }
|
||||
.folderrow__more:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
@media (hover: none) { .folderrow__more { opacity: 1; } }
|
||||
.chatrow.is-filed { padding-left: calc(var(--sp-2) + var(--depth, 1) * var(--sp-4)); }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } }
|
||||
|
||||
@@ -1140,6 +1168,18 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dialog::backdrop { background: var(--scrim); }
|
||||
.dialog--wide { width: min(var(--w-prose), calc(100vw - 2 * var(--sp-4))); }
|
||||
|
||||
/* Draw a picture (draw.js): the prompt, a shape and two buttons on one line, the pictures below. */
|
||||
.draw .dialog__title { display: flex; gap: var(--sp-2); align-items: center; }
|
||||
.draw__prompt { width: 100%; }
|
||||
.draw__bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: var(--sp-2); align-items: center; }
|
||||
.draw__size { justify-self: start; max-width: 100%; }
|
||||
.draw__out { display: grid; gap: var(--sp-3); max-height: 60vh; overflow-y: auto; }
|
||||
.draw__out:empty { display: none; }
|
||||
.draw__pic { display: grid; gap: var(--sp-1); margin: 0; }
|
||||
.draw__pic img { display: block; width: 100%; height: auto; border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.draw__pic figcaption { font-size: var(--text-xs); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||
.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); }
|
||||
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
|
||||
@@ -1177,6 +1217,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.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; }
|
||||
/* A hit found inside: its title over the snippet, the words that matched marked. */
|
||||
.palette__label--two { display: grid; padding: var(--sp-1) 0; white-space: normal; }
|
||||
.palette__title, .palette__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.palette__snippet { font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
.palette__snippet mark { color: var(--ink); background: var(--surface-active); border-radius: var(--radius-sm); }
|
||||
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
|
||||
|
||||
@@ -1243,11 +1288,16 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.composer__files { justify-content: start; }
|
||||
.composer__bar {
|
||||
display: grid;
|
||||
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto auto; }
|
||||
/* Dictate: a round tool beside Send; while it records it says so in the accent. */
|
||||
.composer__mic { width: var(--round); height: var(--round); border-radius: var(--radius-full); }
|
||||
.composer .composer__mic.is-recording { color: var(--accent-ink); background: var(--accent); }
|
||||
.composer__mic.is-busy, .msg__speak.is-busy { opacity: 0.6; cursor: progress; }
|
||||
.msg__speak[aria-pressed="true"] { color: var(--accent); }
|
||||
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
|
||||
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
|
||||
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
|
||||
@@ -1368,6 +1418,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
|
||||
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
|
||||
.files__where { min-width: 0; }
|
||||
/* The server's pane: Upload and New folder before Read again. */
|
||||
#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); }
|
||||
#server-status { white-space: pre-line; }
|
||||
.files__where select { width: 100%; height: var(--control-h-sm); }
|
||||
.files__tree { min-height: 0; }
|
||||
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
|
||||
@@ -1401,6 +1454,12 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
|
||||
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
/* A row with a more button beside it: the button on the row's own line, its folder below both. */
|
||||
.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
|
||||
.tree__item > ul { grid-column: 1 / -1; }
|
||||
.tree__more { opacity: 0; }
|
||||
.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; }
|
||||
@media (hover: none) { .tree__more { opacity: 1; } }
|
||||
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
|
||||
/* An empty page: an icon, a heading, a sentence, centred. */
|
||||
@@ -1491,6 +1550,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.sched + .sched__history { margin-top: var(--sp-6); }
|
||||
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
|
||||
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
/* Where it runs: the computer over its folder; a computer that cannot take the run says so in colour. */
|
||||
.fact__path { display: block; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||
.fact.is-warning .fact__hint { color: var(--warning); }
|
||||
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
|
||||
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
|
||||
.sched__desc.is-error { color: var(--warning); }
|
||||
@@ -1629,6 +1691,73 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.findings__item { min-width: 0; overflow-wrap: anywhere; }
|
||||
.ask__subagent { color: var(--ink); }
|
||||
|
||||
/* --- Archived chats: icon · title over its folder and date · Unarchive · more, on known tracks. */
|
||||
|
||||
.arch { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||
.arow {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr) auto var(--control-h-sm);
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-1) 0;
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.arow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||
.arow__icon.is-agent { color: var(--leaf); }
|
||||
.arow__icon .icon { width: 14px; height: 14px; }
|
||||
.arow__open {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-1) 0;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.arow__open:hover .arow__title { text-decoration: underline; }
|
||||
.arow__title, .arow__sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.arow__title { font: var(--text-sm) / var(--leading-tight) var(--font-body); }
|
||||
.arow__sub { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.arch__more { margin-top: var(--sp-3); }
|
||||
.arch__page { display: grid; gap: var(--sp-3); }
|
||||
.head__unarchive { white-space: nowrap; }
|
||||
|
||||
/* --- A chat's tools and skills (scope.js): switches in a popover, the allowed commands below. */
|
||||
|
||||
.scope { max-height: min(70vh, 32rem); overflow-y: auto; }
|
||||
.scope .pop__actions { justify-content: flex-start; }
|
||||
.scope .pop__actions .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h-sm); }
|
||||
.scope__list { display: grid; gap: var(--sp-2); }
|
||||
.scope__row span { overflow-wrap: anywhere; }
|
||||
.scope__hint { margin: 0; font-size: var(--text-xs); }
|
||||
.scope__allow { display: grid; gap: var(--sp-1); margin: 0; padding: 0; list-style: none; }
|
||||
.scope__allow code { font: var(--text-xs) var(--font-mono); overflow-wrap: anywhere; }
|
||||
|
||||
/* --- Memory: a new one on top, then each record over its group and date · Edit · more. */
|
||||
|
||||
.mnew { display: grid; gap: var(--sp-2); }
|
||||
.mnew textarea { width: 100%; }
|
||||
.mnew__foot { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; }
|
||||
.mnew__foot select { justify-self: start; max-width: 100%; }
|
||||
.mrow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.mrow.is-editing { grid-template-columns: minmax(0, 1fr); }
|
||||
.mrow.is-editing textarea { width: 100%; }
|
||||
.mrow.is-marked { background: var(--surface-active); border-radius: var(--radius-lg); padding-inline: var(--sp-2); }
|
||||
.mrow__words { display: grid; gap: var(--sp-1); min-width: 0; }
|
||||
.mrow__text { margin: 0; font: var(--text-sm) / var(--leading-normal) var(--font-body); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.mrow__sub { margin: 0; font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
|
||||
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||
|
||||
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
||||
|
||||
+732
-3
File diff suppressed because it is too large.
Load diff
@@ -53,6 +53,8 @@
|
||||
<div class="side__nav">
|
||||
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
||||
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
||||
<button type="button" class="navrow" id="nav-archived" data-view="archived"><span class="navrow__icon"></span><span data-i18n>Archived</span></button>
|
||||
<button type="button" class="navrow" id="nav-memory" data-view="memory"><span class="navrow__icon"></span><span data-i18n>Memory</span></button>
|
||||
</div>
|
||||
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
||||
<div class="side__foot">
|
||||
@@ -82,6 +84,7 @@
|
||||
<div class="composer__bar">
|
||||
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||
<div class="composer__chips" id="composer-chips"></div>
|
||||
<button type="button" class="tool composer__mic" id="dictate" hidden aria-pressed="false"></button>
|
||||
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
@@ -107,8 +110,11 @@
|
||||
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where muted" id="server-where"></div>
|
||||
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
<p class="status" id="server-status" hidden></p>
|
||||
<div class="files__tree" id="server-tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -123,6 +129,24 @@
|
||||
<div class="page__body" id="scheduled-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-archived" hidden aria-labelledby="archived-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="archived-title" data-i18n>Archived</h1>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><button type="button" class="tool" id="archived-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||
</header>
|
||||
<div class="page__body" id="archived-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-memory" hidden aria-labelledby="memory-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="memory-title" data-i18n>Memory</h1>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><button type="button" class="tool" id="memory-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||
</header>
|
||||
<div class="page__body" id="memory-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Archived chats: the ones put away from the sidebar with Archive chat, newest first. Each can be
|
||||
// opened (read, and written to, as any chat), taken back into the sidebar (Restore chat), or deleted.
|
||||
//
|
||||
// GET /chats?archived=true&limit=&before= { chats: [chat], more }
|
||||
// PATCH /chats/{id} { archived: false } { chat }
|
||||
// DELETE /chats/{id} 204
|
||||
//
|
||||
// A chat's title is the person's or a model's words, set as text.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
import { folderName } from "./list.js"
|
||||
|
||||
let ctx = null // { open(id), changed(), me() }
|
||||
let rows = []
|
||||
let more = false
|
||||
let error = ""
|
||||
let loading = false
|
||||
|
||||
/** The page, read again from the server. */
|
||||
export async function showArchived() {
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/chats?archived=true&limit=50")
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
rows = []
|
||||
more = false
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function loadMore(button) {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
button.disabled = true
|
||||
try {
|
||||
const r = await get(`/chats?archived=true&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Back into the sidebar. */
|
||||
export async function unarchive(chat) {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: false })
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
render()
|
||||
ctx?.changed(r?.chat || chat)
|
||||
return true
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
render()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(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)}`)
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(c) {
|
||||
const agent = c.kind === "agent"
|
||||
const title = c.title || t("New chat")
|
||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||
const sub = [agent && c.device?.directory ? folderName(c.device.directory) : "", when(c.updated_at)].filter(Boolean).join(" · ")
|
||||
return h(
|
||||
"li",
|
||||
{ class: "arow", dataset: { id: c.id } },
|
||||
h("span", { class: `arow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "arow__open", title, onclick: () => ctx.open(c.id) },
|
||||
h("span", { class: "arow__title" }, h("bdi", { text: title })),
|
||||
h("span", { class: "arow__sub", text: sub }),
|
||||
),
|
||||
h("button", { type: "button", class: "btn btn--sm arow__unarchive", text: t("Restore chat"), onclick: () => unarchive(c) }),
|
||||
mayDelete
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool arow__more",
|
||||
"aria-label": t("More"),
|
||||
title: t("More"),
|
||||
onclick: (e) => menu(e.currentTarget, [{ label: t("Delete chat"), danger: true, run: () => remove(c) }]),
|
||||
},
|
||||
icon(ICONS.more),
|
||||
)
|
||||
: h("span", { class: "arow__more" }),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("archived-body"))
|
||||
const parts = []
|
||||
if (error) parts.push(h("p", { class: "status is-error", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("No archived chats.") }))
|
||||
if (rows.length) {
|
||||
parts.push(h("ul", { class: "arch", "aria-label": t("Archived") }, rows.map(row)))
|
||||
if (more) parts.push(h("button", { type: "button", class: "btn btn--sm arch__more", text: t("More"), onclick: (e) => loadMore(e.currentTarget) }))
|
||||
}
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
}
|
||||
|
||||
export function initArchived(context) {
|
||||
ctx = context
|
||||
}
|
||||
@@ -125,3 +125,10 @@ export async function openTerminal(target, cols, rows, { onOutput, onControl, on
|
||||
export function openLink(url) {
|
||||
return invoke("open_external", { url }).catch(() => {})
|
||||
}
|
||||
|
||||
/** Let go of every reply followed and every shell open (the page is about to load again). */
|
||||
export function letGoAll() {
|
||||
const ids = [...followers.keys()].map((id) => invoke("stream_close", { id }))
|
||||
const shells = [...terminals.keys()].map((id) => invoke("terminal_close", { id }))
|
||||
return Promise.allSettled([...ids, ...shells])
|
||||
}
|
||||
+206
-18
@@ -17,9 +17,12 @@ import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubag
|
||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||
import { folderName } from "./list.js"
|
||||
import { folderName, listed, folderTree, newFolder } from "./list.js"
|
||||
import { unarchive } from "./archived.js"
|
||||
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||
import { scopePopover } from "./scope.js"
|
||||
import { readAloudButton } from "./voice.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
@@ -41,12 +44,46 @@ function deviceOf(id) {
|
||||
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||
}
|
||||
|
||||
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
|
||||
function draftDevice() {
|
||||
/** This computer's device: the one linked right now, else the last one it was. */
|
||||
function hereDevice() {
|
||||
const mine = ctx.thisDevices()
|
||||
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||
}
|
||||
|
||||
/**
|
||||
* The device a new agent chat goes to: the one chosen on its chip while it is still linked, else
|
||||
* this computer's.
|
||||
*/
|
||||
function draftDevice() {
|
||||
if (draft?.device && deviceOf(draft.device)) return draft.device
|
||||
return hereDevice()
|
||||
}
|
||||
|
||||
/** Whether the new agent chat works here (the agent built into this app) or on another device. */
|
||||
function draftHere() {
|
||||
const d = draftDevice()
|
||||
return !d || ctx.thisDevices().includes(d)
|
||||
}
|
||||
|
||||
/** The other linked devices of this account: where else a new agent chat can work. */
|
||||
function otherDevices() {
|
||||
const mine = ctx.thisDevices()
|
||||
return (ctx.devices?.() || []).filter((d) => !mine.includes(d.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders an agent chat on another device has worked in: the sidebar's agent chats there,
|
||||
* newest first. This app cannot browse another computer; these and a typed path are the choice.
|
||||
*/
|
||||
function knownFolders(device) {
|
||||
const out = []
|
||||
for (const c of listed()) {
|
||||
const dir = c.kind === "agent" && c.device?.token_id === device ? c.device.directory : ""
|
||||
if (dir && !out.includes(dir)) out.push(dir)
|
||||
}
|
||||
return out.slice(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||
@@ -57,7 +94,7 @@ function terminalTarget() {
|
||||
if (draft.kind !== "agent" || !draft.dir) return null
|
||||
const device = deviceOf(draftDevice())
|
||||
if (!device?.terminal) return null
|
||||
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
|
||||
return { device: device.id, directory: draft.dir, name: device.name || (draftHere() ? t("This computer") : t("Another device")) }
|
||||
}
|
||||
const chat = open?.chat
|
||||
if (chat?.kind !== "agent" || !chat.device) return null
|
||||
@@ -256,6 +293,7 @@ function assistantMessage(m, chat) {
|
||||
"div",
|
||||
{ class: "msg__actions" },
|
||||
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
|
||||
readAloudButton(m.id, () => 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"),
|
||||
),
|
||||
@@ -691,6 +729,8 @@ function renderHead(chat) {
|
||||
// "Continue in a terminal" stays in the chat's menu.
|
||||
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||
}
|
||||
// An archived chat says so where it is read, with the way back into the sidebar.
|
||||
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
||||
tools.append(activityButton())
|
||||
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||
}
|
||||
@@ -749,6 +789,47 @@ const MODE_WORDS = {
|
||||
plan: () => t("Plan — changes nothing"),
|
||||
}
|
||||
|
||||
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
|
||||
|
||||
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
|
||||
|
||||
/** Whether a chat here may be asked for a picture: the server draws for this account. */
|
||||
function picturesOffered() {
|
||||
const me = ctx.me()
|
||||
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
|
||||
}
|
||||
|
||||
function pictureChip() {
|
||||
const b = chip({
|
||||
ico: ICONS.picture,
|
||||
label: t("Draw a picture"),
|
||||
title: t("The reply to the next message is a picture drawn from it."),
|
||||
menuish: false,
|
||||
cls: "cchip--picture",
|
||||
onclick: () => {
|
||||
wantPicture = !wantPicture
|
||||
b.setAttribute("aria-pressed", String(wantPicture))
|
||||
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
|
||||
$("composer-text").focus()
|
||||
},
|
||||
})
|
||||
b.setAttribute("aria-pressed", String(wantPicture))
|
||||
return b
|
||||
}
|
||||
|
||||
/** The message's body, asking for a picture while the switch is on. */
|
||||
function takePicture(body) {
|
||||
return wantPicture ? { ...body, force_tool: "image_generate" } : body
|
||||
}
|
||||
|
||||
/** The message went: the switch goes off with it. */
|
||||
function pictureSent() {
|
||||
if (!wantPicture) return
|
||||
wantPicture = false
|
||||
$("composer-text").placeholder = t("Message")
|
||||
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
|
||||
}
|
||||
|
||||
function modeChip(value, onChange, disabled) {
|
||||
return chip({
|
||||
ico: ICONS.shield,
|
||||
@@ -836,6 +917,51 @@ function folderChip(roots, value, onChange) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which computer a new agent chat works on: this one (the agent built into this app), or another
|
||||
* linked device of the account, as `GET /devices` lists them. Shown only where there is another.
|
||||
*/
|
||||
function deviceChip(others) {
|
||||
const here = draftHere()
|
||||
const current = deviceOf(draftDevice())
|
||||
const choose = (id) => {
|
||||
if (id === draftDevice()) return
|
||||
draft.device = id
|
||||
const elsewhere = !ctx.thisDevices().includes(id)
|
||||
draft.dir = elsewhere ? knownFolders(id)[0] || "" : ""
|
||||
renderChips()
|
||||
renderHello()
|
||||
renderNotice()
|
||||
}
|
||||
const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }]
|
||||
for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) })
|
||||
const label = here ? t("This computer") : current?.name || current?.host || t("Another device")
|
||||
const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) })
|
||||
return b
|
||||
}
|
||||
|
||||
/** The folder of a new agent chat on another device: one it has worked in, or one typed. */
|
||||
function elsewhereFolderChip(device, value, onChange) {
|
||||
const type = async () => {
|
||||
const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") })
|
||||
if (dir) onChange(dir)
|
||||
}
|
||||
const known = knownFolders(device)
|
||||
if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type })
|
||||
return chip({
|
||||
ico: ICONS.folder,
|
||||
label: value ? folderName(value) : t("Choose a folder…"),
|
||||
title: value ? `${t("Folder")}: ${value}` : "",
|
||||
cls: value ? "" : "is-warn",
|
||||
onclick: (e) =>
|
||||
menu(e.currentTarget, [
|
||||
...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
|
||||
known.length ? null : undefined,
|
||||
{ label: t("Type a folder…"), run: type },
|
||||
].filter((x) => x !== undefined)),
|
||||
})
|
||||
}
|
||||
|
||||
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
||||
|
||||
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||
@@ -849,9 +975,15 @@ async function renderChips() {
|
||||
const model = models.find((m) => m.id === draft.model)
|
||||
const parts = []
|
||||
if (draft.kind === "agent") {
|
||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
const others = otherDevices()
|
||||
if (others.length) parts.push(deviceChip(others))
|
||||
if (draftHere()) {
|
||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
} else {
|
||||
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
}
|
||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||
}
|
||||
const shell = terminalTarget()
|
||||
@@ -862,6 +994,8 @@ async function renderChips() {
|
||||
renderChips()
|
||||
}, mayPick))
|
||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
||||
else wantPicture = false
|
||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||
@@ -907,6 +1041,8 @@ async function renderChips() {
|
||||
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
||||
else wantPicture = false
|
||||
box.replaceChildren(...parts.filter(Boolean))
|
||||
const offer = await teamOffered(chat)
|
||||
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||
@@ -920,7 +1056,7 @@ async function renderChips() {
|
||||
function renderNotice() {
|
||||
const box = $("link-notice")
|
||||
const s = ctx.status()
|
||||
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||
const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||
box.hidden = !show
|
||||
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||
@@ -945,9 +1081,14 @@ 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 (!chat.archived) {
|
||||
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
|
||||
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
|
||||
}
|
||||
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) })
|
||||
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(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) })
|
||||
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
||||
menu(anchor, items)
|
||||
}
|
||||
@@ -980,6 +1121,34 @@ export async function continueInTerminal(chat) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Where a chat can go: no folder, any of the account's, or a new one. */
|
||||
function moveMenu(anchor, chat) {
|
||||
const here = chat.folder_id || ""
|
||||
menu(anchor, [
|
||||
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
|
||||
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
|
||||
null,
|
||||
{
|
||||
label: t("New folder…"),
|
||||
run: async () => {
|
||||
const id = await newFolder()
|
||||
if (id) setChat(chat, { folder_id: id })
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
|
||||
async function setChat(chat, body) {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
|
||||
ctx.changed(r?.chat)
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1004,6 +1173,15 @@ async function archiveChat(chat) {
|
||||
}
|
||||
}
|
||||
|
||||
/** An archived chat, open or in its page's menu, back into the sidebar. */
|
||||
async function unarchiveChat(chat) {
|
||||
if (!(await unarchive(chat))) return
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = { ...open.chat, archived: false }
|
||||
renderHead(open.chat)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(chat) {
|
||||
const yes = await ask({
|
||||
title: t("Delete this chat?"),
|
||||
@@ -1116,7 +1294,7 @@ export function closeChat() {
|
||||
|
||||
// --- A new chat ------------------------------------------------------------------------------------
|
||||
|
||||
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
|
||||
let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written
|
||||
let lastKind = "agent"
|
||||
|
||||
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
||||
@@ -1178,7 +1356,9 @@ function renderHello() {
|
||||
h(
|
||||
"p",
|
||||
{ class: "hello__sub" },
|
||||
kind === "agent"
|
||||
kind === "agent" && !draftHere()
|
||||
? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") })
|
||||
: kind === "agent"
|
||||
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
||||
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
||||
),
|
||||
@@ -1194,7 +1374,7 @@ export function newChat(kind) {
|
||||
const models = ctx.models()
|
||||
const start = models.find((m) => m.default) || models[0]
|
||||
lastKind = kind || lastKind
|
||||
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||
draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||
clear($("messages"))
|
||||
renderCards({ id: "" }, [])
|
||||
alertIn("")
|
||||
@@ -1221,14 +1401,20 @@ async function createChat(text, attachments) {
|
||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||
if (draft.kind === "agent") {
|
||||
const device = draftDevice()
|
||||
if (!device || !agentReady(ctx.status())) {
|
||||
renderNotice()
|
||||
throw { detail: agentState(ctx.status()).sentence }
|
||||
if (draftHere()) {
|
||||
if (!device || !agentReady(ctx.status())) {
|
||||
renderNotice()
|
||||
throw { detail: agentState(ctx.status()).sentence }
|
||||
}
|
||||
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||
} else {
|
||||
if (!deviceOf(device)) throw { detail: t("This device is not linked right now.") }
|
||||
if (!draft.dir) throw { detail: t("Choose a folder first.") }
|
||||
}
|
||||
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
||||
}
|
||||
const r = await post("/chats", body)
|
||||
const r = await post("/chats", draft.kind === "chat" ? takePicture(body) : body)
|
||||
pictureSent()
|
||||
draft = null
|
||||
$("new-chat").hidden = true
|
||||
$("main").dataset.new = "false"
|
||||
@@ -1265,9 +1451,11 @@ async function submit() {
|
||||
try {
|
||||
if (draft) await createChat(text, files)
|
||||
else if (open?.chat?.id) {
|
||||
const body = { text }
|
||||
let body = { text }
|
||||
if (files.length) body.attachments = files
|
||||
if (open.chat.kind === "chat") body = takePicture(body)
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
|
||||
pictureSent()
|
||||
if (r.message) $("messages").append(userMessage(r.message))
|
||||
if (r.reply) follower(open.chat, r.reply.id)
|
||||
} else return
|
||||
|
||||
@@ -99,6 +99,11 @@ export const ICONS = {
|
||||
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
||||
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
||||
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
||||
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
|
||||
contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"],
|
||||
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
|
||||
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
|
||||
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
|
||||
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Draw a picture: the instance's own image generation (`POST /v1/images/generations`, its
|
||||
// workflow and default checkpoint), from Ctrl+K. Offered only where `GET /me` says the account may
|
||||
// (`services.images`) and the token holds `models`.
|
||||
//
|
||||
// The pictures are shown only as Rust read them: each one of the person's own files on this
|
||||
// server (`/v1/files/<id>/content`, fetched by id from the app's own server, never from an
|
||||
// address the answer named), handed over as a `data:` URL of an image type -- checked again here
|
||||
// before it becomes an <img>.
|
||||
|
||||
import { h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, why } from "./bridge.js"
|
||||
|
||||
const PICTURE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/
|
||||
|
||||
/** Whether pictures can be drawn here, for the account `me` (`GET /me`). */
|
||||
export function canDraw(me) {
|
||||
const models = !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||
return !!me?.services?.images && models && me?.permissions?.["tools.image"] !== false
|
||||
}
|
||||
|
||||
/** The dialog: a prompt, a shape, Draw, and the pictures under it. */
|
||||
export function openDraw() {
|
||||
const prompt = h("textarea", { class: "textarea draw__prompt", rows: "3", placeholder: t("What to draw…"), "aria-label": t("What to draw…") })
|
||||
const size = h(
|
||||
"select",
|
||||
{ class: "draw__size", "aria-label": t("Shape") },
|
||||
h("option", { value: "1024x1024", text: t("Square") }),
|
||||
h("option", { value: "1344x768", text: t("Wide") }),
|
||||
h("option", { value: "768x1344", text: t("Tall") }),
|
||||
)
|
||||
const status = h("p", { class: "status", role: "status", hidden: true })
|
||||
const out = h("div", { class: "draw__out" })
|
||||
const go = h("button", { type: "submit", class: "btn btn--primary", text: t("Draw") })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "dialog__body draw", method: "dialog" },
|
||||
h("h2", { class: "dialog__title" }, icon(ICONS.picture), h("span", { text: t("Draw a picture") })),
|
||||
prompt,
|
||||
h("div", { class: "draw__bar" }, size, h("button", { type: "button", class: "btn", text: t("Close"), onclick: () => close() }), go),
|
||||
status,
|
||||
out,
|
||||
)
|
||||
const dlg = h("dialog", { class: "dialog dialog--wide" }, form)
|
||||
let asked = 0
|
||||
const close = () => {
|
||||
asked++
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
}
|
||||
const said = (text, error = false) => {
|
||||
status.hidden = !text
|
||||
status.textContent = text
|
||||
status.className = `status${error ? " is-error" : ""}`
|
||||
status.setAttribute("role", error ? "alert" : "status")
|
||||
}
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
const words = prompt.value.trim()
|
||||
if (!words) return prompt.focus()
|
||||
const mine = ++asked
|
||||
go.disabled = true
|
||||
said(t("Drawing… a picture can take a minute or two."))
|
||||
try {
|
||||
const urls = await invoke("draw", { prompt: words, size: size.value })
|
||||
if (mine !== asked) return
|
||||
const pictures = (urls || []).filter((u) => PICTURE.test(u))
|
||||
if (!pictures.length) throw { detail: t("The server's answer had no picture in it.") }
|
||||
out.prepend(...pictures.map((src) => h("figure", { class: "draw__pic" }, h("img", { src, alt: words }), h("figcaption", { text: words }))))
|
||||
said("")
|
||||
} catch (err) {
|
||||
if (mine === asked) said(why(err), true)
|
||||
} finally {
|
||||
if (mine === asked) go.disabled = false
|
||||
}
|
||||
})
|
||||
prompt.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
close()
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
prompt.focus()
|
||||
}
|
||||
+126
-11
@@ -3,7 +3,8 @@
|
||||
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, get, why } from "./bridge.js"
|
||||
import { invoke, get, post, del, why } from "./bridge.js"
|
||||
import { menu, ask } from "./popup.js"
|
||||
import { openViewer } from "./viewer.js"
|
||||
|
||||
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
||||
@@ -18,12 +19,19 @@ export const SERVER_FILES = {
|
||||
/** A directory in either tree: the server's mounts open like its folders. */
|
||||
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
|
||||
|
||||
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
||||
function tree(box, { list, open, rootPath }) {
|
||||
/**
|
||||
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
|
||||
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
|
||||
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
|
||||
*/
|
||||
function tree(box, { list, open, rootPath, actions }) {
|
||||
clear(box)
|
||||
const ul = h("ul", { class: "tree", role: "tree" })
|
||||
box.append(ul)
|
||||
const levels = new Map() // path -> { ul, depth }, each directory drawn now
|
||||
const opened = new Set() // the directories open now
|
||||
const level = async (parent, path, depth) => {
|
||||
levels.set(path, { ul: parent, depth })
|
||||
let entries
|
||||
try {
|
||||
entries = await list(path)
|
||||
@@ -42,26 +50,45 @@ function tree(box, { list, open, rootPath }) {
|
||||
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||
)
|
||||
row.style.setProperty("--depth", String(depth))
|
||||
const li = h("li", { role: "treeitem" }, row)
|
||||
const items = actions?.(e) || []
|
||||
const li = h(
|
||||
"li",
|
||||
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
|
||||
row,
|
||||
items.length
|
||||
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
|
||||
: null,
|
||||
)
|
||||
if (isDir(e)) {
|
||||
let kids = null
|
||||
row.addEventListener("click", async () => {
|
||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
||||
const expand = async (opening) => {
|
||||
row.setAttribute("aria-expanded", String(opening))
|
||||
if (!opening) {
|
||||
opened.delete(e.path)
|
||||
levels.delete(e.path)
|
||||
kids?.remove()
|
||||
kids = null
|
||||
return
|
||||
}
|
||||
opened.add(e.path)
|
||||
kids = h("ul", { role: "group" })
|
||||
li.append(kids)
|
||||
await level(kids, e.path, depth + 1)
|
||||
})
|
||||
}
|
||||
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
|
||||
if (opened.has(e.path)) expand(true)
|
||||
} else row.addEventListener("click", () => open(e))
|
||||
parent.append(li)
|
||||
}
|
||||
}
|
||||
return level(ul, rootPath, 0)
|
||||
const done = level(ul, rootPath, 0)
|
||||
const reload = (path) => {
|
||||
const at = levels.get(path) || levels.get(rootPath)
|
||||
if (!at) return Promise.resolve()
|
||||
clear(at.ul)
|
||||
return level(at.ul, levels.has(path) ? path : rootPath, at.depth)
|
||||
}
|
||||
return { done, reload }
|
||||
}
|
||||
|
||||
// --- This computer ------------------------------------------------------------------------------
|
||||
@@ -99,7 +126,7 @@ function renderLocal() {
|
||||
head.append(pick)
|
||||
}
|
||||
const root = local.root
|
||||
tree(box, {
|
||||
return tree(box, {
|
||||
rootPath: "",
|
||||
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
||||
open: async (e) => {
|
||||
@@ -125,12 +152,96 @@ export async function readServer(path) {
|
||||
return get(SERVER_FILES.read(path))
|
||||
}
|
||||
|
||||
// The library's writes, as the person (each answers with the directory's listing, or a refusal
|
||||
// in the server's words):
|
||||
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
|
||||
// POST /files/mkdir { path, name }
|
||||
// POST /files/rename { path, name }
|
||||
// DELETE /files?path= to the trash, as on the web
|
||||
|
||||
let server = null // the tree drawn now: { reload(path) }
|
||||
let serverRoot = "/" // the directory the server answered `/` with: the person's home
|
||||
|
||||
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
|
||||
const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir)
|
||||
|
||||
function serverSaid(text, error = false) {
|
||||
const line = $("server-status")
|
||||
line.hidden = !text
|
||||
line.textContent = text || ""
|
||||
line.className = `status${error ? " is-error" : ""}`
|
||||
line.setAttribute("role", error ? "alert" : "status")
|
||||
}
|
||||
|
||||
/** Into `dir`: files chosen in the system's dialog. */
|
||||
export async function uploadInto(dir) {
|
||||
serverSaid("")
|
||||
try {
|
||||
const r = await invoke("library_upload", { dir, title: t("Upload") })
|
||||
if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true)
|
||||
if (r.sent) await reloadServer(dir)
|
||||
} catch (err) {
|
||||
serverSaid(why(err), true)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeFolder(dir) {
|
||||
const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") })
|
||||
if (!name) return
|
||||
try {
|
||||
await post("/files/mkdir", { path: dir, name })
|
||||
serverSaid("")
|
||||
await reloadServer(dir)
|
||||
} catch (err) {
|
||||
serverSaid(why(err), true)
|
||||
}
|
||||
}
|
||||
|
||||
async function renameEntry(e) {
|
||||
const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") })
|
||||
if (!name || name === e.name) return
|
||||
try {
|
||||
await post("/files/rename", { path: e.path, name })
|
||||
serverSaid("")
|
||||
await reloadServer(parentOf(e.path))
|
||||
} catch (err) {
|
||||
serverSaid(why(err), true)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntry(e) {
|
||||
const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true })
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/files?path=${encodeURIComponent(e.path)}`)
|
||||
serverSaid(t("Moved to the trash."))
|
||||
await reloadServer(parentOf(e.path))
|
||||
} catch (err) {
|
||||
serverSaid(why(err), true)
|
||||
}
|
||||
}
|
||||
|
||||
/** What can be done to an entry of the server's tree: a mount (another store) is filled, never
|
||||
* renamed or deleted from here. A read-only place is refused by the server, in its words. */
|
||||
function serverActions(e) {
|
||||
const items = []
|
||||
if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) })
|
||||
if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
|
||||
return items
|
||||
}
|
||||
|
||||
export function renderServer() {
|
||||
const box = $("server-tree")
|
||||
tree(box, {
|
||||
serverSaid("")
|
||||
server = tree(box, {
|
||||
rootPath: "/",
|
||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||
list: async (path) => {
|
||||
const r = await get(SERVER_FILES.list(path))
|
||||
if (path === "/" && r.path) serverRoot = r.path
|
||||
return r.entries || []
|
||||
},
|
||||
open: (e) => openServerFile(e.path, e.name),
|
||||
actions: serverActions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,6 +288,10 @@ export function initExplorer() {
|
||||
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("server-refresh").addEventListener("click", renderServer)
|
||||
$("server-upload").replaceChildren(icon(ICONS.send))
|
||||
$("server-upload").addEventListener("click", () => uploadInto(serverRoot))
|
||||
$("server-mkdir").replaceChildren(icon(ICONS.folder))
|
||||
$("server-mkdir").addEventListener("click", () => makeFolder(serverRoot))
|
||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("local-refresh").addEventListener("click", renderLocal)
|
||||
}
|
||||
+156
-9
@@ -1,15 +1,23 @@
|
||||
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
|
||||
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
|
||||
// on another device of the account says so in its title. The search line above narrows the list
|
||||
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
|
||||
// The sidebar's chats: agent chats and chats on the server in one list, newest first. The
|
||||
// pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the
|
||||
// server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark
|
||||
// and its project folder's name; one that works on another device of the account says so in its
|
||||
// title. The search line above narrows the list by title (and an agent chat by its folder) as it
|
||||
// is typed, flat; Ctrl+K searches everything.
|
||||
//
|
||||
// Folders are made, renamed and deleted here (`POST/PATCH/DELETE /folders`); a chat is pinned and
|
||||
// moved from its own menu (`PATCH /chats/{id}` `pinned`, `folder_id`). Deleting a folder never
|
||||
// deletes a chat: its chats go back to the list.
|
||||
|
||||
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
import { get, post, patch, del, why } from "./bridge.js"
|
||||
import { menu, ask } from "./popup.js"
|
||||
|
||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat), note(text) }
|
||||
let rows = []
|
||||
let more = false
|
||||
let query = ""
|
||||
let folders = [] // { id, name, parent_id, collapsed, position }
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
function fold(text) {
|
||||
@@ -22,9 +30,10 @@ function fold(text) {
|
||||
/** The list again, from the server. */
|
||||
export async function refreshList() {
|
||||
try {
|
||||
const r = await get("/chats?limit=50")
|
||||
const [r, f] = await Promise.all([get("/chats?limit=50"), get("/folders").catch(() => ({ folders: [] }))])
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
folders = f.folders || []
|
||||
render()
|
||||
} catch (err) {
|
||||
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
|
||||
@@ -77,6 +86,108 @@ function row(c) {
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
/** A folder's head: it folds and unfolds; its menu renames it, makes one inside, deletes it. */
|
||||
function folderHead(f, depth, count) {
|
||||
const open = !f.collapsed
|
||||
const head = h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "folderrow",
|
||||
dataset: { folder: f.id },
|
||||
style: depth ? `--depth: ${depth}` : null,
|
||||
"aria-expanded": String(open),
|
||||
title: f.name,
|
||||
onclick: () => foldFolder(f, !f.collapsed),
|
||||
},
|
||||
h("span", { class: "folderrow__chev" }, icon(open ? ICONS.chevronDown : ICONS.chevron)),
|
||||
h("span", { class: "folderrow__icon" }, icon(ICONS.folder)),
|
||||
h("span", { class: "folderrow__name", text: f.name }),
|
||||
h("span", { class: "folderrow__count", text: count ? String(count) : "" }),
|
||||
)
|
||||
head.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault()
|
||||
folderMenu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : head, f)
|
||||
})
|
||||
const more = h("button", { type: "button", class: "folderrow__more", "aria-label": t("More"), title: t("More"), onclick: (e) => folderMenu(e.currentTarget, f) }, icon(ICONS.more))
|
||||
return h("li", { class: "list__folder" }, head, more)
|
||||
}
|
||||
|
||||
async function foldFolder(f, collapsed) {
|
||||
f.collapsed = collapsed
|
||||
render()
|
||||
try {
|
||||
await patch(`/folders/${encodeURIComponent(f.id)}`, { collapsed })
|
||||
} catch {
|
||||
// Folding is the sidebar's own business: drawn as asked even where the server did not keep it.
|
||||
}
|
||||
}
|
||||
|
||||
function folderMenu(anchor, f) {
|
||||
menu(anchor, [
|
||||
{ label: t("Rename folder"), run: () => renameFolder(f) },
|
||||
{ label: t("New folder"), run: () => newFolder(f.id) },
|
||||
null,
|
||||
{ label: t("Delete folder"), danger: true, run: () => deleteFolder(f) },
|
||||
])
|
||||
}
|
||||
|
||||
/** A new folder, at the top level or inside `parent`: its id once made, else "". */
|
||||
export async function newFolder(parent = "") {
|
||||
const name = await ask({ title: t("New folder"), text: t("Folder name"), value: "", ok: t("Create"), cancel: t("Cancel") })
|
||||
if (!name) return ""
|
||||
try {
|
||||
const r = await post("/folders", parent ? { name, parent_id: parent } : { name })
|
||||
await refreshList()
|
||||
return r.folder?.id || ""
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolder(f) {
|
||||
const name = await ask({ title: t("Rename folder"), text: t("Folder name"), value: f.name, ok: t("Rename"), cancel: t("Cancel") })
|
||||
if (!name || name === f.name) return
|
||||
try {
|
||||
await patch(`/folders/${encodeURIComponent(f.id)}`, { name })
|
||||
await refreshList()
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(f) {
|
||||
const ok = await ask({ title: t("Delete folder"), text: t("“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", { name: f.name }), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||
if (!ok) return
|
||||
try {
|
||||
await del(`/folders/${encodeURIComponent(f.id)}`)
|
||||
await refreshList()
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's chat folders, as the list last read them. */
|
||||
export function listedFolders() {
|
||||
return folders
|
||||
}
|
||||
|
||||
/** The folders as a tree, depth first, each with its depth: what a menu of them lists. */
|
||||
export function folderTree() {
|
||||
const out = []
|
||||
const known = new Set(folders.map((f) => f.id))
|
||||
const walk = (parent, depth) => {
|
||||
const kids = folders.filter((f) => (f.parent_id && known.has(f.parent_id) ? f.parent_id : null) === parent).sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name))
|
||||
for (const f of kids) {
|
||||
out.push({ folder: f, depth })
|
||||
walk(f.id, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(null, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function isToday(stamp) {
|
||||
const d = new Date(stamp)
|
||||
if (Number.isNaN(d.getTime())) return false
|
||||
@@ -98,8 +209,44 @@ function render() {
|
||||
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||
return
|
||||
}
|
||||
const today = visible.filter((r) => isToday(r.updated_at))
|
||||
const earlier = visible.filter((r) => !isToday(r.updated_at))
|
||||
// Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest.
|
||||
let rest = visible
|
||||
if (!words.length) {
|
||||
const pinned = visible.filter((r) => r.pinned)
|
||||
if (pinned.length) append(list, [h("li", { class: "list__head", text: t("Pinned chats") }), pinned.map(row)])
|
||||
const known = new Set(folders.map((f) => f.id))
|
||||
const filed = (r) => !r.pinned && r.folder_id && known.has(r.folder_id)
|
||||
const tree = folderTree()
|
||||
if (tree.length) {
|
||||
const inside = (id) => {
|
||||
// A folder's count: its own chats and those of the folders inside it.
|
||||
const ids = new Set([id])
|
||||
for (const { folder } of tree) if (folder.parent_id && ids.has(folder.parent_id)) ids.add(folder.id)
|
||||
return visible.filter((r) => filed(r) && ids.has(r.folder_id)).length
|
||||
}
|
||||
const hidden = new Set() // folders under a folded one
|
||||
for (const { folder, depth } of tree) {
|
||||
if (folder.parent_id && hidden.has(folder.parent_id)) {
|
||||
hidden.add(folder.id)
|
||||
continue
|
||||
}
|
||||
list.append(folderHead(folder, depth, inside(folder.id)))
|
||||
if (folder.collapsed) {
|
||||
hidden.add(folder.id)
|
||||
continue
|
||||
}
|
||||
for (const r of visible.filter((x) => filed(x) && x.folder_id === folder.id)) {
|
||||
const li = row(r)
|
||||
li.firstChild.style.setProperty("--depth", String(depth + 1))
|
||||
li.firstChild.classList.add("is-filed")
|
||||
list.append(li)
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = visible.filter((r) => !r.pinned && !filed(r))
|
||||
}
|
||||
const today = rest.filter((r) => isToday(r.updated_at))
|
||||
const earlier = rest.filter((r) => !isToday(r.updated_at))
|
||||
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
||||
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
||||
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// The app's language and theme, as Settings chooses them.
|
||||
//
|
||||
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||
// `t()` as it is needed, so a new language opens the window again.
|
||||
//
|
||||
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||
|
||||
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||
import { terminalThemeChanged } from "./terminal.js"
|
||||
|
||||
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||
let me = null // /me, as last read
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) || ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value)
|
||||
else localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||
function known(code) {
|
||||
const c = String(code || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[-_]/)[0]
|
||||
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||
}
|
||||
|
||||
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||
export function accountThemes() {
|
||||
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||
}
|
||||
|
||||
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||
export function localTheme() {
|
||||
const name = read("lembas.theme")
|
||||
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||
}
|
||||
|
||||
/** The account's theme, as the server says it. */
|
||||
export function accountTheme() {
|
||||
return me?.theme || "moria"
|
||||
}
|
||||
|
||||
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||
export function applyTheme(name) {
|
||||
name = localTheme() || name
|
||||
if (!LOOKS.includes(name)) name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
return name
|
||||
}
|
||||
|
||||
function paint() {
|
||||
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||
}
|
||||
|
||||
/**
|
||||
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||
* it is now another, the window opens again in it.
|
||||
*/
|
||||
export function noteMe(next) {
|
||||
me = next || null
|
||||
paint()
|
||||
if (!me || !me.language) return
|
||||
const account = known(me.language)
|
||||
const before = read("lembas.account-language")
|
||||
write("lembas.account-language", account)
|
||||
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||
if (read("lembas.account-language") !== account) return
|
||||
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||
* server cannot be told, kept here ("" goes back to the account's).
|
||||
*/
|
||||
export async function chooseTheme(name) {
|
||||
if (accountThemes()) {
|
||||
const r = await patch("/me", { theme: name })
|
||||
me = r && r.theme ? r : { ...me, theme: name }
|
||||
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||
paint()
|
||||
}
|
||||
|
||||
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||
export function chosenLanguage() {
|
||||
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||
}
|
||||
|
||||
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||
export function accountLanguage() {
|
||||
return me?.language ? known(me.language) : ""
|
||||
}
|
||||
|
||||
/** Choose the language; the window opens again in it. */
|
||||
export function chooseLanguage(choice) {
|
||||
if (choice === chosenLanguage()) return
|
||||
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||
return reopen()
|
||||
}
|
||||
|
||||
/** Open the window again: the replies followed and the shells open let go first. */
|
||||
async function reopen() {
|
||||
await letGoAll()
|
||||
location.reload()
|
||||
}
|
||||
+62
-16
@@ -10,21 +10,26 @@ import { renderBar, initBar } from "./titlebar.js"
|
||||
import { renderSetup, initSetup } from "./setup.js"
|
||||
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||
import { initExplorer, showLocal, showFilesTab, openServerFile } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
|
||||
import { applyTheme, noteMe } from "./look.js"
|
||||
import { initAttach, allowAttach } from "./attach.js"
|
||||
import { initPalette, openPalette } from "./palette.js"
|
||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||
import { initAgent, link } from "./agent.js"
|
||||
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||
import { initArchived, showArchived } from "./archived.js"
|
||||
import { showMemory } from "./memory.js"
|
||||
import { initVoice, voiceFor } from "./voice.js"
|
||||
import { canDraw, openDraw } from "./draw.js"
|
||||
import { closePopover } from "./popup.js"
|
||||
import { noteSubagentsCap } from "./models.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "memory" | "settings"
|
||||
let me = null
|
||||
let models = []
|
||||
let devices = [] // this account's linked devices
|
||||
@@ -63,14 +68,6 @@ function thisDevices() {
|
||||
return remembered.slice()
|
||||
}
|
||||
|
||||
function applyTheme(name) {
|
||||
if (name !== "moria" && name !== "shire") name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
}
|
||||
|
||||
// --- Views ------------------------------------------------------------------------------------
|
||||
|
||||
function route() {
|
||||
@@ -98,8 +95,8 @@ async function startApp() {
|
||||
started = true
|
||||
try {
|
||||
me = await get("/me")
|
||||
applyTheme(me.theme)
|
||||
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
|
||||
noteMe(me)
|
||||
voiceFor(me)
|
||||
} catch (err) {
|
||||
$("thread-note").textContent = why(err)
|
||||
$("thread-note").hidden = false
|
||||
@@ -154,7 +151,7 @@ function accountButton() {
|
||||
|
||||
// --- The pages ----------------------------------------------------------------------------------
|
||||
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, the archived chats, or the settings. */
|
||||
function showPage(name) {
|
||||
page = name
|
||||
closePopover()
|
||||
@@ -162,6 +159,8 @@ function showPage(name) {
|
||||
$("main").hidden = name !== "chat"
|
||||
$("page-files").hidden = name !== "files"
|
||||
$("page-scheduled").hidden = name !== "scheduled"
|
||||
$("page-archived").hidden = name !== "archived"
|
||||
$("page-memory").hidden = name !== "memory"
|
||||
$("settings").hidden = name !== "settings"
|
||||
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||
if (name !== "chat") {
|
||||
@@ -209,6 +208,16 @@ function showSettings(on, section) {
|
||||
if (section) scrollToSection(section)
|
||||
}
|
||||
|
||||
function showArchivedPage() {
|
||||
showPage("archived")
|
||||
showArchived()
|
||||
}
|
||||
|
||||
function showMemoryPage(focus) {
|
||||
showPage("memory")
|
||||
showMemory(focus || "")
|
||||
}
|
||||
|
||||
function showFiles(which) {
|
||||
showPage("files")
|
||||
if (which) showFilesTab(which)
|
||||
@@ -223,8 +232,11 @@ function commands() {
|
||||
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||
...scheduledCommands(),
|
||||
{ label: t("Files"), run: () => showFiles() },
|
||||
{ label: t("Archived"), run: showArchivedPage },
|
||||
{ label: t("Memory"), run: () => showMemoryPage() },
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (canDraw(me)) out.push({ label: t("Draw a picture"), run: openDraw })
|
||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||
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 }) })
|
||||
@@ -267,7 +279,12 @@ async function boot() {
|
||||
showChat(id)
|
||||
},
|
||||
menu: (anchor, c) => chatMenu(anchor, c),
|
||||
note: (text) => {
|
||||
$("thread-note").textContent = text
|
||||
$("thread-note").hidden = !text
|
||||
},
|
||||
})
|
||||
initVoice()
|
||||
initChat({
|
||||
status: () => status,
|
||||
thisDevices,
|
||||
@@ -291,8 +308,19 @@ async function boot() {
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
initPalette({ commands, open: (c) => showChat(c.id) })
|
||||
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||
initArchived({
|
||||
me: () => me,
|
||||
open: (id) => showChat(id),
|
||||
// Back in the sidebar: the list again.
|
||||
changed: () => refreshList(),
|
||||
})
|
||||
initPalette({
|
||||
commands,
|
||||
open: (c) => showChat(c.id),
|
||||
openFile: (path, name) => openServerFile(path, name),
|
||||
openMemory: (id) => showMemoryPage(id),
|
||||
})
|
||||
initScheduled({ status: () => status, models: () => models, me: () => me, thisDevices, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
@@ -326,6 +354,14 @@ async function boot() {
|
||||
})
|
||||
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||
$("nav-files").addEventListener("click", () => showFiles())
|
||||
$("nav-archived").querySelector(".navrow__icon").replaceChildren(icon(ICONS.archive))
|
||||
$("nav-archived").addEventListener("click", showArchivedPage)
|
||||
$("archived-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("archived-refresh").addEventListener("click", () => showArchived())
|
||||
$("nav-memory").querySelector(".navrow__icon").replaceChildren(icon(ICONS.bulb))
|
||||
$("nav-memory").addEventListener("click", () => showMemoryPage())
|
||||
$("memory-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("memory-refresh").addEventListener("click", () => showMemory())
|
||||
accountButton()
|
||||
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
@@ -374,6 +410,16 @@ async function boot() {
|
||||
listen("open-settings", () => {
|
||||
if (view === "app") showSettings(true)
|
||||
})
|
||||
// A notification clicked: the chat it told of, or the scheduled task whose run it was.
|
||||
listen("open-chat", (e) => {
|
||||
if (view === "app" && typeof e.payload === "string") showChat(e.payload)
|
||||
})
|
||||
listen("open-schedule", (e) => {
|
||||
const id = e.payload?.schedule
|
||||
if (view !== "app" || typeof id !== "string") return
|
||||
showPage("scheduled")
|
||||
showScheduled(id)
|
||||
})
|
||||
status = await invoke("get_status")
|
||||
applyTheme(status.theme)
|
||||
renderBar(status)
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// Memory: what the account's models remember about the person, as the web's Settings → Memory
|
||||
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
|
||||
// group chosen (where there is more than one).
|
||||
//
|
||||
// GET /memories { memories: [memory], groups: [{ id, name }] }
|
||||
// POST /memories { content, data_group_id? } -> { memory }
|
||||
// PATCH /memories/{id} { content } -> { memory }
|
||||
// DELETE /memories/{id} 204
|
||||
//
|
||||
// A record may be a model's words (`author` other than "user"): set as text, never markup.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, post, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
|
||||
let rows = []
|
||||
let groups = []
|
||||
let error = ""
|
||||
let loading = false
|
||||
let editing = "" // the record being changed
|
||||
let mark = "" // the record to bring into view (from a search hit)
|
||||
|
||||
/** The page, read again from the server; `focus` a record to bring into view. */
|
||||
export async function showMemory(focus = "") {
|
||||
mark = focus
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/memories")
|
||||
rows = r.memories || []
|
||||
groups = r.groups || []
|
||||
} catch (err) {
|
||||
rows = []
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function add(form) {
|
||||
const text = form.querySelector("textarea")
|
||||
const content = text.value.trim()
|
||||
if (!content) return text.focus()
|
||||
const group = form.querySelector("select")?.value
|
||||
form.querySelector("button[type=submit]").disabled = true
|
||||
try {
|
||||
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
|
||||
if (r?.memory) rows = [r.memory, ...rows]
|
||||
error = ""
|
||||
text.value = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
$("memory-new")?.focus()
|
||||
}
|
||||
|
||||
async function save(m, content) {
|
||||
content = content.trim()
|
||||
if (!content || content === m.content) {
|
||||
editing = ""
|
||||
return render()
|
||||
}
|
||||
try {
|
||||
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
|
||||
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
|
||||
editing = ""
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/memories/${encodeURIComponent(m.id)}`)
|
||||
rows = rows.filter((x) => x.id !== m.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(m) {
|
||||
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
|
||||
if (editing === m.id) {
|
||||
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
|
||||
box.value = m.content
|
||||
const done = () => save(m, box.value)
|
||||
box.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
editing = ""
|
||||
render()
|
||||
}
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
|
||||
})
|
||||
queueMicrotask(() => box.focus())
|
||||
return h(
|
||||
"li",
|
||||
{ class: "mrow is-editing", dataset: { id: m.id } },
|
||||
box,
|
||||
h(
|
||||
"div",
|
||||
{ class: "set__buttons" },
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
|
||||
),
|
||||
)
|
||||
}
|
||||
return h(
|
||||
"li",
|
||||
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("memory-body"))
|
||||
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
|
||||
const form = h(
|
||||
"form",
|
||||
{
|
||||
class: "mnew",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault()
|
||||
add(e.currentTarget)
|
||||
},
|
||||
},
|
||||
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "mnew__foot" },
|
||||
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
|
||||
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
|
||||
),
|
||||
)
|
||||
form.querySelector("textarea").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||
})
|
||||
parts.push(form)
|
||||
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
|
||||
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
+70
-8
@@ -1,12 +1,16 @@
|
||||
// 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.
|
||||
// the chats of both kinds by title, newest first, and below them what the server's search finds
|
||||
// inside them, in the file library and in memory (`GET /search`, once two letters are typed and
|
||||
// the typing pauses). 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.
|
||||
//
|
||||
// A hit's snippet is plain text with the ranges that matched (`marks`): drawn as text, the
|
||||
// ranges as <mark> elements. Titles and snippets may be a model's words; nothing is markup.
|
||||
|
||||
import { h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get } from "./bridge.js"
|
||||
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(chat), openFile(path, name), openMemory(id) }
|
||||
let dlg = null
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
@@ -17,6 +21,22 @@ function fold(text) {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** `text` with the `[start, end)` ranges of `marks` as <mark> elements: text nodes only. */
|
||||
function marked(text, marks) {
|
||||
const el = h("span", { class: "palette__snippet" })
|
||||
let at = 0
|
||||
const chars = [...String(text || "")]
|
||||
for (const [a, b] of (marks || []).filter((m) => Array.isArray(m) && m[1] > m[0]).sort((x, y) => x[0] - y[0])) {
|
||||
if (a < at) continue
|
||||
el.append(chars.slice(at, a).join(""), h("mark", { text: chars.slice(a, b).join("") }))
|
||||
at = b
|
||||
}
|
||||
el.append(chars.slice(at).join(""))
|
||||
return el
|
||||
}
|
||||
|
||||
const SEARCH_KIND = { chats: () => t("Chats"), files: () => t("Files"), memories: () => t("Memory") }
|
||||
|
||||
function matches(text, words) {
|
||||
const f = fold(text)
|
||||
return words.every((w) => f.includes(w))
|
||||
@@ -31,7 +51,11 @@ export async function openPalette() {
|
||||
let chats = []
|
||||
let shown = []
|
||||
let at = 0
|
||||
let found = [] // the server's hits for `asked`
|
||||
let asked = ""
|
||||
let timer = 0
|
||||
const close = () => {
|
||||
clearTimeout(timer)
|
||||
if (!dlg) return
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
@@ -44,16 +68,32 @@ export async function openPalette() {
|
||||
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)
|
||||
const titled = 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) => ({
|
||||
...titled.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),
|
||||
})),
|
||||
]
|
||||
// What the server found inside: the chats already listed by their title once only.
|
||||
const listed = new Set(titled.map((c) => c.id))
|
||||
for (const group of input.value.trim() === asked ? found : []) {
|
||||
for (const hit of group.hits || []) {
|
||||
if (hit.kind === "chat" && listed.has(hit.id)) continue
|
||||
const kind = { chat: "found-chat", file: "found-file", memory: "found-memory" }[hit.kind]
|
||||
if (!kind) continue
|
||||
shown.push({
|
||||
kind,
|
||||
label: hit.title || (hit.kind === "chat" ? t("New chat") : hit.kind === "file" ? String(hit.path || "").split("/").pop() : t("Memory")),
|
||||
snippet: hit.snippet ? marked(hit.snippet, hit.marks) : null,
|
||||
hint: SEARCH_KIND[group.kind]?.() || "",
|
||||
run: () => (hit.kind === "chat" ? ctx.open({ id: hit.id, kind: hit.agent ? "agent" : "chat" }) : hit.kind === "file" ? ctx.openFile(hit.path, hit.title) : ctx.openMemory(hit.id)),
|
||||
})
|
||||
}
|
||||
}
|
||||
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.") }))
|
||||
@@ -74,8 +114,10 @@ export async function openPalette() {
|
||||
}
|
||||
},
|
||||
},
|
||||
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__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "found-memory" ? ICONS.bulb : item.kind.startsWith("found") ? ICONS.search : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||
item.snippet
|
||||
? h("span", { class: "palette__label palette__label--two" }, h("span", { class: "palette__title", text: item.label }), item.snippet)
|
||||
: h("span", { class: "palette__label", text: item.label }),
|
||||
h("span", { class: "palette__hint", text: item.hint }),
|
||||
),
|
||||
)
|
||||
@@ -87,9 +129,29 @@ export async function openPalette() {
|
||||
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
||||
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
// The server's search, once the typing pauses: an older server without it finds nothing more.
|
||||
const search = () => {
|
||||
clearTimeout(timer)
|
||||
const q = input.value.trim()
|
||||
if (q.length < 2) {
|
||||
found = []
|
||||
asked = ""
|
||||
return
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await get(`/search?q=${encodeURIComponent(q)}&in=all&limit=8`)
|
||||
if (!dlg || input.value.trim() !== q) return
|
||||
found = r.results || []
|
||||
asked = q
|
||||
draw()
|
||||
} catch {}
|
||||
}, 250)
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
at = 0
|
||||
draw()
|
||||
search()
|
||||
})
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
|
||||
+4
-3
@@ -199,7 +199,8 @@ function escPop(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
|
||||
* A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's
|
||||
* point; or above it, `up`), holding `content` (an element, or a function
|
||||
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
||||
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
||||
*/
|
||||
@@ -211,7 +212,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
||||
draw()
|
||||
document.body.append(el)
|
||||
const place = () => {
|
||||
const at = anchor.getBoundingClientRect()
|
||||
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y }
|
||||
const r = el.getBoundingClientRect()
|
||||
const left = align === "end" ? at.right - r.width : at.left
|
||||
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
||||
@@ -219,7 +220,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
||||
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||
}
|
||||
place()
|
||||
openPop = { el, back: anchor, onClose }
|
||||
openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose }
|
||||
document.addEventListener("pointerdown", outsidePop, true)
|
||||
document.addEventListener("keydown", escPop, true)
|
||||
el.querySelector("button, input, select")?.focus()
|
||||
|
||||
+103
-5
@@ -27,6 +27,15 @@
|
||||
// by the app (Rust, watch.rs), handed here as the
|
||||
// `schedules` event
|
||||
//
|
||||
// GET /devices { devices: [{ id, name, host, scheduled, max_mode }] }:
|
||||
// the linked computers, each saying whether it
|
||||
// accepts scheduled work
|
||||
//
|
||||
// Where it runs (`runs_on`, server 2.0.0-beta.9): null on the server, else `{ device, device_name,
|
||||
// cwd, mode, approval_wait_minutes }` -- an agent chat on that linked computer, in that folder and
|
||||
// mode (never Plan). A run waiting for the computer says `waiting_for: "device"`; one it refused
|
||||
// (it does not accept scheduled work) fails with the computer's own words.
|
||||
//
|
||||
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
||||
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
||||
// model's words, set as text; a result's Markdown goes through markdown.js.
|
||||
@@ -37,7 +46,7 @@ import { ask, menu, popover } from "./popup.js"
|
||||
import { avatar, levelsOf, levelWord } from "./models.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
|
||||
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
||||
let ctx = null // { status(), models(), me(), thisDevices(), openChat(id), show() }
|
||||
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
||||
let tasks = []
|
||||
let missing = false // the server has no scheduling routes
|
||||
@@ -83,7 +92,7 @@ const RUNS_PAGE = 20
|
||||
function statusChip(run, running) {
|
||||
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
||||
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||
const word = STATUS[run.status]?.() || run.status
|
||||
const word = run.status === "waiting" && run.waiting_for === "device" ? t("Waiting for the computer") : STATUS[run.status]?.() || run.status
|
||||
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
||||
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
||||
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
||||
@@ -326,7 +335,8 @@ async function drawTask(id) {
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
const models = (await schedulingModels())?.models || ctx.models()
|
||||
const [smodels, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||
const models = smodels?.models || ctx.models()
|
||||
const body = clear($("scheduled-body"))
|
||||
body.append(
|
||||
h(
|
||||
@@ -344,6 +354,7 @@ async function drawTask(id) {
|
||||
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||
"runs_on" in task ? whereFact(task.runs_on, devices) : null,
|
||||
),
|
||||
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
||||
upcoming(task.next_runs || []),
|
||||
@@ -352,6 +363,46 @@ async function drawTask(id) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Where it runs ------------------------------------------------------------------------------
|
||||
|
||||
const RUN_MODES = {
|
||||
manual: () => t("Manual — asks first"),
|
||||
edit: () => t("Edit — asks before commands"),
|
||||
auto: () => t("Auto — asks nothing"),
|
||||
}
|
||||
|
||||
/** The account's linked computers (`GET /devices`), or null where the server cannot say. */
|
||||
async function linkedDevices() {
|
||||
try {
|
||||
return (await get("/devices")).devices || []
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function deviceLabel(d) {
|
||||
const name = d.name || d.host || d.id
|
||||
return (ctx.thisDevices?.() || []).includes(d.id) ? t("This computer (%(name)s)", { name }) : name
|
||||
}
|
||||
|
||||
/** "Where it runs": the server, or a computer and a folder, with what it is doing about it now. */
|
||||
function whereFact(on, devices) {
|
||||
if (!on) return fact(t("Where it runs"), h("span", { text: t("On this server") }))
|
||||
const d = (devices || []).find((x) => x.id === on.device)
|
||||
const name = d ? deviceLabel(d) : on.device_name || t("A linked computer")
|
||||
let state = ""
|
||||
if (devices && !d) state = t("Away now: a run waits for it, then counts as missed.")
|
||||
else if (d && d.scheduled === false) state = t("It does not accept scheduled work: turn on Accept scheduled work there.")
|
||||
const hint = [RUN_MODES[on.mode]?.() || on.mode, t("approvals wait %(n)s min", { n: on.approval_wait_minutes }), state].filter(Boolean).join(" · ")
|
||||
return h(
|
||||
"div",
|
||||
{ class: `fact${state ? " is-warning" : ""}` },
|
||||
h("span", { class: "fact__label", text: t("Where it runs") }),
|
||||
h("span", { class: "fact__value" }, h("bdi", { text: name }), h("span", { class: "fact__path", title: on.cwd }, h("bdi", { text: on.cwd }))),
|
||||
h("span", { class: "fact__hint", text: hint }),
|
||||
)
|
||||
}
|
||||
|
||||
function fact(label, value, hint) {
|
||||
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
||||
}
|
||||
@@ -448,7 +499,7 @@ function runRow(task, run) {
|
||||
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||
),
|
||||
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || (task.runs_on ? task.runs_on.device_name || "" : "") }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||
open || h("span", { class: "run__error", title: note, text: note }),
|
||||
detail,
|
||||
)
|
||||
@@ -488,7 +539,10 @@ async function drawForm(task) {
|
||||
const editing = !!task
|
||||
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
||||
clear($("scheduled-tools"))
|
||||
const smodels = (await schedulingModels())?.models || []
|
||||
const [sm, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||
const smodels = sm?.models || []
|
||||
// Offered where the server can run a schedule on a computer: its devices say whether they accept.
|
||||
const canPlace = !!devices && (devices.some((d) => "scheduled" in d) || !!task?.runs_on)
|
||||
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
||||
const draft = {
|
||||
request: task?.request || "",
|
||||
@@ -501,6 +555,7 @@ async function drawForm(task) {
|
||||
agents: task?.agents || null,
|
||||
notify: task?.notify || "matters",
|
||||
mode: task?.mode || "fresh",
|
||||
runs_on: task?.runs_on ? { device: task.runs_on.device, cwd: task.runs_on.cwd, mode: task.runs_on.mode, approval_wait_minutes: task.runs_on.approval_wait_minutes } : null,
|
||||
}
|
||||
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
||||
|
||||
@@ -555,6 +610,47 @@ async function drawForm(task) {
|
||||
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
||||
}
|
||||
|
||||
/** Where it runs: the server, or a linked computer that accepts scheduled work, its folder and mode. */
|
||||
const whereFields = () => {
|
||||
const on = draft.runs_on
|
||||
const listed = devices || []
|
||||
const where = h(
|
||||
"select",
|
||||
{
|
||||
id: "sched-where",
|
||||
onchange: (e) => {
|
||||
const id = e.target.value
|
||||
if (!id) draft.runs_on = null
|
||||
else {
|
||||
const mine = (ctx.thisDevices?.() || []).includes(id)
|
||||
const roots = ctx.status?.()?.remote?.roots || []
|
||||
draft.runs_on = { device: id, cwd: draft.runs_on?.cwd || (mine ? roots[0] || "" : ""), mode: draft.runs_on?.mode || "edit", approval_wait_minutes: draft.runs_on?.approval_wait_minutes || 30 }
|
||||
}
|
||||
drawSettings()
|
||||
settings.querySelector(draft.runs_on ? "#sched-cwd" : "#sched-where")?.focus()
|
||||
},
|
||||
},
|
||||
h("option", { value: "", text: t("On this server"), selected: !on }),
|
||||
listed.map((d) => h("option", { value: d.id, text: d.scheduled === false ? `${deviceLabel(d)} — ${t("does not accept scheduled work")}` : deviceLabel(d), disabled: d.scheduled === false && on?.device !== d.id, selected: on?.device === d.id })),
|
||||
on && !listed.some((d) => d.id === on.device) ? h("option", { value: on.device, text: `${task?.runs_on?.device_name || on.device} — ${t("not linked now")}`, selected: true }) : null,
|
||||
)
|
||||
const out = [field(t("Where it runs"), where, on ? t("A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).") : "", "sched-where")]
|
||||
if (!on) return out
|
||||
const cwd = h("input", { type: "text", id: "sched-cwd", value: on.cwd, placeholder: "/home/…", spellcheck: "false", oninput: (e) => (on.cwd = e.target.value.trim()) })
|
||||
const mode = h(
|
||||
"select",
|
||||
{ id: "sched-runmode", onchange: (e) => (on.mode = e.target.value) },
|
||||
Object.entries(RUN_MODES).map(([v, word]) => h("option", { value: v, text: word(), selected: on.mode === v })),
|
||||
)
|
||||
const wait = h("input", { type: "number", id: "sched-wait", min: "1", max: "1440", step: "1", value: String(on.approval_wait_minutes || 30), oninput: (e) => (on.approval_wait_minutes = Number(e.target.value) || 30) })
|
||||
out.push(
|
||||
field(t("Folder on the computer"), cwd, t("A full path, inside a folder the computer allows remote work in."), "sched-cwd"),
|
||||
field(t("Mode"), mode, t("Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it."), "sched-runmode"),
|
||||
field(t("An approval waits (minutes)"), wait, t("A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied."), "sched-wait"),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
const drawSettings = () => {
|
||||
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
||||
const select = (id, table, key, onChange) =>
|
||||
@@ -586,6 +682,7 @@ async function drawForm(task) {
|
||||
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||
)
|
||||
settings.replaceChildren(
|
||||
...(canPlace ? whereFields() : []),
|
||||
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||
@@ -656,6 +753,7 @@ async function drawForm(task) {
|
||||
notify: draft.notify,
|
||||
mode: draft.mode,
|
||||
}
|
||||
if (canPlace) body.runs_on = draft.runs_on ? { ...draft.runs_on } : null
|
||||
try {
|
||||
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
||||
go({ name: "task", id: r.schedule.id })
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
|
||||
// the server offers this chat now (web search among them) and the skills, each on or off for this
|
||||
// chat only, and the commands it was told to stop asking about.
|
||||
//
|
||||
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
|
||||
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
|
||||
// POST /chats/{chat}/allow/clear -> { scope }
|
||||
//
|
||||
// Narrowing only, on the server: switching on what the model, the permissions or the instance
|
||||
// left out reaches nothing. Labels, skill names and descriptions are set as text.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
import { get, post, why } from "./bridge.js"
|
||||
import { popover } from "./popup.js"
|
||||
|
||||
/** The switches of `chat`, in a popover under `anchor`. */
|
||||
export async function scopePopover(anchor, chat) {
|
||||
const path = `/chats/${encodeURIComponent(chat.id)}`
|
||||
let scope = null
|
||||
let error = ""
|
||||
let busy = false
|
||||
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
|
||||
|
||||
// Drawn again after each answer: the switch that was used keeps the focus.
|
||||
const redraw = (key) => {
|
||||
pop.redraw()
|
||||
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
|
||||
}
|
||||
const send = async (url, body) => {
|
||||
const key = document.activeElement?.dataset?.key || ""
|
||||
busy = true
|
||||
redraw(key)
|
||||
try {
|
||||
scope = (await post(url, body))?.scope || scope
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
busy = false
|
||||
redraw(key)
|
||||
}
|
||||
|
||||
const toggle = (kind, name, label, on) =>
|
||||
h(
|
||||
"label",
|
||||
{ class: "check scope__row", title: label.title || null },
|
||||
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
|
||||
h("span", { text: label.text }),
|
||||
)
|
||||
|
||||
function draw() {
|
||||
const body = h("div", { class: "pop__body scope" })
|
||||
if (!scope) {
|
||||
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
|
||||
return body
|
||||
}
|
||||
const families = scope.families || []
|
||||
const skills = scope.skills || []
|
||||
const allow = scope.allow || []
|
||||
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
|
||||
if (families.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Tools") }),
|
||||
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
|
||||
)
|
||||
}
|
||||
if (skills.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Skills") }),
|
||||
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
|
||||
)
|
||||
}
|
||||
if (allow.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Always allowed here") }),
|
||||
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
|
||||
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
|
||||
)
|
||||
}
|
||||
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
|
||||
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||
return body
|
||||
}
|
||||
|
||||
try {
|
||||
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
pop.redraw()
|
||||
}
|
||||
+123
-2
@@ -1,10 +1,13 @@
|
||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
||||
// sign-in), the agent and the folders it may work in, notifications, and this release.
|
||||
// sign-in), the agent and the folders it may work in, the language and theme, notifications, and
|
||||
// this release.
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen } from "./bridge.js"
|
||||
import { invoke, listen, get, openLink } from "./bridge.js"
|
||||
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
|
||||
import { why } from "./bridge.js"
|
||||
|
||||
let folderError = ""
|
||||
let current = () => null
|
||||
@@ -13,6 +16,7 @@ const SECTIONS = [
|
||||
["account", ICONS.user, () => t("Account")],
|
||||
["agent", ICONS.terminal, () => t("Agent")],
|
||||
["folders", ICONS.folder, () => t("Project folders")],
|
||||
["look", ICONS.contrast, () => t("Appearance")],
|
||||
["notify", ICONS.bell, () => t("Notifications")],
|
||||
["about", ICONS.info, () => t("About")],
|
||||
]
|
||||
@@ -118,6 +122,8 @@ export function renderSettings(s) {
|
||||
)
|
||||
}
|
||||
|
||||
box.append(lookSection())
|
||||
|
||||
box.append(
|
||||
section(
|
||||
"notify",
|
||||
@@ -130,6 +136,7 @@ export function renderSettings(s) {
|
||||
),
|
||||
)
|
||||
|
||||
const advised = recommendation(s)
|
||||
box.append(
|
||||
section(
|
||||
"about",
|
||||
@@ -139,8 +146,122 @@ export function renderSettings(s) {
|
||||
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||
),
|
||||
advised
|
||||
? line(
|
||||
t("Your server recommends LLeMbas Desktop %(version)s", { version: advised.version }),
|
||||
t("The release that goes with the server's version. This is %(current)s.", { current: s.version }),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Open the release page"), onclick: () => openLink(advised.page) }),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
if (s.account && appsFor !== s.server) askApps(s.server)
|
||||
}
|
||||
|
||||
// --- The release the server recommends (GET /apps) --------------------------------------------------
|
||||
//
|
||||
// GET /apps { server: { version, channel }, apps: { desktop: { version, tag, channel, page } | null } }
|
||||
//
|
||||
// Shown when it is newer than this app. A stable install is never offered a beta; a beta install
|
||||
// may be. Only the project's own release pages are opened.
|
||||
|
||||
let apps = null // the server's recommendation; null: none (an older server, or nothing newer)
|
||||
let appsFor = null // the server it was asked of
|
||||
const RELEASES = "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/"
|
||||
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/
|
||||
|
||||
/** A sortable key for `X.Y.Z` / `X.Y.Z-beta.N` (a release above its betas); null for anything else. */
|
||||
export function versionKey(text) {
|
||||
const m = VERSION.exec(String(text || "").trim())
|
||||
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? Infinity : +m[4]] : null
|
||||
}
|
||||
|
||||
function newer(a, b) {
|
||||
for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] > b[i]
|
||||
return false
|
||||
}
|
||||
|
||||
async function askApps(server) {
|
||||
appsFor = server
|
||||
apps = null
|
||||
try {
|
||||
const r = (await get("/apps"))?.apps?.desktop || null
|
||||
if (appsFor === server) apps = r
|
||||
} catch {
|
||||
apps = null
|
||||
}
|
||||
if (!$("settings").hidden) renderSettings(current())
|
||||
}
|
||||
|
||||
/** The recommended release, where it is newer than this one and on a channel this install takes. */
|
||||
function recommendation(s) {
|
||||
if (!apps) return null
|
||||
const mine = versionKey(s.version)
|
||||
const theirs = versionKey(apps.version)
|
||||
if (!mine || !theirs) return null
|
||||
if (theirs[3] !== Infinity && mine[3] === Infinity) return null
|
||||
if (!newer(theirs, mine)) return null
|
||||
const page = String(apps.page || "")
|
||||
if (!page.startsWith(RELEASES) || !/^[A-Za-z0-9._-]+$/.test(page.slice(RELEASES.length))) return null
|
||||
return { version: apps.version, page }
|
||||
}
|
||||
|
||||
let lookError = ""
|
||||
|
||||
/**
|
||||
* The language and the theme. The language: the account's (the default), this computer's, or one
|
||||
* the app has, kept here. The theme: the account's, saved to it; kept here where the server
|
||||
* cannot be told.
|
||||
*/
|
||||
function lookSection() {
|
||||
const named = (code) => LEMBAS_LANGS[code] || code
|
||||
const account = accountLanguage()
|
||||
const languages = [
|
||||
["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })],
|
||||
["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })],
|
||||
...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]),
|
||||
]
|
||||
const language = h(
|
||||
"select",
|
||||
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
|
||||
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
|
||||
)
|
||||
const scheme = (s) => (s === "light" ? t("light") : t("dark"))
|
||||
const offered = accountThemes()
|
||||
const themes = offered
|
||||
? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`])
|
||||
: [
|
||||
["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })],
|
||||
["moria", `Moria — ${t("dark")}`],
|
||||
["shire", `Shire — ${t("light")}`],
|
||||
]
|
||||
const chosen = offered ? accountTheme() : localTheme()
|
||||
const theme = h(
|
||||
"select",
|
||||
{
|
||||
id: "set-theme",
|
||||
"aria-label": t("Theme"),
|
||||
onchange: async (e) => {
|
||||
const el = e.target
|
||||
el.disabled = true
|
||||
try {
|
||||
await chooseTheme(el.value)
|
||||
lookError = ""
|
||||
} catch (err) {
|
||||
lookError = why(err)
|
||||
}
|
||||
renderSettings(current())
|
||||
},
|
||||
},
|
||||
themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })),
|
||||
)
|
||||
return section(
|
||||
"look",
|
||||
t("Appearance"),
|
||||
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
|
||||
line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme),
|
||||
lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null,
|
||||
)
|
||||
}
|
||||
|
||||
export function initSettings(getStatus) {
|
||||
|
||||
+4
-1
@@ -280,6 +280,9 @@ export async function showTerminal(on) {
|
||||
ctx.layout(on)
|
||||
markOpen()
|
||||
if (!on) return
|
||||
// Whether this showing is to open a shell: none was open as it began. One that ends meanwhile
|
||||
// (before the frame below) is said, not quietly replaced.
|
||||
const wanted = !session
|
||||
try {
|
||||
await load()
|
||||
} catch {
|
||||
@@ -291,7 +294,7 @@ export async function showTerminal(on) {
|
||||
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||
requestAnimationFrame(() => {
|
||||
refit()
|
||||
connect()
|
||||
if (wanted && terminalOpen()) connect()
|
||||
term.focus()
|
||||
})
|
||||
}
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
|
||||
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
|
||||
// `models` scope (src-tauri/src/server.rs):
|
||||
//
|
||||
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
|
||||
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
|
||||
//
|
||||
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
|
||||
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
|
||||
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
|
||||
|
||||
import { $, h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, why } from "./bridge.js"
|
||||
|
||||
let me = null
|
||||
|
||||
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||
/** Whether a message can be dictated here. */
|
||||
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
|
||||
/** Whether a reply can be read aloud here. */
|
||||
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
|
||||
|
||||
function say(text) {
|
||||
const box = $("thread-note")
|
||||
if (!box) return
|
||||
box.textContent = text
|
||||
box.hidden = !text
|
||||
}
|
||||
|
||||
// --- Dictate --------------------------------------------------------------------------------------
|
||||
|
||||
let recorder = null // the recording in progress
|
||||
let busy = false // a recording being turned into words
|
||||
|
||||
function drawMic() {
|
||||
const b = $("dictate")
|
||||
if (!b) return
|
||||
b.hidden = !canDictate()
|
||||
const on = !!recorder
|
||||
b.classList.toggle("is-recording", on)
|
||||
b.classList.toggle("is-busy", busy)
|
||||
b.disabled = busy
|
||||
b.setAttribute("aria-pressed", String(on))
|
||||
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
|
||||
b.setAttribute("aria-label", label)
|
||||
b.title = label
|
||||
}
|
||||
|
||||
async function start() {
|
||||
say("")
|
||||
let stream
|
||||
try {
|
||||
await invoke("voice_arm")
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (err) {
|
||||
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||
return
|
||||
}
|
||||
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
|
||||
let rec
|
||||
try {
|
||||
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
|
||||
} catch (err) {
|
||||
stream.getTracks().forEach((x) => x.stop())
|
||||
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
|
||||
rec.addEventListener("stop", async () => {
|
||||
stream.getTracks().forEach((x) => x.stop())
|
||||
recorder = null
|
||||
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
|
||||
if (!blob.size) return drawMic()
|
||||
busy = true
|
||||
drawMic()
|
||||
try {
|
||||
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
|
||||
intoComposer(text)
|
||||
} catch (err) {
|
||||
say(why(err))
|
||||
}
|
||||
busy = false
|
||||
drawMic()
|
||||
})
|
||||
recorder = rec
|
||||
rec.start()
|
||||
drawMic()
|
||||
}
|
||||
|
||||
/** The words heard, where the cursor is in the message box, a space either side where needed. */
|
||||
function intoComposer(text) {
|
||||
text = String(text || "").trim()
|
||||
if (!text) return
|
||||
const box = $("composer-text")
|
||||
const at = box.selectionStart ?? box.value.length
|
||||
const end = box.selectionEnd ?? at
|
||||
const before = box.value.slice(0, at)
|
||||
const after = box.value.slice(end)
|
||||
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
|
||||
box.value = before + words + after
|
||||
box.selectionStart = box.selectionEnd = before.length + words.length
|
||||
box.dispatchEvent(new Event("input"))
|
||||
box.focus()
|
||||
}
|
||||
|
||||
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
|
||||
export function stopDictating() {
|
||||
if (recorder?.state === "recording") recorder.stop()
|
||||
}
|
||||
|
||||
// --- Read aloud -----------------------------------------------------------------------------------
|
||||
|
||||
let playing = null // { id, audio, button }
|
||||
|
||||
/** A reply's words without the markdown around them, and without its code. */
|
||||
export function plain(markdown) {
|
||||
return String(markdown || "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
|
||||
.replace(/[*_`#>|~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stopReading() {
|
||||
if (!playing) return
|
||||
playing.audio?.pause()
|
||||
playing.button.setAttribute("aria-pressed", "false")
|
||||
playing.button.classList.remove("is-busy")
|
||||
playing = null
|
||||
}
|
||||
|
||||
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
|
||||
export function readAloudButton(id, text) {
|
||||
if (!canSpeak()) return null
|
||||
const label = t("Read this aloud")
|
||||
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
|
||||
b.addEventListener("click", async () => {
|
||||
if (playing?.id === id) return stopReading()
|
||||
stopReading()
|
||||
const words = plain(text())
|
||||
if (!words) return
|
||||
const mine = { id, audio: null, button: b }
|
||||
playing = mine
|
||||
b.setAttribute("aria-pressed", "true")
|
||||
b.classList.add("is-busy")
|
||||
try {
|
||||
const url = await invoke("speak", { text: words })
|
||||
if (playing !== mine) return
|
||||
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
|
||||
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
|
||||
mine.audio = new Audio(url)
|
||||
b.classList.remove("is-busy")
|
||||
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
|
||||
await mine.audio.play()
|
||||
} catch (err) {
|
||||
if (playing === mine) stopReading()
|
||||
say(why(err?.message ? { detail: err.message } : err))
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
|
||||
export function voiceFor(account) {
|
||||
me = account
|
||||
drawMic()
|
||||
}
|
||||
|
||||
export function initVoice() {
|
||||
const b = $("dictate")
|
||||
b.replaceChildren(icon(ICONS.mic))
|
||||
b.addEventListener("click", () => {
|
||||
if (busy) return
|
||||
if (recorder) recorder.stop()
|
||||
else start()
|
||||
})
|
||||
drawMic()
|
||||
}
|
||||
Reference in new issue
Block a user