Author SHA1 Message Date
HomerandClaude Opus 5.5 189f706c65 LLeMbas Desktop 1.0.0-beta.7: CLI 2.0.0-beta.7, the look and words of LLeMbas 2.0.0-beta.9
ci / ui (push) Waiting to run
ci / check (push) Failing after 4s
ci / start (push) Failing after 3s
release / linux (push) Failing after 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:08:46 +00:00
HomerandClaude Opus 5.5 2cdd4e5355 Draw a picture in a chat: force_tool image_generate on POST /chats and /messages
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
A switch among a chat's chips (not an agent chat's), offered where /me says
the server draws for the account, asks for the next message's reply to be a
picture; it goes off once the message is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:31:02 +00:00
HomerandClaude Opus 5.5 df27ca82fa Scheduled: where a task runs (runs_on), and choosing a linked computer in the form
ci / ui (push) Waiting to run
ci / check (push) Failing after 4s
ci / start (push) Failing after 4s
The task page says the computer, folder, mode and approval wait, and whether
the computer is away or does not accept scheduled work (/devices
`scheduled`); a run waiting for it says so. The form offers Where it runs
where the server's devices say whether they accept, sending runs_on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:24:24 +00:00
HomerandClaude Opus 5.5 3101552858 Settings › About: the Desktop release the server recommends (GET /apps)
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Offered when newer than this install, a beta only to a beta install, and
only with the project's own release page as the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:19:11 +00:00
HomerandClaude Opus 5.5 9b07527c3a Scheduled-result notifications: first lines, Open / Run again / Pause; a click opens
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
A run's own notification (run.notification, server dev) gives the body (the
result's first lines) and the actions with labels in the person's language;
they become the notification's action type. Every notification is
remembered by id (watch::Notes) so a click opens the chat or the task, and
Run again / Pause post /schedules/{id}/run|pause without the window. An
older server's run is said as before. Fixtures re-pinned at server dev
e0c2332, schedule.json added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:08:04 +00:00
HomerandClaude Opus 5.5 2b87fa4c29 Voice (dictate, read aloud) and Draw a picture over /v1, with the app's models scope
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
The server kept voice and images on /v1 (models scope, which the app's token
already asks for); /me's services and scopes decide what is offered. Rust
sends the recording to /v1/audio/transcriptions, reads /v1/audio/speech back
as a data: URL (CSP media-src data:), and for /v1/images/generations takes
only the id of each of the person's own files from the answer and fetches
/v1/files/<id>/content from the app's own server. The microphone is allowed
only within seconds of the page's own Dictate (voice_arm); WebKitGTK's
media stream is switched on through its settings getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:02:56 +00:00
HomerandClaude Opus 5.5 67ce6076c5 Library upload, new folder, rename and delete; a chat's tools and skills (/scope)
ci / ui (push) Waiting to run
ci / check (push) Failing after 4s
ci / start (push) Failing after 4s
Files -> Server writes through /api/v2/files/{upload,mkdir,rename} and
DELETE /files (the trash); upload is a Rust command (library_upload) that
picks files in the system dialog and sends them multipart in the field
`files`. A chat's menu opens Tools and skills..., the web's chat-settings
switches over GET/POST /chats/{id}/scope and /allow/clear. A popover can
open at a context menu's point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:53:38 +00:00
HomerandClaude Opus 5.5 d691a4dcfa Memory page and search inside chats, files and memory (Ctrl+K, GET /search)
ci / ui (push) Waiting to run
ci / check (push) Failing after 7s
ci / start (push) Failing after 5s
The account's memory as the web's Settings -> Memory keeps it: add, change,
remove, over /api/v2/memories. Ctrl+K asks the server's search once two
letters are typed and lists the hits below the titles, the matched ranges
drawn as <mark> elements over plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:44:49 +00:00
HomerandClaude Opus 5.5 be70e1a628 Pinned chats and chat folders in the sidebar
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s
The server's /api/v2 sets `pinned` on PATCH /chats/{id} and makes, renames and deletes
folders. The sidebar lists the pinned chats first, then the folders (nested, folded as the
server keeps them, each counting its chats), then the rest by day; searching lists every
match flat. A chat's menu pins it and moves it (to no folder, any folder, or a new one);
a folder's menu renames it, makes one inside it, deletes it (its chats stay). Tests F8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:24:34 +00:00
HomerandClaude Opus 5.5 04736db057 Language and theme follow the account: PATCH /me for the theme, /me's language by default
ci / ui (push) Waiting to run
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s
The server's /api/v2 now has PATCH /me {language, theme} and /me lists its languages and
themes. The theme is the account's and choosing one saves it there (a refusal is said);
a server whose /me lists no themes keeps the earlier choice in the app. The language is the
account's unless Settings chose this computer's or another (kept in the app, as the phone
does); a change of the account's opens the window again in it, once.

