Compare commits
14
Commits
v1.0.0-beta.6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cdd4e5355
|
||
|
|
df27ca82fa
|
||
|
|
3101552858
|
||
|
|
9b07527c3a
|
||
|
|
2b87fa4c29
|
||
|
|
67ce6076c5
|
||
|
|
d691a4dcfa
|
||
|
|
be70e1a628
|
||
|
|
04736db057
|
||
|
|
151fc5fcca
|
||
|
|
d4a1f13cf1
|
||
|
|
effcc2c2fd
|
||
|
|
97c2301bab
|
||
|
|
94e903ef70
|
No files matched your search
@@ -4,6 +4,68 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Archived chats.** Archive chat was one-way: an archived chat left the sidebar and could not be
|
||||||
|
found again in the app. Archived, under Files in the sidebar, lists them newest first; each opens
|
||||||
|
as any chat does, goes back into the sidebar with Restore chat, or is deleted. An archived chat
|
||||||
|
that is open says so in its header, with Restore chat beside it and in its menu.
|
||||||
|
- **An agent chat on another computer.** A new agent chat could only work on this computer. Where
|
||||||
|
the account has another computer linked, a chip before the folder chooses it (this computer is
|
||||||
|
the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal
|
||||||
|
before the first message opens there too.
|
||||||
|
- **Language and theme in Settings.** Under Appearance. The language is the account's, as the
|
||||||
|
server says it, unless this computer's or another of the app's nine is chosen (kept in the app;
|
||||||
|
the window opens again in it). The theme is the account's, and choosing one saves it there, so
|
||||||
|
the web interface and the phone follow; on a server that cannot be told, it is kept in the app.
|
||||||
|
- **Pinned chats and chat folders.** The sidebar lists the pinned chats first, then the account's
|
||||||
|
folders, nested and folded as the server keeps them, then the rest by day. A chat's menu pins
|
||||||
|
it and moves it into a folder or a new one; a folder's menu renames it, makes one inside it, or
|
||||||
|
deletes it (its chats stay, back in the list). The search line still lists every match.
|
||||||
|
- **Memory.** A page in the sidebar (and in Ctrl+K) lists what the account's models remember about
|
||||||
|
the person, as the web's Settings → Memory does: add a record (into a data group, where there is
|
||||||
|
more than one), change it, remove it. A record a model noted says so.
|
||||||
|
- **Search inside.** Ctrl+K finds more than titles: once two letters are typed it asks the
|
||||||
|
server's search, and lists below the titles what it found inside chats, in the file library and
|
||||||
|
in memory, each with the words around the match, the match marked. A chat opens, a file opens
|
||||||
|
in the viewer, a memory opens the Memory page at it. A server without search finds titles only.
|
||||||
|
- **The server's files can be changed.** On Files → Server, Upload (files chosen in the system's
|
||||||
|
dialog) and New folder work in the home folder, and each folder's menu does the same inside it;
|
||||||
|
a file's or folder's menu renames it or moves it to the trash. What the server refuses is said
|
||||||
|
in the pane, in its words.
|
||||||
|
- **A chat's tools and skills.** Tools and skills… in a chat's menu switches the tool families
|
||||||
|
(web search among them) and the skills on or off for that chat only, as the web's chat settings
|
||||||
|
do, and lists the commands it was told to always allow, with Ask me about these again.
|
||||||
|
- **Voice.** Dictate, beside Send, records a message and writes down what was said where the
|
||||||
|
cursor is, through the server's speech to text; a reply's Read this aloud reads it in the
|
||||||
|
account's own voice (without its code), and stops when pressed again. Each shows only where the
|
||||||
|
server has it on for the account. The microphone is allowed only right after Dictate is pressed.
|
||||||
|
- **Draw a picture.** From Ctrl+K, where the server can draw for the account: a prompt and a
|
||||||
|
shape, and the picture under it. Only the account's own pictures on its own server are shown.
|
||||||
|
- **A scheduled task's notification says what it found.** Where the server makes the run's
|
||||||
|
notification, it carries the task's name and the result's first lines (or the failure said),
|
||||||
|
with Open and Run again (and Pause, when a task has failed three times in a row), labelled in
|
||||||
|
the account's language. Open, or a click on the notification, opens the task; Run again and
|
||||||
|
Pause do it without opening the window. A chat's notification, clicked, opens that chat.
|
||||||
|
- **The release your server recommends.** Settings › About names the Desktop release that goes
|
||||||
|
with the server's version when it is newer than this one (a beta only to a beta install), with
|
||||||
|
a link to its release page.
|
||||||
|
- **Scheduled tasks on a linked computer.** A task's page says where it runs: on the server, or
|
||||||
|
on a computer, in a folder and mode, with how long an approval waits, and whether that computer
|
||||||
|
is away or does not accept scheduled work. A run waiting for its computer says so. The form
|
||||||
|
offers "Where it runs" where the server can: this computer or another linked one that accepts
|
||||||
|
scheduled work, its folder (this computer's agent folder to begin with), mode and wait.
|
||||||
|
- **A picture as the reply, in a chat.** Where the server draws for the account, a Draw a picture
|
||||||
|
switch beside the model asks for the next message's reply to be a picture drawn from it — in a
|
||||||
|
new chat (its first message) as in an open one. It goes off once the message is sent.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A terminal that ended could come back by itself.** Shown again and ended by the server within
|
||||||
|
one frame, the panel opened a new shell in its place; an ended shell is now only said.
|
||||||
|
|
||||||
## [1.0.0-beta.6] — 2026-11-10
|
## [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.
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Generated
+1
@@ -2149,6 +2149,7 @@ dependencies = [
|
|||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"webkit2gtk",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)))]))
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
LLeMbas origin/dev (e0c2332d688f69e5a7efe157f8263eac02ac28d6) tests/fixtures/api_v2
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"version": "2.1.0-beta.3",
|
||||||
|
"channel": "beta"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"android": {
|
||||||
|
"version": "1.1.0-beta.2",
|
||||||
|
"tag": "v1.1.0-beta.2",
|
||||||
|
"channel": "beta",
|
||||||
|
"page": "https://github.com/LLeMbas/LLeMbas-Android/releases/tag/v1.1.0-beta.2"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"tag": "v1.0.1",
|
||||||
|
"channel": "stable",
|
||||||
|
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"channel": "stable"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"android": null,
|
||||||
|
"desktop": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"tag": "v1.0.1",
|
||||||
|
"channel": "stable",
|
||||||
|
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "chat1",
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": false,
|
||||||
|
"mode": "",
|
||||||
|
"effort": "low",
|
||||||
|
"device": null,
|
||||||
|
"device_session_id": null,
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false,
|
||||||
|
"cap": 32
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"chats": [
|
||||||
|
{
|
||||||
|
"id": "chat1",
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": false,
|
||||||
|
"mode": "",
|
||||||
|
"effort": "low",
|
||||||
|
"device": null,
|
||||||
|
"device_session_id": null,
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": null,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"more": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"version": "8bee85746004dc19",
|
||||||
|
"schedules": [
|
||||||
|
{
|
||||||
|
"id": "sched1",
|
||||||
|
"title": "Waybread check",
|
||||||
|
"instruction": "Check the waybread is wrapped.",
|
||||||
|
"request": "every second Monday at 7:30",
|
||||||
|
"rule": {
|
||||||
|
"at": {
|
||||||
|
"nth": [
|
||||||
|
{
|
||||||
|
"n": 2,
|
||||||
|
"weekday": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"times": [
|
||||||
|
"07:30"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "On the second Monday of each month at 07:30",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"enabled": true,
|
||||||
|
"next_run": "2026-01-12T07:30:00+00:00",
|
||||||
|
"next_runs": [
|
||||||
|
"2026-01-12T07:30:00+00:00",
|
||||||
|
"2026-02-09T07:30:00+00:00",
|
||||||
|
"2026-03-09T07:30:00+00:00",
|
||||||
|
"2026-04-13T07:30:00+00:00",
|
||||||
|
"2026-05-11T07:30:00+00:00"
|
||||||
|
],
|
||||||
|
"target": "chat",
|
||||||
|
"mode": "fresh",
|
||||||
|
"notify": "matters",
|
||||||
|
"catch_up": "one",
|
||||||
|
"grace_minutes": null,
|
||||||
|
"model": "qwen",
|
||||||
|
"effort": "high",
|
||||||
|
"agents": null,
|
||||||
|
"tools": [
|
||||||
|
"files",
|
||||||
|
"recall",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"memory",
|
||||||
|
"skills",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"origin": "user",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"fired_count": 3,
|
||||||
|
"failure_streak": 0,
|
||||||
|
"watch": null,
|
||||||
|
"runs_on": null,
|
||||||
|
"last_error": null,
|
||||||
|
"last_run": {
|
||||||
|
"id": "run1",
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"status": "ok",
|
||||||
|
"timing": "late",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"waiting_for": null,
|
||||||
|
"retry_at": null,
|
||||||
|
"progress": null,
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "GPT-OSS",
|
||||||
|
"reason": "switched",
|
||||||
|
"switched_from": "Qwen"
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 1200,
|
||||||
|
"completion": 340
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"message_id": "msg2",
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "All the waybread is wrapped.",
|
||||||
|
"error": null,
|
||||||
|
"notified": true,
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"running": false,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"path": "/home/frodo",
|
||||||
|
"parent": "/home",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "recipes",
|
||||||
|
"path": "/home/frodo/recipes",
|
||||||
|
"kind": "dir",
|
||||||
|
"size": 0,
|
||||||
|
"mime": "",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lembas.md",
|
||||||
|
"path": "/home/frodo/lembas.md",
|
||||||
|
"kind": "file",
|
||||||
|
"size": 27,
|
||||||
|
"mime": "text/markdown",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"folder": {
|
||||||
|
"id": "fold1",
|
||||||
|
"name": "Waybread",
|
||||||
|
"parent_id": null,
|
||||||
|
"kind": "",
|
||||||
|
"position": 0,
|
||||||
|
"collapsed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "fold1",
|
||||||
|
"name": "Waybread",
|
||||||
|
"parent_id": null,
|
||||||
|
"kind": "",
|
||||||
|
"position": 0,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fold2",
|
||||||
|
"name": "Recipes",
|
||||||
|
"parent_id": "fold1",
|
||||||
|
"kind": "chat",
|
||||||
|
"position": 1,
|
||||||
|
"collapsed": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"api": 2,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"account": {
|
||||||
|
"id": "user1",
|
||||||
|
"name": "Frodo",
|
||||||
|
"email": "frodo@shire.test"
|
||||||
|
},
|
||||||
|
"language": "sk",
|
||||||
|
"theme": "shire",
|
||||||
|
"default_model": "qwen",
|
||||||
|
"permissions": {
|
||||||
|
"chat.create": true,
|
||||||
|
"chat.delete": false,
|
||||||
|
"chat.model_select": true,
|
||||||
|
"chat.system_prompt": true,
|
||||||
|
"chat.params": true,
|
||||||
|
"files.upload": true,
|
||||||
|
"tools.agent": true,
|
||||||
|
"audio.transcribe": true,
|
||||||
|
"audio.listen": true
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"stt": true,
|
||||||
|
"tts": false,
|
||||||
|
"search": true,
|
||||||
|
"fetch": true,
|
||||||
|
"images": false
|
||||||
|
},
|
||||||
|
"scopes": [
|
||||||
|
"app",
|
||||||
|
"models"
|
||||||
|
],
|
||||||
|
"language_setting": "sk",
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": "en",
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sk",
|
||||||
|
"name": "Slovenčina"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"id": "moria",
|
||||||
|
"label": "Moria",
|
||||||
|
"scheme": "dark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shire",
|
||||||
|
"label": "Shire",
|
||||||
|
"scheme": "light"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"memories": [
|
||||||
|
{
|
||||||
|
"id": "mem1",
|
||||||
|
"content": "Prefers lembas warm.",
|
||||||
|
"author": "user",
|
||||||
|
"data_group": {
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"memory": {
|
||||||
|
"id": "mem1",
|
||||||
|
"content": "Prefers lembas warm.",
|
||||||
|
"author": "user",
|
||||||
|
"data_group": {
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"run_id": "run1",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"status": "ok",
|
||||||
|
"visibility": "private",
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"ask": "",
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"pause_label": "Pause",
|
||||||
|
"title": "LLeMbas",
|
||||||
|
"body": "A scheduled task has finished.",
|
||||||
|
"url": "/scheduled/sched1?run=run1",
|
||||||
|
"kind": "schedule"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"run_id": "run3",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"status": "failed",
|
||||||
|
"visibility": "private",
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again",
|
||||||
|
"pause"
|
||||||
|
],
|
||||||
|
"ask": "pause",
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Failed 3 times in a row. Pause it?",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Failed"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again",
|
||||||
|
"pause"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again",
|
||||||
|
"pause": "Pause"
|
||||||
|
},
|
||||||
|
"ask": "pause"
|
||||||
|
},
|
||||||
|
"pause_label": "Pause",
|
||||||
|
"title": "LLeMbas",
|
||||||
|
"body": "A scheduled task failed 3 times in a row. Pause it?",
|
||||||
|
"url": "/scheduled/sched1?run=run3",
|
||||||
|
"kind": "schedule_failed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"schedule": {
|
||||||
|
"id": "sched1",
|
||||||
|
"title": "Waybread check",
|
||||||
|
"instruction": "Check the waybread is wrapped.",
|
||||||
|
"request": "every second Monday at 7:30",
|
||||||
|
"rule": {
|
||||||
|
"at": {
|
||||||
|
"nth": [
|
||||||
|
{
|
||||||
|
"n": 2,
|
||||||
|
"weekday": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"times": [
|
||||||
|
"07:30"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "On the second Monday of each month at 07:30",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"enabled": true,
|
||||||
|
"next_run": "2026-01-12T07:30:00+00:00",
|
||||||
|
"next_runs": [
|
||||||
|
"2026-01-12T07:30:00+00:00",
|
||||||
|
"2026-02-09T07:30:00+00:00",
|
||||||
|
"2026-03-09T07:30:00+00:00",
|
||||||
|
"2026-04-13T07:30:00+00:00",
|
||||||
|
"2026-05-11T07:30:00+00:00"
|
||||||
|
],
|
||||||
|
"target": "chat",
|
||||||
|
"mode": "fresh",
|
||||||
|
"notify": "matters",
|
||||||
|
"catch_up": "one",
|
||||||
|
"grace_minutes": null,
|
||||||
|
"model": "qwen",
|
||||||
|
"effort": "high",
|
||||||
|
"agents": null,
|
||||||
|
"tools": [
|
||||||
|
"files",
|
||||||
|
"recall",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"memory",
|
||||||
|
"skills",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"origin": "user",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"fired_count": 3,
|
||||||
|
"failure_streak": 0,
|
||||||
|
"watch": null,
|
||||||
|
"runs_on": null,
|
||||||
|
"last_error": null,
|
||||||
|
"last_run": {
|
||||||
|
"id": "run1",
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"status": "ok",
|
||||||
|
"timing": "late",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"waiting_for": null,
|
||||||
|
"retry_at": null,
|
||||||
|
"progress": null,
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "GPT-OSS",
|
||||||
|
"reason": "switched",
|
||||||
|
"switched_from": "Qwen"
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 1200,
|
||||||
|
"completion": 340
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"message_id": "msg2",
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "All the waybread is wrapped.",
|
||||||
|
"error": null,
|
||||||
|
"notified": true,
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"running": false,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"schedule": {
|
||||||
|
"id": "sched3",
|
||||||
|
"title": "Nightly tests",
|
||||||
|
"instruction": "Run the tests.",
|
||||||
|
"request": "every night at 02:00",
|
||||||
|
"rule": {
|
||||||
|
"every": {
|
||||||
|
"minutes": 1440
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Every 1 day",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"enabled": true,
|
||||||
|
"next_run": "2026-01-03T02:00:00+00:00",
|
||||||
|
"next_runs": [],
|
||||||
|
"target": "chat",
|
||||||
|
"mode": "fresh",
|
||||||
|
"notify": "matters",
|
||||||
|
"catch_up": "one",
|
||||||
|
"grace_minutes": null,
|
||||||
|
"model": null,
|
||||||
|
"effort": null,
|
||||||
|
"agents": null,
|
||||||
|
"tools": [
|
||||||
|
"files",
|
||||||
|
"recall",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"memory",
|
||||||
|
"skills",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"origin": "user",
|
||||||
|
"chat_id": "chat2",
|
||||||
|
"fired_count": 3,
|
||||||
|
"failure_streak": 0,
|
||||||
|
"watch": {
|
||||||
|
"kind": "command",
|
||||||
|
"command": "git log -1 --format=%H",
|
||||||
|
"timeout_s": 30,
|
||||||
|
"target": "git log -1 --format=%H",
|
||||||
|
"changed_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
},
|
||||||
|
"runs_on": {
|
||||||
|
"device": "tok1",
|
||||||
|
"device_name": "bag-end",
|
||||||
|
"cwd": "/home/frodo/waybread",
|
||||||
|
"mode": "edit",
|
||||||
|
"approval_wait_minutes": 30
|
||||||
|
},
|
||||||
|
"last_error": null,
|
||||||
|
"last_run": {
|
||||||
|
"id": "run5",
|
||||||
|
"schedule_id": "sched3",
|
||||||
|
"status": "waiting",
|
||||||
|
"timing": "on_time",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"waiting_for": "device",
|
||||||
|
"retry_at": "2026-01-02T03:09:05+00:00",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"reason": "Waiting for bag-end to be linked.",
|
||||||
|
"in": 0,
|
||||||
|
"attempt": 1
|
||||||
|
},
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": null,
|
||||||
|
"name": null,
|
||||||
|
"reason": null,
|
||||||
|
"switched_from": null
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 0,
|
||||||
|
"completion": 0
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": null,
|
||||||
|
"message_id": null,
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "",
|
||||||
|
"error": {
|
||||||
|
"class": "device_away",
|
||||||
|
"text": "Waiting for bag-end to be linked."
|
||||||
|
},
|
||||||
|
"notified": false,
|
||||||
|
"notification": null,
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"running": false,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"run": {
|
||||||
|
"id": "run1",
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"status": "ok",
|
||||||
|
"timing": "late",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"waiting_for": null,
|
||||||
|
"retry_at": null,
|
||||||
|
"progress": null,
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "GPT-OSS",
|
||||||
|
"reason": "switched",
|
||||||
|
"switched_from": "Qwen"
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 1200,
|
||||||
|
"completion": 340
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"message_id": "msg2",
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "All the waybread is wrapped.",
|
||||||
|
"error": null,
|
||||||
|
"notified": true,
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"text": "All the waybread is *wrapped*."
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"scope": {
|
||||||
|
"families": [
|
||||||
|
{
|
||||||
|
"gate": "notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"on": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "web",
|
||||||
|
"label": "Web search",
|
||||||
|
"on": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "baking",
|
||||||
|
"description": "Bake waybread.",
|
||||||
|
"on": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"bash:ls *"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"q": "lembas",
|
||||||
|
"in": "all",
|
||||||
|
"offered": [
|
||||||
|
"chats",
|
||||||
|
"files",
|
||||||
|
"memories"
|
||||||
|
],
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"kind": "chats",
|
||||||
|
"more": true,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"snippet": "…how long does lembas keep & travel?",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
15,
|
||||||
|
21
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false,
|
||||||
|
"id": "chat1",
|
||||||
|
"agent": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "files",
|
||||||
|
"more": false,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"title": "bread.md",
|
||||||
|
"snippet": "/home/frodo/bread.md — Lembas recipe",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
23,
|
||||||
|
29
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false,
|
||||||
|
"path": "/home/frodo/bread.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "memories",
|
||||||
|
"more": false,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "memory",
|
||||||
|
"id": "mem1",
|
||||||
|
"title": "",
|
||||||
|
"snippet": "Prefers lembas warm.",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
14
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -100,8 +100,12 @@
|
|||||||
return ""
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+131
-2
@@ -431,6 +431,34 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
.chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); }
|
.chatrow__state { 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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Archived chats: the ones put away from the sidebar with Archive chat, newest first. Each can be
|
||||||
|
// opened (read, and written to, as any chat), taken back into the sidebar (Restore chat), or deleted.
|
||||||
|
//
|
||||||
|
// GET /chats?archived=true&limit=&before= { chats: [chat], more }
|
||||||
|
// PATCH /chats/{id} { archived: false } { chat }
|
||||||
|
// DELETE /chats/{id} 204
|
||||||
|
//
|
||||||
|
// A chat's title is the person's or a model's words, set as text.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { get, patch, del, why } from "./bridge.js"
|
||||||
|
import { ask, menu } from "./popup.js"
|
||||||
|
import { folderName } from "./list.js"
|
||||||
|
|
||||||
|
let ctx = null // { open(id), changed(), me() }
|
||||||
|
let rows = []
|
||||||
|
let more = false
|
||||||
|
let error = ""
|
||||||
|
let loading = false
|
||||||
|
|
||||||
|
/** The page, read again from the server. */
|
||||||
|
export async function showArchived() {
|
||||||
|
loading = true
|
||||||
|
error = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
const r = await get("/chats?archived=true&limit=50")
|
||||||
|
rows = r.chats || []
|
||||||
|
more = !!r.more
|
||||||
|
} catch (err) {
|
||||||
|
rows = []
|
||||||
|
more = false
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(button) {
|
||||||
|
const last = rows[rows.length - 1]
|
||||||
|
if (!last) return
|
||||||
|
button.disabled = true
|
||||||
|
try {
|
||||||
|
const r = await get(`/chats?archived=true&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||||
|
rows = rows.concat(r.chats || [])
|
||||||
|
more = !!r.more
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back into the sidebar. */
|
||||||
|
export async function unarchive(chat) {
|
||||||
|
try {
|
||||||
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: false })
|
||||||
|
rows = rows.filter((x) => x.id !== chat.id)
|
||||||
|
error = ""
|
||||||
|
render()
|
||||||
|
ctx?.changed(r?.chat || chat)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
render()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(chat) {
|
||||||
|
const yes = await ask({
|
||||||
|
title: t("Delete this chat?"),
|
||||||
|
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
|
||||||
|
ok: t("Delete"),
|
||||||
|
cancel: t("Cancel"),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/chats/${encodeURIComponent(chat.id)}`)
|
||||||
|
rows = rows.filter((x) => x.id !== chat.id)
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(c) {
|
||||||
|
const agent = c.kind === "agent"
|
||||||
|
const title = c.title || t("New chat")
|
||||||
|
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||||
|
const sub = [agent && c.device?.directory ? folderName(c.device.directory) : "", when(c.updated_at)].filter(Boolean).join(" · ")
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: "arow", dataset: { id: c.id } },
|
||||||
|
h("span", { class: `arow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: "arow__open", title, onclick: () => ctx.open(c.id) },
|
||||||
|
h("span", { class: "arow__title" }, h("bdi", { text: title })),
|
||||||
|
h("span", { class: "arow__sub", text: sub }),
|
||||||
|
),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm arow__unarchive", text: t("Restore chat"), onclick: () => unarchive(c) }),
|
||||||
|
mayDelete
|
||||||
|
? h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool arow__more",
|
||||||
|
"aria-label": t("More"),
|
||||||
|
title: t("More"),
|
||||||
|
onclick: (e) => menu(e.currentTarget, [{ label: t("Delete chat"), danger: true, run: () => remove(c) }]),
|
||||||
|
},
|
||||||
|
icon(ICONS.more),
|
||||||
|
)
|
||||||
|
: h("span", { class: "arow__more" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const box = clear($("archived-body"))
|
||||||
|
const parts = []
|
||||||
|
if (error) parts.push(h("p", { class: "status is-error", text: error }))
|
||||||
|
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("No archived chats.") }))
|
||||||
|
if (rows.length) {
|
||||||
|
parts.push(h("ul", { class: "arch", "aria-label": t("Archived") }, rows.map(row)))
|
||||||
|
if (more) parts.push(h("button", { type: "button", class: "btn btn--sm arch__more", text: t("More"), onclick: (e) => loadMore(e.currentTarget) }))
|
||||||
|
}
|
||||||
|
box.append(h("div", { class: "arch__page" }, parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initArchived(context) {
|
||||||
|
ctx = context
|
||||||
|
}
|
||||||
@@ -125,3 +125,10 @@ export async function openTerminal(target, cols, rows, { onOutput, onControl, on
|
|||||||
export function openLink(url) {
|
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
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Draw a picture: the instance's own image generation (`POST /v1/images/generations`, its
|
||||||
|
// workflow and default checkpoint), from Ctrl+K. Offered only where `GET /me` says the account may
|
||||||
|
// (`services.images`) and the token holds `models`.
|
||||||
|
//
|
||||||
|
// The pictures are shown only as Rust read them: each one of the person's own files on this
|
||||||
|
// server (`/v1/files/<id>/content`, fetched by id from the app's own server, never from an
|
||||||
|
// address the answer named), handed over as a `data:` URL of an image type -- checked again here
|
||||||
|
// before it becomes an <img>.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, why } from "./bridge.js"
|
||||||
|
|
||||||
|
const PICTURE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/
|
||||||
|
|
||||||
|
/** Whether pictures can be drawn here, for the account `me` (`GET /me`). */
|
||||||
|
export function canDraw(me) {
|
||||||
|
const models = !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||||
|
return !!me?.services?.images && models && me?.permissions?.["tools.image"] !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog: a prompt, a shape, Draw, and the pictures under it. */
|
||||||
|
export function openDraw() {
|
||||||
|
const prompt = h("textarea", { class: "textarea draw__prompt", rows: "3", placeholder: t("What to draw…"), "aria-label": t("What to draw…") })
|
||||||
|
const size = h(
|
||||||
|
"select",
|
||||||
|
{ class: "draw__size", "aria-label": t("Shape") },
|
||||||
|
h("option", { value: "1024x1024", text: t("Square") }),
|
||||||
|
h("option", { value: "1344x768", text: t("Wide") }),
|
||||||
|
h("option", { value: "768x1344", text: t("Tall") }),
|
||||||
|
)
|
||||||
|
const status = h("p", { class: "status", role: "status", hidden: true })
|
||||||
|
const out = h("div", { class: "draw__out" })
|
||||||
|
const go = h("button", { type: "submit", class: "btn btn--primary", text: t("Draw") })
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{ class: "dialog__body draw", method: "dialog" },
|
||||||
|
h("h2", { class: "dialog__title" }, icon(ICONS.picture), h("span", { text: t("Draw a picture") })),
|
||||||
|
prompt,
|
||||||
|
h("div", { class: "draw__bar" }, size, h("button", { type: "button", class: "btn", text: t("Close"), onclick: () => close() }), go),
|
||||||
|
status,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
const dlg = h("dialog", { class: "dialog dialog--wide" }, form)
|
||||||
|
let asked = 0
|
||||||
|
const close = () => {
|
||||||
|
asked++
|
||||||
|
dlg.close()
|
||||||
|
dlg.remove()
|
||||||
|
}
|
||||||
|
const said = (text, error = false) => {
|
||||||
|
status.hidden = !text
|
||||||
|
status.textContent = text
|
||||||
|
status.className = `status${error ? " is-error" : ""}`
|
||||||
|
status.setAttribute("role", error ? "alert" : "status")
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const words = prompt.value.trim()
|
||||||
|
if (!words) return prompt.focus()
|
||||||
|
const mine = ++asked
|
||||||
|
go.disabled = true
|
||||||
|
said(t("Drawing… a picture can take a minute or two."))
|
||||||
|
try {
|
||||||
|
const urls = await invoke("draw", { prompt: words, size: size.value })
|
||||||
|
if (mine !== asked) return
|
||||||
|
const pictures = (urls || []).filter((u) => PICTURE.test(u))
|
||||||
|
if (!pictures.length) throw { detail: t("The server's answer had no picture in it.") }
|
||||||
|
out.prepend(...pictures.map((src) => h("figure", { class: "draw__pic" }, h("img", { src, alt: words }), h("figcaption", { text: words }))))
|
||||||
|
said("")
|
||||||
|
} catch (err) {
|
||||||
|
if (mine === asked) said(why(err), true)
|
||||||
|
} finally {
|
||||||
|
if (mine === asked) go.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prompt.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
dlg.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
document.body.append(dlg)
|
||||||
|
dlg.showModal()
|
||||||
|
prompt.focus()
|
||||||
|
}
|
||||||
+126
-11
@@ -3,7 +3,8 @@
|
|||||||
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
// 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
@@ -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
@@ -0,0 +1,121 @@
|
|||||||
|
// The app's language and theme, as Settings chooses them.
|
||||||
|
//
|
||||||
|
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||||
|
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||||
|
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||||
|
// `t()` as it is needed, so a new language opens the window again.
|
||||||
|
//
|
||||||
|
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||||
|
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||||
|
|
||||||
|
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||||
|
import { terminalThemeChanged } from "./terminal.js"
|
||||||
|
|
||||||
|
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||||
|
let me = null // /me, as last read
|
||||||
|
|
||||||
|
function read(key) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || ""
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key, value) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value)
|
||||||
|
else localStorage.removeItem(key)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||||
|
function known(code) {
|
||||||
|
const c = String(code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[-_]/)[0]
|
||||||
|
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||||
|
export function accountThemes() {
|
||||||
|
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||||
|
export function localTheme() {
|
||||||
|
const name = read("lembas.theme")
|
||||||
|
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's theme, as the server says it. */
|
||||||
|
export function accountTheme() {
|
||||||
|
return me?.theme || "moria"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||||
|
export function applyTheme(name) {
|
||||||
|
name = localTheme() || name
|
||||||
|
if (!LOOKS.includes(name)) name = "moria"
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.theme = name
|
||||||
|
root.dataset.base = name
|
||||||
|
terminalThemeChanged()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||||
|
* it is now another, the window opens again in it.
|
||||||
|
*/
|
||||||
|
export function noteMe(next) {
|
||||||
|
me = next || null
|
||||||
|
paint()
|
||||||
|
if (!me || !me.language) return
|
||||||
|
const account = known(me.language)
|
||||||
|
const before = read("lembas.account-language")
|
||||||
|
write("lembas.account-language", account)
|
||||||
|
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||||
|
if (read("lembas.account-language") !== account) return
|
||||||
|
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||||
|
* server cannot be told, kept here ("" goes back to the account's).
|
||||||
|
*/
|
||||||
|
export async function chooseTheme(name) {
|
||||||
|
if (accountThemes()) {
|
||||||
|
const r = await patch("/me", { theme: name })
|
||||||
|
me = r && r.theme ? r : { ...me, theme: name }
|
||||||
|
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||||
|
paint()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||||
|
export function chosenLanguage() {
|
||||||
|
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||||
|
export function accountLanguage() {
|
||||||
|
return me?.language ? known(me.language) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose the language; the window opens again in it. */
|
||||||
|
export function chooseLanguage(choice) {
|
||||||
|
if (choice === chosenLanguage()) return
|
||||||
|
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||||
|
return reopen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the window again: the replies followed and the shells open let go first. */
|
||||||
|
async function reopen() {
|
||||||
|
await letGoAll()
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
+62
-16
@@ -10,21 +10,26 @@ import { renderBar, initBar } from "./titlebar.js"
|
|||||||
import { renderSetup, initSetup } from "./setup.js"
|
import { 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
@@ -0,0 +1,159 @@
|
|||||||
|
// Memory: what the account's models remember about the person, as the web's Settings → Memory
|
||||||
|
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
|
||||||
|
// group chosen (where there is more than one).
|
||||||
|
//
|
||||||
|
// GET /memories { memories: [memory], groups: [{ id, name }] }
|
||||||
|
// POST /memories { content, data_group_id? } -> { memory }
|
||||||
|
// PATCH /memories/{id} { content } -> { memory }
|
||||||
|
// DELETE /memories/{id} 204
|
||||||
|
//
|
||||||
|
// A record may be a model's words (`author` other than "user"): set as text, never markup.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { get, post, patch, del, why } from "./bridge.js"
|
||||||
|
import { ask, menu } from "./popup.js"
|
||||||
|
|
||||||
|
let rows = []
|
||||||
|
let groups = []
|
||||||
|
let error = ""
|
||||||
|
let loading = false
|
||||||
|
let editing = "" // the record being changed
|
||||||
|
let mark = "" // the record to bring into view (from a search hit)
|
||||||
|
|
||||||
|
/** The page, read again from the server; `focus` a record to bring into view. */
|
||||||
|
export async function showMemory(focus = "") {
|
||||||
|
mark = focus
|
||||||
|
loading = true
|
||||||
|
error = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
const r = await get("/memories")
|
||||||
|
rows = r.memories || []
|
||||||
|
groups = r.groups || []
|
||||||
|
} catch (err) {
|
||||||
|
rows = []
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(form) {
|
||||||
|
const text = form.querySelector("textarea")
|
||||||
|
const content = text.value.trim()
|
||||||
|
if (!content) return text.focus()
|
||||||
|
const group = form.querySelector("select")?.value
|
||||||
|
form.querySelector("button[type=submit]").disabled = true
|
||||||
|
try {
|
||||||
|
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
|
||||||
|
if (r?.memory) rows = [r.memory, ...rows]
|
||||||
|
error = ""
|
||||||
|
text.value = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
$("memory-new")?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(m, content) {
|
||||||
|
content = content.trim()
|
||||||
|
if (!content || content === m.content) {
|
||||||
|
editing = ""
|
||||||
|
return render()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
|
||||||
|
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
|
||||||
|
editing = ""
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(m) {
|
||||||
|
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/memories/${encodeURIComponent(m.id)}`)
|
||||||
|
rows = rows.filter((x) => x.id !== m.id)
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(m) {
|
||||||
|
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
|
||||||
|
if (editing === m.id) {
|
||||||
|
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
|
||||||
|
box.value = m.content
|
||||||
|
const done = () => save(m, box.value)
|
||||||
|
box.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation()
|
||||||
|
editing = ""
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
|
||||||
|
})
|
||||||
|
queueMicrotask(() => box.focus())
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: "mrow is-editing", dataset: { id: m.id } },
|
||||||
|
box,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "set__buttons" },
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
|
||||||
|
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
|
||||||
|
icon(ICONS.more),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const box = clear($("memory-body"))
|
||||||
|
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{
|
||||||
|
class: "mnew",
|
||||||
|
onsubmit: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
add(e.currentTarget)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "mnew__foot" },
|
||||||
|
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
|
||||||
|
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
form.querySelector("textarea").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
parts.push(form)
|
||||||
|
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||||
|
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
|
||||||
|
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
|
||||||
|
box.append(h("div", { class: "arch__page" }, parts))
|
||||||
|
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
|
||||||
|
}
|
||||||
+70
-8
@@ -1,12 +1,16 @@
|
|||||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
// 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
@@ -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
@@ -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 })
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
|
||||||
|
// the server offers this chat now (web search among them) and the skills, each on or off for this
|
||||||
|
// chat only, and the commands it was told to stop asking about.
|
||||||
|
//
|
||||||
|
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
|
||||||
|
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
|
||||||
|
// POST /chats/{chat}/allow/clear -> { scope }
|
||||||
|
//
|
||||||
|
// Narrowing only, on the server: switching on what the model, the permissions or the instance
|
||||||
|
// left out reaches nothing. Labels, skill names and descriptions are set as text.
|
||||||
|
|
||||||
|
import { h } from "./dom.js"
|
||||||
|
import { get, post, why } from "./bridge.js"
|
||||||
|
import { popover } from "./popup.js"
|
||||||
|
|
||||||
|
/** The switches of `chat`, in a popover under `anchor`. */
|
||||||
|
export async function scopePopover(anchor, chat) {
|
||||||
|
const path = `/chats/${encodeURIComponent(chat.id)}`
|
||||||
|
let scope = null
|
||||||
|
let error = ""
|
||||||
|
let busy = false
|
||||||
|
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
|
||||||
|
|
||||||
|
// Drawn again after each answer: the switch that was used keeps the focus.
|
||||||
|
const redraw = (key) => {
|
||||||
|
pop.redraw()
|
||||||
|
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
|
||||||
|
}
|
||||||
|
const send = async (url, body) => {
|
||||||
|
const key = document.activeElement?.dataset?.key || ""
|
||||||
|
busy = true
|
||||||
|
redraw(key)
|
||||||
|
try {
|
||||||
|
scope = (await post(url, body))?.scope || scope
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
redraw(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (kind, name, label, on) =>
|
||||||
|
h(
|
||||||
|
"label",
|
||||||
|
{ class: "check scope__row", title: label.title || null },
|
||||||
|
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
|
||||||
|
h("span", { text: label.text }),
|
||||||
|
)
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const body = h("div", { class: "pop__body scope" })
|
||||||
|
if (!scope) {
|
||||||
|
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
const families = scope.families || []
|
||||||
|
const skills = scope.skills || []
|
||||||
|
const allow = scope.allow || []
|
||||||
|
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
|
||||||
|
if (families.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Tools") }),
|
||||||
|
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (skills.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Skills") }),
|
||||||
|
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allow.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Always allowed here") }),
|
||||||
|
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
|
||||||
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
|
||||||
|
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
pop.redraw()
|
||||||
|
}
|
||||||
+123
-2
@@ -1,10 +1,13 @@
|
|||||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
// 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
@@ -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
@@ -0,0 +1,180 @@
|
|||||||
|
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
|
||||||
|
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
|
||||||
|
// `models` scope (src-tauri/src/server.rs):
|
||||||
|
//
|
||||||
|
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
|
||||||
|
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
|
||||||
|
//
|
||||||
|
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
|
||||||
|
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
|
||||||
|
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
|
||||||
|
|
||||||
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, why } from "./bridge.js"
|
||||||
|
|
||||||
|
let me = null
|
||||||
|
|
||||||
|
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||||
|
/** Whether a message can be dictated here. */
|
||||||
|
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
|
||||||
|
/** Whether a reply can be read aloud here. */
|
||||||
|
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
|
||||||
|
|
||||||
|
function say(text) {
|
||||||
|
const box = $("thread-note")
|
||||||
|
if (!box) return
|
||||||
|
box.textContent = text
|
||||||
|
box.hidden = !text
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dictate --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let recorder = null // the recording in progress
|
||||||
|
let busy = false // a recording being turned into words
|
||||||
|
|
||||||
|
function drawMic() {
|
||||||
|
const b = $("dictate")
|
||||||
|
if (!b) return
|
||||||
|
b.hidden = !canDictate()
|
||||||
|
const on = !!recorder
|
||||||
|
b.classList.toggle("is-recording", on)
|
||||||
|
b.classList.toggle("is-busy", busy)
|
||||||
|
b.disabled = busy
|
||||||
|
b.setAttribute("aria-pressed", String(on))
|
||||||
|
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
|
||||||
|
b.setAttribute("aria-label", label)
|
||||||
|
b.title = label
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
say("")
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
await invoke("voice_arm")
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
} catch (err) {
|
||||||
|
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
|
||||||
|
let rec
|
||||||
|
try {
|
||||||
|
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
|
||||||
|
} catch (err) {
|
||||||
|
stream.getTracks().forEach((x) => x.stop())
|
||||||
|
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks = []
|
||||||
|
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
|
||||||
|
rec.addEventListener("stop", async () => {
|
||||||
|
stream.getTracks().forEach((x) => x.stop())
|
||||||
|
recorder = null
|
||||||
|
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
|
||||||
|
if (!blob.size) return drawMic()
|
||||||
|
busy = true
|
||||||
|
drawMic()
|
||||||
|
try {
|
||||||
|
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
|
||||||
|
intoComposer(text)
|
||||||
|
} catch (err) {
|
||||||
|
say(why(err))
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
drawMic()
|
||||||
|
})
|
||||||
|
recorder = rec
|
||||||
|
rec.start()
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words heard, where the cursor is in the message box, a space either side where needed. */
|
||||||
|
function intoComposer(text) {
|
||||||
|
text = String(text || "").trim()
|
||||||
|
if (!text) return
|
||||||
|
const box = $("composer-text")
|
||||||
|
const at = box.selectionStart ?? box.value.length
|
||||||
|
const end = box.selectionEnd ?? at
|
||||||
|
const before = box.value.slice(0, at)
|
||||||
|
const after = box.value.slice(end)
|
||||||
|
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
|
||||||
|
box.value = before + words + after
|
||||||
|
box.selectionStart = box.selectionEnd = before.length + words.length
|
||||||
|
box.dispatchEvent(new Event("input"))
|
||||||
|
box.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
|
||||||
|
export function stopDictating() {
|
||||||
|
if (recorder?.state === "recording") recorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Read aloud -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let playing = null // { id, audio, button }
|
||||||
|
|
||||||
|
/** A reply's words without the markdown around them, and without its code. */
|
||||||
|
export function plain(markdown) {
|
||||||
|
return String(markdown || "")
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/[*_`#>|~]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopReading() {
|
||||||
|
if (!playing) return
|
||||||
|
playing.audio?.pause()
|
||||||
|
playing.button.setAttribute("aria-pressed", "false")
|
||||||
|
playing.button.classList.remove("is-busy")
|
||||||
|
playing = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
|
||||||
|
export function readAloudButton(id, text) {
|
||||||
|
if (!canSpeak()) return null
|
||||||
|
const label = t("Read this aloud")
|
||||||
|
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
if (playing?.id === id) return stopReading()
|
||||||
|
stopReading()
|
||||||
|
const words = plain(text())
|
||||||
|
if (!words) return
|
||||||
|
const mine = { id, audio: null, button: b }
|
||||||
|
playing = mine
|
||||||
|
b.setAttribute("aria-pressed", "true")
|
||||||
|
b.classList.add("is-busy")
|
||||||
|
try {
|
||||||
|
const url = await invoke("speak", { text: words })
|
||||||
|
if (playing !== mine) return
|
||||||
|
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
|
||||||
|
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
|
||||||
|
mine.audio = new Audio(url)
|
||||||
|
b.classList.remove("is-busy")
|
||||||
|
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
|
||||||
|
await mine.audio.play()
|
||||||
|
} catch (err) {
|
||||||
|
if (playing === mine) stopReading()
|
||||||
|
say(why(err?.message ? { detail: err.message } : err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
|
||||||
|
export function voiceFor(account) {
|
||||||
|
me = account
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initVoice() {
|
||||||
|
const b = $("dictate")
|
||||||
|
b.replaceChildren(icon(ICONS.mic))
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
if (busy) return
|
||||||
|
if (recorder) recorder.stop()
|
||||||
|
else start()
|
||||||
|
})
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user