Fixtures re-pinned to the server's dev (f7f2ff2), with the new /api/v2 parity ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:19:44 +00:00
HomerandClaude Opus 5.5 151fc5fcca Settings: language and theme, kept in the app
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
ci / ui (push) Waiting to run
Appearance in Settings chooses the language (the system's, or one of the nine the app has;
the window opens again in it, its replies and shells let go first) and the theme (the
account's, or Moria or Shire, drawn at once and the window painted in it). Kept in
localStorage on this computer: /api/v2 has no PATCH /me for either yet, so the account's
own stay as they are. i18n.js reads the choice and carries each language's own name from
the server's catalogue. Tests E5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:14:36 +00:00
HomerandClaude Opus 5.5 d4a1f13cf1 Terminal: a shell that ends while its panel is shown again is not replaced
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
Showing the panel connects a frame later (the panel needs its width first). A shell that ended
before that frame -- reopened and closed by the server within one frame, as headless CI's slow
frames made happen -- was quietly replaced by a new one, against "no reconnect of its own".
The frame now connects only if no shell was open as the showing began. The cause of the
B3 failures on GitHub since 94e903e; a test makes it happen every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:14:23 +00:00
HomerandClaude Opus 5.5 effcc2c2fd tests/ui: a failure's annotation on GitHub names the line of run.mjs
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:07:48 +00:00
HomerandClaude Opus 5.5 97c2301bab A new agent chat on another linked computer (as the web and Android can)
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Where the account has another computer linked (GET /devices), a chip before the folder
chooses it; this computer stays 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. Tests D4;
failing UI tests are also GitHub annotations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:55:54 +00:00
HomerandClaude Opus 5.5 94e903ef70 Archived chats: a page to find them again, Restore chat, delete
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Archive was one-way in the app. Archived (sidebar, under Files) reads
GET /api/v2/chats?archived=true, newest first and paged; a row opens the
chat, Restore chat PATCHes archived:false and reads the sidebar again, and
the row's menu deletes. An open archived chat says so in its header, with
Restore chat there and in its menu. The words are the web's own ("Archived",
"Restore chat"). Tests: D2 at 1280 and 390 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:19:35 +00:00
54 changed files with 5558 additions and 119 deletions

No files matched your search

+64
View File
@@ -4,6 +4,70 @@ All notable changes to LLeMbas Desktop. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/). [Semantic Versioning](https://semver.org/).
## [1.0.0-beta.7] — 2026-11-10
LLeMbas CLI 2.0.0-beta.7 inside, the look and words of LLeMbas 2.0.0-beta.9.
### 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 ## [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. LLeMbas CLI 2.0.0-beta.6 inside, the look and words of LLeMbas 2.0.0-beta.8.
+1 -1
View File
@@ -1 +1 @@
2.0.0-beta.6 2.0.0-beta.7
+3 -1
View File
@@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}"
FROM="tests/fixtures/api_v2" FROM="tests/fixtures/api_v2"
OUT="tests/ui/fixtures" OUT="tests/ui/fixtures"
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json 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; } [ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \ commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
+547 -3
View File
@@ -445,6 +445,104 @@ KEYS = (
"Stop %(name)s and the subagents under it?", "Stop %(name)s and the subagents under it?",
"This device is not linked right now.", "This device is not linked right now.",
"Subagent %(name)s (%(id)s), %(position)s, asks:", "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 # 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", "Days": "Dny",
"Time": "Čas", "Time": "Čas",
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.", "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": { "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.", "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", "Days": "Tage",
"Time": "Uhrzeit", "Time": "Uhrzeit",
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.", "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": { "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.", "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", "Days": "Días",
"Time": "Hora", "Time": "Hora",
"No day ticked: every day.": "Ningún día marcado: todos los días.", "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": { "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.", "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", "Days": "Jours",
"Time": "Heure", "Time": "Heure",
"No day ticked: every day.": "Aucun jour coché : tous les jours.", "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": { "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.", "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", "Days": "Napok",
"Time": "Időpont", "Time": "Időpont",
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.", "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": { "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.", "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", "Days": "Giorni",
"Time": "Ora", "Time": "Ora",
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.", "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": { "pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", "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", "Days": "Dni",
"Time": "Godzina", "Time": "Godzina",
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.", "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": { "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.", "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", "Days": "Dni",
"Time": "Čas", "Time": "Čas",
"No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.", "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) messages = getattr(module, "MESSAGES", None)
if not isinstance(messages, dict): if not isinstance(messages, dict):
sys.exit(f"gen-i18n: {path} has no MESSAGES") sys.exit(f"gen-i18n: {path} has no MESSAGES")
NAMES[lang] = str(getattr(module, "NAME", "") or lang)
return messages return messages
NAMES: dict[str, str] = {} # each language's own name for itself, as the server's catalogue says
def main() -> None: def main() -> None:
server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas")) 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]] = {} 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") 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) 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 = ( out = (
"// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:\n" "// 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" "// 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" "// script's own table. Do not edit; run the script.\n"
"//\n" "//\n"
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\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" "// missing entry is the English. The language is the account's (kept from /me as\n"
"// English unless it is one of these. Elements marked `data-i18n` (their text) and\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" "// `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" "// are translated once the page is parsed; this script is deferred, before the page's.\n"
'"use strict"\n' '"use strict"\n'
"\n" "\n"
f"const LEMBAS_I18N = {body}\n" f"const LEMBAS_I18N = {body}\n"
"\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' ' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n' ' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
"})()\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" "document.documentElement.lang = LEMBAS_LANG\n"
"\n" "\n"
"function t(text, fields) {\n" "function t(text, fields) {\n"
+2 -1
View File
@@ -2135,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "llembas-desktop" name = "llembas-desktop"
version = "1.0.0-beta.6" version = "1.0.0-beta.7"
dependencies = [ dependencies = [
"libc", "libc",
"serde", "serde",
@@ -2149,6 +2149,7 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"tungstenite", "tungstenite",
"ureq", "ureq",
"webkit2gtk",
] ]
[[package]] [[package]]
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "llembas-desktop" name = "llembas-desktop"
version = "1.0.0-beta.6" version = "1.0.0-beta.7"
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own" description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
authors = ["Jaroslav Beneš"] authors = ["Jaroslav Beneš"]
license = "MIT" license = "MIT"
@@ -32,6 +32,11 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true
+287 -9
View File
@@ -36,13 +36,14 @@ use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::webview::{PermissionKind, PermissionResponse};
use tauri::window::Color; use tauri::window::Color;
use tauri::{ use tauri::{
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry, WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
}; };
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
@@ -122,6 +123,9 @@ struct App {
watch: watch::Watch, watch: watch::Watch,
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else. /// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
dropped: Mutex<Vec<PathBuf>>, 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 { struct TrayItems {
@@ -153,6 +157,13 @@ impl App {
Some((server.app, account.token)) 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. /// The folders the agent may use: what the explorer and the viewer may read.
fn roots(&self) -> Vec<String> { fn roots(&self) -> Vec<String> {
self.remote 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()))? .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 /// 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. /// first word of "continue in a terminal" for an agent chat on this computer.
#[tauri::command] #[tauri::command]
@@ -690,6 +772,75 @@ async fn attach_dropped(
.map_err(|e| ApiError::new(0, e.to_string()))? .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). /// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
fn percent_decode(text: &str) -> String { fn percent_decode(text: &str) -> String {
let b = text.as_bytes(); let b = text.as_bytes();
@@ -1180,7 +1331,39 @@ fn build_main(handle: &AppHandle) -> tauri::Result<WebviewWindow> {
.decorations(false) .decorations(false)
.background_color(theme.background()) .background_color(theme.background())
.on_navigation(allowed) .on_navigation(allowed)
.on_permission_request(permission)
.build() .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. /// 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 { } else {
d.title.clone() d.title.clone()
}; };
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
let _ = handle let _ = handle
.notification() .notification()
.builder() .builder()
.id(id)
.title(title) .title(title)
.body(if d.agent { &words.agent } else { &words.reply }) .body(if d.agent { &words.agent } else { &words.reply })
.show(); .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 /// How long to wait before following the event stream again: after it closed or broke off, and
/// (longer) on a server that has none. /// (longer) on a server that has none.
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5); const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
@@ -1520,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
} else { } else {
r.title r.title
}; };
let body = match r.told { let body = r.body.clone().unwrap_or_else(|| {
watch::Told::Result => &words.scheduled, match r.told {
watch::Told::Failed => &words.scheduled_failed, watch::Told::Result => &words.scheduled,
watch::Told::Pause => &words.scheduled_pause, watch::Told::Failed => &words.scheduled_failed,
}; watch::Told::Pause => &words.scheduled_pause,
let _ = handle }
.clone()
});
let id = app.watch.notes.add(watch::Note::Run {
schedule: r.schedule.clone(),
run: r.run.clone(),
});
let mut note = handle
.notification() .notification()
.builder() .builder()
.id(id)
.title(title) .title(title)
.body(body) .body(body);
.show(); 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 true
}); });
@@ -1640,6 +1906,11 @@ pub fn run() {
sealed_open, sealed_open,
sealed_close, sealed_close,
attach_pick, attach_pick,
library_upload,
voice_arm,
transcribe,
speak,
draw,
attach_dropped, attach_dropped,
attach_bytes, attach_bytes,
set_theme, set_theme,
@@ -1700,6 +1971,7 @@ pub fn run() {
sealed: sealed::Pages::default(), sealed: sealed::Pages::default(),
watch: watch::Watch::default(), watch: watch::Watch::default(),
dropped: Mutex::new(Vec::new()), dropped: Mutex::new(Vec::new()),
mic_until: Mutex::new(None),
}); });
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still // Without a tray (no AppIndicator library, no StatusNotifier host) the app still
// starts: the title bar's menu has every item the tray has. // starts: the title bar's menu has every item the tray has.
@@ -1711,6 +1983,12 @@ pub fn run() {
watch_updates(handle.clone()); watch_updates(handle.clone());
watch_chats(handle.clone()); watch_chats(handle.clone());
watch_events(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(); let h = handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
+268 -11
View File
@@ -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`. /// The body of a `multipart/form-data` request with one file in the field `field`.
fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec<u8> { 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); let mut body = Vec::with_capacity(bytes.len() + 256);
body.extend_from_slice( body.extend_from_slice(
format!( 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) header_name(name)
) )
.as_bytes(), .as_bytes(),
@@ -685,6 +690,224 @@ pub fn upload(
} else { } else {
format!("/attachments?chat={chat}") 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() let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos()) .map(|d| d.as_nanos())
@@ -698,13 +921,9 @@ pub fn upload(
"content-type", "content-type",
&format!("multipart/form-data; boundary={boundary}"), &format!("multipart/form-data; boundary={boundary}"),
) )
.send_bytes(&multipart(&boundary, name, bytes))?; .send_bytes(&multipart(&boundary, field, name, bytes))?;
let v: Value = res res.into_json()
.into_json() .map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
v.get("attachment")
.cloned()
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
} }
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame. /// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
@@ -957,9 +1176,45 @@ mod tests {
assert_eq!(base64(&[0xfb, 0xff]), "+/8="); 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] #[test]
fn a_file_goes_as_one_multipart_field() { 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(); let text = String::from_utf8(body).unwrap();
assert!(text.starts_with( assert!(text.starts_with(
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n" "--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!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
assert_eq!(header_name(" "), "file"); assert_eq!(header_name(" "), "file");
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf"); 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] #[test]
+172 -4
View File
@@ -15,8 +15,15 @@
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`: //! 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; //! `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 //! `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 //! whether to pause it. A server that made the run's notification (`run.notification`, server
//! the stream answers 404, and nothing is said. //! 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::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -90,6 +97,8 @@ pub struct Watch {
runs: Mutex<Option<HashMap<String, (String, bool)>>>, 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. /// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
stop: Mutex<Arc<AtomicBool>>, 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. /// How a scheduled task's run is told.
@@ -108,6 +117,95 @@ pub enum Told {
pub struct Ran { pub struct Ran {
pub title: String, pub title: String,
pub told: Told, 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. /// 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), .unwrap_or(0),
); );
if let Some(told) = told { if let Some(told) = told {
let (body, actions) = run_notification(run);
out.push(Ran { out.push(Ran {
title: row title: row
.get("title") .get("title")
@@ -219,6 +318,10 @@ impl Watch {
.unwrap_or("") .unwrap_or("")
.to_string(), .to_string(),
told, told,
schedule: id.to_string(),
run: run_id.to_string(),
body,
actions,
}); });
} }
} }
@@ -259,13 +362,75 @@ mod tests {
})).collect::<Vec<_>>()}) })).collect::<Vec<_>>()})
} }
fn ran(id: &str, told: Told) -> Ran { fn ran(id: &str, run: &str, told: Told) -> Ran {
Ran { Ran {
title: format!("Task {id}"), title: format!("Task {id}"),
told, 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] #[test]
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() { fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
let w = Watch::default(); let w = Watch::default();
@@ -290,7 +455,10 @@ mod tests {
("c", "matters", 0, Some(("r4", "silent", false))), ("c", "matters", 0, Some(("r4", "silent", false))),
("d", "never", 0, Some(("r5", "ok", 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. // The same list again (the server told a device meanwhile): not said twice.
assert!(w assert!(w
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))])) .look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
+1 -1
View File
@@ -10,7 +10,7 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [], "windows": [],
"security": { "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": [ "dangerousDisableAssetCspModification": [
"style-src" "style-src"
] ]
+1 -1
View File
@@ -1 +1 @@
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2 LLeMbas v2.0.0-beta.9 (3b7da5fc7ac72d5de9229919793dd299c03da6a8) tests/fixtures/api_v2
+20
View File
@@ -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"
}
}
}
+15
View File
@@ -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"
}
}
}
+28
View File
@@ -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"
}
+29
View File
@@ -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
}
+110
View File
@@ -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"
}
]
}
+22
View File
@@ -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"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"folder": {
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
}
}
+20
View File
@@ -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
}
]
}
+57
View File
@@ -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"
}
]
}
+21
View File
@@ -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"
}
]
}
+13
View File
@@ -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"
}
}
+34
View File
@@ -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"
}
+107
View File
@@ -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"
}
}
+98
View File
@@ -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"
}
}
+52
View File
@@ -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*."
}
+26
View File
@@ -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 *"
]
}
}
+69
View File
@@ -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
}
]
}
]
}
+6 -2
View File
@@ -100,8 +100,12 @@
return "" return ""
case "terminal_word": case "terminal_word":
return "lembas" return "lembas"
default: default: {
return null // 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))
}
} }
} }
+901
View File
@@ -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, []) 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 () => { 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: {} } })) const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
await openChatRow(p, "chat2") await openChatRow(p, "chat2")
@@ -822,6 +841,886 @@ test("C4: the tree at 390 px stays inside the window", async () => {
assert.deepEqual(p.errors, []) 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 ---------------------------------------------------------------------------------------- // --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || "" const only = process.argv[2] || ""
@@ -835,6 +1734,8 @@ for (const { name, fn } of tests) {
} catch (err) { } catch (err) {
failed++ failed++
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`) 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() await browser.close()
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"tag": "v2.0.0-beta.8", "tag": "v2.0.0-beta.9",
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f", "commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"sha256": "6fd6d9d530c005dc52ad7dfcd61b3515b29955aa6d55365d35df3936fb7f29da" "sha256": "46d010bb862b06345c05f726cd1e6ae8415866324bfb0a55b8642ce986a9e769"
} }
+131 -2
View File
@@ -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 { 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-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; }
.chatrow__state.is-new { background: var(--leaf); } .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; } } @keyframes pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } } @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); box-shadow: var(--shadow-lg);
} }
.dialog::backdrop { background: var(--scrim); } .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__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); } .dialog__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. */ /* 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__icon { display: grid; place-items: center; color: var(--ink-muted); }
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; } .palette__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); } .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__files { justify-content: start; }
.composer__bar { .composer__bar {
display: grid; 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); gap: var(--sp-2);
align-items: center; 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); } .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. */ /* 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; } .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__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__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; } .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__where select { width: 100%; height: var(--control-h-sm); }
.files__tree { min-height: 0; } .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); } .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__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); } .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); } .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. */ /* 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 + .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; } .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); } .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. */ /* 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 { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
.sched__desc.is-error { color: var(--warning); } .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; } .findings__item { min-width: 0; overflow-wrap: anywhere; }
.ask__subagent { color: var(--ink); } .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 ---------------------------------------------------------------------- */ /* --- Narrower windows ---------------------------------------------------------------------- */
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */ /* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
+732 -3
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -53,6 +53,8 @@
<div class="side__nav"> <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-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-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> </div>
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul> <ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
<div class="side__foot"> <div class="side__foot">
@@ -82,6 +84,7 @@
<div class="composer__bar"> <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> <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> <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="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> <button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
</div> </div>
@@ -107,8 +110,11 @@
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label> <section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="files__head"> <div class="files__head">
<div class="files__where muted" id="server-where"></div> <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> <button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div> </div>
<p class="status" id="server-status" hidden></p>
<div class="files__tree" id="server-tree"></div> <div class="files__tree" id="server-tree"></div>
</section> </section>
</div> </div>
@@ -123,6 +129,24 @@
<div class="page__body" id="scheduled-body"></div> <div class="page__body" id="scheduled-body"></div>
</section> </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"> <section class="page settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head"> <header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1> <h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
+135
View File
@@ -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
}
+7
View File
@@ -125,3 +125,10 @@ export async function openTerminal(target, cols, rows, { onOutput, onControl, on
export function openLink(url) { export function openLink(url) {
return invoke("open_external", { url }).catch(() => {}) 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
View File
@@ -17,9 +17,12 @@ import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubag
import { readyIds, sending, sent, discard } from "./attach.js" import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js" import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.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 { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.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"] const MODES = ["manual", "edit", "auto", "plan"]
@@ -41,12 +44,46 @@ function deviceOf(id) {
return (ctx.devices?.() || []).find((d) => d.id === id) || null 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. */ /** This computer's device: the one linked right now, else the last one it was. */
function draftDevice() { function hereDevice() {
const mine = ctx.thisDevices() const mine = ctx.thisDevices()
return mine.find((id) => deviceOf(id)) || mine[0] || "" 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 * 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 * 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 if (draft.kind !== "agent" || !draft.dir) return null
const device = deviceOf(draftDevice()) const device = deviceOf(draftDevice())
if (!device?.terminal) return null 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 const chat = open?.chat
if (chat?.kind !== "agent" || !chat.device) return null if (chat?.kind !== "agent" || !chat.device) return null
@@ -256,6 +293,7 @@ function assistantMessage(m, chat) {
"div", "div",
{ class: "msg__actions" }, { class: "msg__actions" },
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))), 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). // 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"), 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. // "Continue in a terminal" stays in the chat's menu.
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton()) 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(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))) 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"), 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) { function modeChip(value, onChange, disabled) {
return chip({ return chip({
ico: ICONS.shield, 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 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. */ /** 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 model = models.find((m) => m.id === draft.model)
const parts = [] const parts = []
if (draft.kind === "agent") { if (draft.kind === "agent") {
const roots = s?.remote?.enabled ? s.remote.roots : [] const others = otherDevices()
if (!roots.includes(draft.dir)) draft.dir = roots[0] || "" if (others.length) parts.push(deviceChip(others))
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips()))) 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()))) parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
} }
const shell = terminalTarget() const shell = terminalTarget()
@@ -862,6 +994,8 @@ async function renderChips() {
renderChips() renderChips()
}, mayPick)) }, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips()))) 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) })) 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. // An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : "" 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.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)) 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 }))) 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)) box.replaceChildren(...parts.filter(Boolean))
const offer = await teamOffered(chat) const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
@@ -920,7 +1056,7 @@ async function renderChips() {
function renderNotice() { function renderNotice() {
const box = $("link-notice") const box = $("link-notice")
const s = ctx.status() 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" const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
box.hidden = !show box.hidden = !show
if (show) box.replaceChildren(agentBlock(s, { compact: !draft })) if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
@@ -945,9 +1081,14 @@ export function chatMenu(anchor, chat) {
const base = ctx.status()?.server const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }] 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 (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)}`) }) 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) }) if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items) 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) { async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") }) const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return 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) { async function deleteChat(chat) {
const yes = await ask({ const yes = await ask({
title: t("Delete this chat?"), title: t("Delete this chat?"),
@@ -1116,7 +1294,7 @@ export function closeChat() {
// --- A new chat ------------------------------------------------------------------------------------ // --- 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" let lastKind = "agent"
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */ /** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
@@ -1178,7 +1356,9 @@ function renderHello() {
h( h(
"p", "p",
{ class: "hello__sub" }, { 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("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") }), : 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 models = ctx.models()
const start = models.find((m) => m.default) || models[0] const start = models.find((m) => m.default) || models[0]
lastKind = kind || lastKind 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")) clear($("messages"))
renderCards({ id: "" }, []) renderCards({ id: "" }, [])
alertIn("") alertIn("")
@@ -1221,14 +1401,20 @@ async function createChat(text, attachments) {
if (agents && (agents.min || agents.max)) body.agents = agents if (agents && (agents.min || agents.max)) body.agents = agents
if (draft.kind === "agent") { if (draft.kind === "agent") {
const device = draftDevice() const device = draftDevice()
if (!device || !agentReady(ctx.status())) { if (draftHere()) {
renderNotice() if (!device || !agentReady(ctx.status())) {
throw { detail: agentState(ctx.status()).sentence } 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" }) 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 draft = null
$("new-chat").hidden = true $("new-chat").hidden = true
$("main").dataset.new = "false" $("main").dataset.new = "false"
@@ -1265,9 +1451,11 @@ async function submit() {
try { try {
if (draft) await createChat(text, files) if (draft) await createChat(text, files)
else if (open?.chat?.id) { else if (open?.chat?.id) {
const body = { text } let body = { text }
if (files.length) body.attachments = files 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) const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
pictureSent()
if (r.message) $("messages").append(userMessage(r.message)) if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id) if (r.reply) follower(open.chat, r.reply.id)
} else return } else return
+5
View File
@@ -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"], 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", 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"], 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"], 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"],
} }
+86
View File
@@ -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
View File
@@ -3,7 +3,8 @@
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way. // 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 { $, 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" 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): // 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. */ /** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount" 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) clear(box)
const ul = h("ul", { class: "tree", role: "tree" }) const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul) 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) => { const level = async (parent, path, depth) => {
levels.set(path, { ul: parent, depth })
let entries let entries
try { try {
entries = await list(path) 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) }), h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
) )
row.style.setProperty("--depth", String(depth)) row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row) const 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)) { if (isDir(e)) {
let kids = null let kids = null
row.addEventListener("click", async () => { const expand = async (opening) => {
const opening = row.getAttribute("aria-expanded") !== "true"
row.setAttribute("aria-expanded", String(opening)) row.setAttribute("aria-expanded", String(opening))
if (!opening) { if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
kids?.remove() kids?.remove()
kids = null kids = null
return return
} }
opened.add(e.path)
kids = h("ul", { role: "group" }) kids = h("ul", { role: "group" })
li.append(kids) li.append(kids)
await level(kids, e.path, depth + 1) 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)) } else row.addEventListener("click", () => open(e))
parent.append(li) 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 ------------------------------------------------------------------------------ // --- This computer ------------------------------------------------------------------------------
@@ -99,7 +126,7 @@ function renderLocal() {
head.append(pick) head.append(pick)
} }
const root = local.root const root = local.root
tree(box, { return tree(box, {
rootPath: "", rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries, list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => { open: async (e) => {
@@ -125,12 +152,96 @@ export async function readServer(path) {
return get(SERVER_FILES.read(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() { export function renderServer() {
const box = $("server-tree") const box = $("server-tree")
tree(box, { serverSaid("")
server = tree(box, {
rootPath: "/", 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), 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-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh)) $("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer) $("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").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal) $("local-refresh").addEventListener("click", renderLocal)
} }
+156 -9
View File
@@ -1,15 +1,23 @@
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under // The sidebar's chats: agent chats and chats on the server in one list, newest first. The
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works // pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the
// on another device of the account says so in its title. The search line above narrows the list // server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything. // 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 { $, 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 rows = []
let more = false let more = false
let query = "" let query = ""
let folders = [] // { id, name, parent_id, collapsed, position }
/** Lower case, without diacritics: "Príliš" is found by "prilis". */ /** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) { function fold(text) {
@@ -22,9 +30,10 @@ function fold(text) {
/** The list again, from the server. */ /** The list again, from the server. */
export async function refreshList() { export async function refreshList() {
try { 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 || [] rows = r.chats || []
more = !!r.more more = !!r.more
folders = f.folders || []
render() render()
} catch (err) { } catch (err) {
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(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) 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) { function isToday(stamp) {
const d = new Date(stamp) const d = new Date(stamp)
if (Number.isNaN(d.getTime())) return false 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.") })) list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
return return
} }
const today = visible.filter((r) => isToday(r.updated_at)) // Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest.
const earlier = visible.filter((r) => !isToday(r.updated_at)) 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 (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 (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 }))) 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
View File
@@ -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
View File
@@ -10,21 +10,26 @@ import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js" import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings, scrollToSection } from "./settings.js" import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.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 { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.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 { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js" import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
import { initAgent, link } from "./agent.js" import { initAgent, link } from "./agent.js"
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.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 { closePopover } from "./popup.js"
import { noteSubagentsCap } from "./models.js" import { noteSubagentsCap } from "./models.js"
let status = null let status = null
let view = "" // "setup" | "app" let view = "" // "setup" | "app"
let page = "chat" // "chat" | "files" | "scheduled" | "settings" let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "memory" | "settings"
let me = null let me = null
let models = [] let models = []
let devices = [] // this account's linked devices let devices = [] // this account's linked devices
@@ -63,14 +68,6 @@ function thisDevices() {
return remembered.slice() 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 ------------------------------------------------------------------------------------ // --- Views ------------------------------------------------------------------------------------
function route() { function route() {
@@ -98,8 +95,8 @@ async function startApp() {
started = true started = true
try { try {
me = await get("/me") me = await get("/me")
applyTheme(me.theme) noteMe(me)
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" }) voiceFor(me)
} catch (err) { } catch (err) {
$("thread-note").textContent = why(err) $("thread-note").textContent = why(err)
$("thread-note").hidden = false $("thread-note").hidden = false
@@ -154,7 +151,7 @@ function accountButton() {
// --- The pages ---------------------------------------------------------------------------------- // --- 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) { function showPage(name) {
page = name page = name
closePopover() closePopover()
@@ -162,6 +159,8 @@ function showPage(name) {
$("main").hidden = name !== "chat" $("main").hidden = name !== "chat"
$("page-files").hidden = name !== "files" $("page-files").hidden = name !== "files"
$("page-scheduled").hidden = name !== "scheduled" $("page-scheduled").hidden = name !== "scheduled"
$("page-archived").hidden = name !== "archived"
$("page-memory").hidden = name !== "memory"
$("settings").hidden = name !== "settings" $("settings").hidden = name !== "settings"
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name)) for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
if (name !== "chat") { if (name !== "chat") {
@@ -209,6 +208,16 @@ function showSettings(on, section) {
if (section) scrollToSection(section) if (section) scrollToSection(section)
} }
function showArchivedPage() {
showPage("archived")
showArchived()
}
function showMemoryPage(focus) {
showPage("memory")
showMemory(focus || "")
}
function showFiles(which) { function showFiles(which) {
showPage("files") showPage("files")
if (which) showFilesTab(which) if (which) showFilesTab(which)
@@ -223,8 +232,11 @@ function commands() {
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) }, { label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
...scheduledCommands(), ...scheduledCommands(),
{ label: t("Files"), run: () => showFiles() }, { label: t("Files"), run: () => showFiles() },
{ label: t("Archived"), run: showArchivedPage },
{ label: t("Memory"), run: () => showMemoryPage() },
{ label: t("Settings"), run: () => showSettings(true) }, { 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 (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 (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 }) }) 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) showChat(id)
}, },
menu: (anchor, c) => chatMenu(anchor, c), menu: (anchor, c) => chatMenu(anchor, c),
note: (text) => {
$("thread-note").textContent = text
$("thread-note").hidden = !text
},
}) })
initVoice()
initChat({ initChat({
status: () => status, status: () => status,
thisDevices, thisDevices,
@@ -291,8 +308,19 @@ async function boot() {
}, },
}) })
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false }) initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
initPalette({ commands, open: (c) => showChat(c.id) }) initArchived({
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") }) 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) => { onViewer((on) => {
$("shell").dataset.viewer = String(on) $("shell").dataset.viewer = String(on)
}) })
@@ -326,6 +354,14 @@ async function boot() {
}) })
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder)) $("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
$("nav-files").addEventListener("click", () => showFiles()) $("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() accountButton()
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings")) $("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
$("settings-close").replaceChildren(icon(ICONS.close)) $("settings-close").replaceChildren(icon(ICONS.close))
@@ -374,6 +410,16 @@ async function boot() {
listen("open-settings", () => { listen("open-settings", () => {
if (view === "app") showSettings(true) 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") status = await invoke("get_status")
applyTheme(status.theme) applyTheme(status.theme)
renderBar(status) renderBar(status)
+159
View File
@@ -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
View File
@@ -1,12 +1,16 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands, // 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 // the chats of both kinds by title, newest first, and below them what the server's search finds
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere // inside them, in the file library and in memory (`GET /search`, once two letters are typed and
// is there. // 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 { h, clear, icon, ICONS, when } from "./dom.js"
import { get } from "./bridge.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 let dlg = null
/** Lower case, without diacritics: "Príliš" is found by "prilis". */ /** Lower case, without diacritics: "Príliš" is found by "prilis". */
@@ -17,6 +21,22 @@ function fold(text) {
.toLowerCase() .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) { function matches(text, words) {
const f = fold(text) const f = fold(text)
return words.every((w) => f.includes(w)) return words.every((w) => f.includes(w))
@@ -31,7 +51,11 @@ export async function openPalette() {
let chats = [] let chats = []
let shown = [] let shown = []
let at = 0 let at = 0
let found = [] // the server's hits for `asked`
let asked = ""
let timer = 0
const close = () => { const close = () => {
clearTimeout(timer)
if (!dlg) return if (!dlg) return
dlg.close() dlg.close()
dlg.remove() dlg.remove()
@@ -44,16 +68,32 @@ export async function openPalette() {
const draw = () => { const draw = () => {
const words = fold(input.value).split(/\s+/).filter(Boolean) const words = fold(input.value).split(/\s+/).filter(Boolean)
const commands = ctx.commands().filter((c) => matches(c.label, words)) 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 = [ shown = [
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })), ...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
...found.map((c) => ({ ...titled.map((c) => ({
kind: c.kind, kind: c.kind,
label: c.title || t("New chat"), label: c.title || t("New chat"),
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`, hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
run: () => ctx.open(c), 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)) at = Math.min(at, Math.max(0, shown.length - 1))
clear(list) clear(list)
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") })) 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__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)),
h("span", { class: "palette__label", text: item.label }), 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 }), h("span", { class: "palette__hint", text: item.hint }),
), ),
) )
@@ -87,9 +129,29 @@ export async function openPalette() {
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "") input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" }) 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", () => { input.addEventListener("input", () => {
at = 0 at = 0
draw() draw()
search()
}) })
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+4 -3
View File
@@ -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 * 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). * { 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() draw()
document.body.append(el) document.body.append(el)
const place = () => { 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 r = el.getBoundingClientRect()
const left = align === "end" ? at.right - r.width : at.left 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` 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` el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
} }
place() place()
openPop = { el, back: anchor, onClose } openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose }
document.addEventListener("pointerdown", outsidePop, true) document.addEventListener("pointerdown", outsidePop, true)
document.addEventListener("keydown", escPop, true) document.addEventListener("keydown", escPop, true)
el.querySelector("button, input, select")?.focus() el.querySelector("button, input, select")?.focus()
+103 -5
View File
@@ -27,6 +27,15 @@
// by the app (Rust, watch.rs), handed here as the // by the app (Rust, watch.rs), handed here as the
// `schedules` event // `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. // 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 // 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. // 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 { avatar, levelsOf, levelWord } from "./models.js"
import { markdown } from "./markdown.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 view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
let tasks = [] let tasks = []
let missing = false // the server has no scheduling routes let missing = false // the server has no scheduling routes
@@ -83,7 +92,7 @@ const RUNS_PAGE = 20
function statusChip(run, running) { function statusChip(run, running) {
if (running) return h("span", { class: "schip is-running", text: STATUS.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") }) 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. // 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 })}` : "" 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}` }) 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), 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")) const body = clear($("scheduled-body"))
body.append( body.append(
h( 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("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("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })), 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), 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 || []), 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) { 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) 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) }), h("span", { text: stamp(run.started_at || run.due_at) }),
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null, 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 }), open || h("span", { class: "run__error", title: note, text: note }),
detail, detail,
) )
@@ -488,7 +539,10 @@ async function drawForm(task) {
const editing = !!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" })) 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")) 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). // What will be saved: the rule only from the server's reading of words (or the task's own).
const draft = { const draft = {
request: task?.request || "", request: task?.request || "",
@@ -501,6 +555,7 @@ async function drawForm(task) {
agents: task?.agents || null, agents: task?.agents || null,
notify: task?.notify || "matters", notify: task?.notify || "matters",
mode: task?.mode || "fresh", 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 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) 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 drawSettings = () => {
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model) const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
const select = (id, table, key, onChange) => 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 })), 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( 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("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("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"), 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, notify: draft.notify,
mode: draft.mode, mode: draft.mode,
} }
if (canPlace) body.runs_on = draft.runs_on ? { ...draft.runs_on } : null
try { try {
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body) const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
go({ name: "task", id: r.schedule.id }) go({ name: "task", id: r.schedule.id })
+91
View File
@@ -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
View File
@@ -1,10 +1,13 @@
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone // 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 // 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 { $, 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 { 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 folderError = ""
let current = () => null let current = () => null
@@ -13,6 +16,7 @@ const SECTIONS = [
["account", ICONS.user, () => t("Account")], ["account", ICONS.user, () => t("Account")],
["agent", ICONS.terminal, () => t("Agent")], ["agent", ICONS.terminal, () => t("Agent")],
["folders", ICONS.folder, () => t("Project folders")], ["folders", ICONS.folder, () => t("Project folders")],
["look", ICONS.contrast, () => t("Appearance")],
["notify", ICONS.bell, () => t("Notifications")], ["notify", ICONS.bell, () => t("Notifications")],
["about", ICONS.info, () => t("About")], ["about", ICONS.info, () => t("About")],
] ]
@@ -118,6 +122,8 @@ export function renderSettings(s) {
) )
} }
box.append(lookSection())
box.append( box.append(
section( section(
"notify", "notify",
@@ -130,6 +136,7 @@ export function renderSettings(s) {
), ),
) )
const advised = recommendation(s)
box.append( box.append(
section( section(
"about", "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 }) : "", 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") }), 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) { export function initSettings(getStatus) {
+4 -1
View File
@@ -280,6 +280,9 @@ export async function showTerminal(on) {
ctx.layout(on) ctx.layout(on)
markOpen() markOpen()
if (!on) return 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 { try {
await load() await load()
} catch { } 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. // A frame later: the panel has only just been shown and has no width to fit to yet.
requestAnimationFrame(() => { requestAnimationFrame(() => {
refit() refit()
connect() if (wanted && terminalOpen()) connect()
term.focus() term.focus()
}) })
} }
+180
View File
@@ -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()
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* LLeMbas v2.0.0-beta.8 (d06ca353b801) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit. /* LLeMbas v2.0.0-beta.9 (3b7da5fc7ac7) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
The font-face rules are left out; the app bundles no font. */ The font-face rules are left out; the app bundles no font. */
/* /*
Design tokens. Design tokens.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"tag": "v2.0.0-beta.8", "tag": "v2.0.0-beta.9",
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f", "commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"files": { "files": {
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"}, "cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"}, "cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},