Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
113306b60d
|
||
|
|
b13cfa4ace
|
||
|
|
fe4bf064f5
|
||
|
|
6874dd46cd
|
||
|
|
189f706c65
|
||
|
|
2cdd4e5355
|
||
|
|
df27ca82fa
|
||
|
|
3101552858
|
||
|
|
9b07527c3a
|
||
|
|
2b87fa4c29
|
||
|
|
67ce6076c5
|
||
|
|
d691a4dcfa
|
||
|
|
be70e1a628
|
||
|
|
04736db057
|
||
|
|
151fc5fcca
|
||
|
|
d4a1f13cf1
|
||
|
|
effcc2c2fd
|
||
|
|
97c2301bab
|
||
|
|
94e903ef70
|
No files matched your search
+102
@@ -4,6 +4,108 @@ All notable changes to LLeMbas Desktop. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.0-beta.8] — 2026-11-10
|
||||
|
||||
LLeMbas CLI 2.0.0-beta.8 inside, the look and words of LLeMbas 2.0.0-beta.10.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The chat's settings moved to the top right of its header; the composer keeps the message.**
|
||||
The model picker, how hard it thinks, the subagents and an agent chat's mode sat as chips inside
|
||||
the composer, where with a long model name or every chip on they were cut off and some could not
|
||||
be reached. They are now in the chat's header, beside the Terminal, the activity panel, the
|
||||
chat's tools and skills (a button of its own now, still in the chat's menu too) and the menu; a
|
||||
new agent chat's computer and folder sit beside the title, where an open one says where it
|
||||
works. The composer holds only the message's own: the text, attach, Dictate, Draw a picture and
|
||||
Send/Stop. Where the header has no room for the settings -- measured, so a long model name
|
||||
counts -- they fold behind Chat settings, which opens them as a wrapped row under the header:
|
||||
nothing is cut off or drawn over another at any width (checked at 1920, 1280, 1024, 800 and
|
||||
390 px with long model names and every chip on).
|
||||
|
||||
- **Files, in the web explorer's shape, using the window's width.** Both tabs (This computer and
|
||||
Server) are now where you are (crumbs, each part a way back), the folder tree, the list in
|
||||
columns -- name with an icon by kind, type, size, changed -- and, on a wide window, the chosen
|
||||
file's details with the start of it and Open in the viewer (a double click opens it too). With
|
||||
less room the details give way first (a file then opens in the viewer), then the tree, then the
|
||||
type and date columns, by the room the page has rather than the window's width; nothing is cut
|
||||
off or scrolls the page sideways (checked at 1920, 1280, 1024, 800 and 390 px with a long name).
|
||||
The tree and the list share one request per folder.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Files › Server showed an empty pane while it waited.** Nothing was drawn until the server's
|
||||
listing came back -- no word that it was being read -- and a refusal or a broken answer was said
|
||||
only once Rust's read timeout (60 s) had passed; to the person it looked as if there were no
|
||||
files at all. Every folder in both trees now says Loading… while it is read; after 8 s it says
|
||||
the server has not answered yet and offers Try again (the late answer is still drawn); a
|
||||
refusal, an answer that is not a listing, or anything that breaks drawing it is said in its
|
||||
place with Try again. Read again while an answer was on its way no longer draws the listing
|
||||
twice.
|
||||
|
||||
## [1.0.0-beta.7] — 2026-11-10
|
||||
|
||||
LLeMbas CLI 2.0.0-beta.7 inside, the look and words of LLeMbas 2.0.0-beta.9.
|
||||
|
||||
### Added
|
||||
|
||||
- **Archived chats.** Archive chat was one-way: an archived chat left the sidebar and could not be
|
||||
found again in the app. Archived, under Files in the sidebar, lists them newest first; each opens
|
||||
as any chat does, goes back into the sidebar with Restore chat, or is deleted. An archived chat
|
||||
that is open says so in its header, with Restore chat beside it and in its menu.
|
||||
- **An agent chat on another computer.** A new agent chat could only work on this computer. Where
|
||||
the account has another computer linked, a chip before the folder chooses it (this computer is
|
||||
the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal
|
||||
before the first message opens there too.
|
||||
- **Language and theme in Settings.** Under Appearance. The language is the account's, as the
|
||||
server says it, unless this computer's or another of the app's nine is chosen (kept in the app;
|
||||
the window opens again in it). The theme is the account's, and choosing one saves it there, so
|
||||
the web interface and the phone follow; on a server that cannot be told, it is kept in the app.
|
||||
- **Pinned chats and chat folders.** The sidebar lists the pinned chats first, then the account's
|
||||
folders, nested and folded as the server keeps them, then the rest by day. A chat's menu pins
|
||||
it and moves it into a folder or a new one; a folder's menu renames it, makes one inside it, or
|
||||
deletes it (its chats stay, back in the list). The search line still lists every match.
|
||||
- **Memory.** A page in the sidebar (and in Ctrl+K) lists what the account's models remember about
|
||||
the person, as the web's Settings → Memory does: add a record (into a data group, where there is
|
||||
more than one), change it, remove it. A record a model noted says so.
|
||||
- **Search inside.** Ctrl+K finds more than titles: once two letters are typed it asks the
|
||||
server's search, and lists below the titles what it found inside chats, in the file library and
|
||||
in memory, each with the words around the match, the match marked. A chat opens, a file opens
|
||||
in the viewer, a memory opens the Memory page at it. A server without search finds titles only.
|
||||
- **The server's files can be changed.** On Files → Server, Upload (files chosen in the system's
|
||||
dialog) and New folder work in the home folder, and each folder's menu does the same inside it;
|
||||
a file's or folder's menu renames it or moves it to the trash. What the server refuses is said
|
||||
in the pane, in its words.
|
||||
- **A chat's tools and skills.** Tools and skills… in a chat's menu switches the tool families
|
||||
(web search among them) and the skills on or off for that chat only, as the web's chat settings
|
||||
do, and lists the commands it was told to always allow, with Ask me about these again.
|
||||
- **Voice.** Dictate, beside Send, records a message and writes down what was said where the
|
||||
cursor is, through the server's speech to text; a reply's Read this aloud reads it in the
|
||||
account's own voice (without its code), and stops when pressed again. Each shows only where the
|
||||
server has it on for the account. The microphone is allowed only right after Dictate is pressed.
|
||||
- **Draw a picture.** From Ctrl+K, where the server can draw for the account: a prompt and a
|
||||
shape, and the picture under it. Only the account's own pictures on its own server are shown.
|
||||
- **A scheduled task's notification says what it found.** Where the server makes the run's
|
||||
notification, it carries the task's name and the result's first lines (or the failure said),
|
||||
with Open and Run again (and Pause, when a task has failed three times in a row), labelled in
|
||||
the account's language. Open, or a click on the notification, opens the task; Run again and
|
||||
Pause do it without opening the window. A chat's notification, clicked, opens that chat.
|
||||
- **The release your server recommends.** Settings › About names the Desktop release that goes
|
||||
with the server's version when it is newer than this one (a beta only to a beta install), with
|
||||
a link to its release page.
|
||||
- **Scheduled tasks on a linked computer.** A task's page says where it runs: on the server, or
|
||||
on a computer, in a folder and mode, with how long an approval waits, and whether that computer
|
||||
is away or does not accept scheduled work. A run waiting for its computer says so. The form
|
||||
offers "Where it runs" where the server can: this computer or another linked one that accepts
|
||||
scheduled work, its folder (this computer's agent folder to begin with), mode and wait.
|
||||
- **A picture as the reply, in a chat.** Where the server draws for the account, a Draw a picture
|
||||
switch beside the model asks for the next message's reply to be a picture drawn from it — in a
|
||||
new chat (its first message) as in an open one. It goes off once the message is sent.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A terminal that ended could come back by itself.** Shown again and ended by the server within
|
||||
one frame, the panel opened a new shell in its place; an ended shell is now only said.
|
||||
|
||||
## [1.0.0-beta.6] — 2026-11-10
|
||||
|
||||
LLeMbas CLI 2.0.0-beta.6 inside, the look and words of LLeMbas 2.0.0-beta.8.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.0.0-beta.6
|
||||
2.0.0-beta.8
|
||||
@@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||
FROM="tests/fixtures/api_v2"
|
||||
OUT="tests/ui/fixtures"
|
||||
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
||||
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
|
||||
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json
|
||||
me.json folders.json folder.json search.json memories.json memory.json scope.json push_schedule.json push_schedule_failed.json
|
||||
apps.json apps_stable.json event_schedules.json schedule.json schedule_on_device.json schedule_run.json chat.json chats_listed.json files_listing.json"
|
||||
|
||||
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
||||
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
||||
|
||||
+602
-3
@@ -445,6 +445,127 @@ KEYS = (
|
||||
"Stop %(name)s and the subagents under it?",
|
||||
"This device is not linked right now.",
|
||||
"Subagent %(name)s (%(id)s), %(position)s, asks:",
|
||||
# archived.js
|
||||
"Archived",
|
||||
"Restore chat",
|
||||
"No archived chats.",
|
||||
# chat.js: a new agent chat on another computer
|
||||
"Computer",
|
||||
"Folder on %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.",
|
||||
"Choose",
|
||||
"Choose a folder…",
|
||||
"Type a folder…",
|
||||
"Choose a folder first.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.",
|
||||
# settings.js: language and theme
|
||||
"Appearance",
|
||||
"Language",
|
||||
"Theme",
|
||||
"dark",
|
||||
"light",
|
||||
"The interface only. Models answer in the language you write to them in, whatever this says.",
|
||||
"This computer's (%(language)s)",
|
||||
"The account's (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.",
|
||||
"The account's (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.",
|
||||
# list.js, chat.js: pinned chats and chat folders
|
||||
"Pinned chats",
|
||||
"Pin chat",
|
||||
"Unpin chat",
|
||||
"New folder",
|
||||
"Rename folder",
|
||||
"Delete folder",
|
||||
"Folder name",
|
||||
"Move to folder…",
|
||||
"No folder",
|
||||
"New folder…",
|
||||
"Create",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.",
|
||||
# palette.js, memory.js: search inside, and the memory page
|
||||
"Memory",
|
||||
"Add",
|
||||
"Data group",
|
||||
"Remove this memory?",
|
||||
"Noted by a model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.",
|
||||
"Something to remember…",
|
||||
"Nothing remembered yet.",
|
||||
# explorer.js, scope.js: the library's writes, and a chat's tools and skills
|
||||
"Upload",
|
||||
"Moved to the trash.",
|
||||
"Move to the trash",
|
||||
"Move “%(name)s” to the trash? It can be restored from there.",
|
||||
"Tools and skills…",
|
||||
"Skills",
|
||||
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.",
|
||||
"Always allowed here",
|
||||
"Ask me about these again",
|
||||
"Nothing to switch in this chat.",
|
||||
# voice.js, draw.js: dictation, read aloud, and drawing a picture
|
||||
"Dictate a message",
|
||||
"Stop dictating",
|
||||
"Writing down…",
|
||||
"The microphone could not be opened: %(why)s",
|
||||
"Read this aloud",
|
||||
"The server's answer was not audio.",
|
||||
"Draw a picture",
|
||||
"What to draw…",
|
||||
"Shape",
|
||||
"Square",
|
||||
"Wide",
|
||||
"Tall",
|
||||
"Draw",
|
||||
"Drawing… a picture can take a minute or two.",
|
||||
"The server's answer had no picture in it.",
|
||||
# settings.js: the release the server recommends
|
||||
"Your server recommends LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.",
|
||||
"Open the release page",
|
||||
# scheduled.js: where a task runs
|
||||
"Where it runs",
|
||||
"On this server",
|
||||
"does not accept scheduled work",
|
||||
"not linked now",
|
||||
"A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).",
|
||||
"Folder on the computer",
|
||||
"A full path, inside a folder the computer allows remote work in.",
|
||||
"Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it.",
|
||||
"An approval waits (minutes)",
|
||||
"A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied.",
|
||||
"A linked computer",
|
||||
"Away now: a run waits for it, then counts as missed.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.",
|
||||
"This computer (%(name)s)",
|
||||
"Waiting for the computer",
|
||||
"approvals wait %(n)s min",
|
||||
# chat.js: a picture as the next reply
|
||||
"The reply to the next message is a picture drawn from it.",
|
||||
# explorer.js: a directory still being read, or an answer that is not a listing
|
||||
"The server has not answered in %(n)s seconds.",
|
||||
"The server's answer could not be read.",
|
||||
# explorer.js: the Files page as the web's explorer (the server's words, but the last)
|
||||
"Name",
|
||||
"Type",
|
||||
"Size",
|
||||
"Changed",
|
||||
"folder",
|
||||
"mount",
|
||||
"All files",
|
||||
"Home",
|
||||
"Folders",
|
||||
"Where you are",
|
||||
"Nothing here yet. Not even a hobbit hole.",
|
||||
"Contents",
|
||||
"Actions",
|
||||
"More for %(name)s",
|
||||
"Path",
|
||||
"Choose a file to see it here.",
|
||||
"Details",
|
||||
# chat.js: the chat's settings in its header, folded behind one button where they do not fit
|
||||
"Chat settings",
|
||||
)
|
||||
|
||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||
@@ -673,6 +794,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dny",
|
||||
"Time": "Čas",
|
||||
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.",
|
||||
"No archived chats.": "Žádné archivované konverzace.",
|
||||
"Computer": "Počítač",
|
||||
"Folder on %(device)s": "Složka na zařízení %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta ke složce na tom počítači. Jeho agent otevře jen složky, které dostal.",
|
||||
"Choose": "Vybrat",
|
||||
"Choose a folder…": "Vybrat složku…",
|
||||
"Type a folder…": "Zadat složku…",
|
||||
"Choose a folder first.": "Nejprve vyberte složku.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Podle tohoto počítače (%(language)s)",
|
||||
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.",
|
||||
"The account's (%(language)s)": "Podle účtu (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.",
|
||||
"Move to folder…": "Přesunout do složky…",
|
||||
"No folder": "Bez složky",
|
||||
"New folder…": "Nová složka…",
|
||||
"Create": "Vytvořit",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a složky v ní se smažou. Jejich konverzace zůstanou, zpět v seznamu.",
|
||||
"Add": "Přidat",
|
||||
"Data group": "Skupina dat",
|
||||
"Remove this memory?": "Odstranit tento záznam z paměti?",
|
||||
"Noted by a model": "Zaznamenal model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Co si o vás vaše modely pamatují napříč konverzacemi. Cokoli tu můžete změnit nebo odstranit.",
|
||||
"Something to remember…": "Něco k zapamatování…",
|
||||
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
|
||||
"Tools and skills…": "Nástroje a dovednosti…",
|
||||
"Nothing to switch in this chat.": "V této konverzaci není co přepnout.",
|
||||
"Stop dictating": "Zastavit diktování",
|
||||
"Writing down…": "Zapisuji…",
|
||||
"The microphone could not be opened: %(why)s": "Mikrofon se nepodařilo otevřít: %(why)s",
|
||||
"Read this aloud": "Přečíst nahlas",
|
||||
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
|
||||
"What to draw…": "Co nakreslit…",
|
||||
"Shape": "Tvar",
|
||||
"Square": "Čtverec",
|
||||
"Wide": "Široký",
|
||||
"Tall": "Vysoký",
|
||||
"Draw": "Nakreslit",
|
||||
"Drawing… a picture can take a minute or two.": "Kreslím… obrázek může trvat minutu nebo dvě.",
|
||||
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Váš server doporučuje LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Vydání, které patří k verzi serveru. Toto je %(current)s.",
|
||||
"Open the release page": "Otevřít stránku vydání",
|
||||
"A linked computer": "Propojený počítač",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teď je pryč: spuštění na něj počká, pak se počítá jako zmeškané.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nepřijímá plánovanou práci: zapněte tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Tento počítač (%(name)s)",
|
||||
"Waiting for the computer": "Čeká na počítač",
|
||||
"approvals wait %(n)s min": "schválení čeká %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
|
||||
"The server has not answered in %(n)s seconds.": "Server neodpověděl do %(n)s sekund.",
|
||||
"The server's answer could not be read.": "Odpověď serveru nešlo přečíst.",
|
||||
"Choose a file to see it here.": "Vyberte soubor a uvidíte ho tady.",
|
||||
"Details": "Podrobnosti",
|
||||
},
|
||||
"de": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
||||
@@ -896,6 +1073,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Tage",
|
||||
"Time": "Uhrzeit",
|
||||
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.",
|
||||
"No archived chats.": "Keine archivierten Chats.",
|
||||
"Computer": "Computer",
|
||||
"Folder on %(device)s": "Ordner auf %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Der vollständige Pfad des Ordners auf diesem Computer. Sein Agent öffnet nur Ordner, die ihm freigegeben wurden.",
|
||||
"Choose": "Auswählen",
|
||||
"Choose a folder…": "Ordner wählen…",
|
||||
"Type a folder…": "Ordner eingeben…",
|
||||
"Choose a folder first.": "Wählen Sie zuerst einen Ordner.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.",
|
||||
"This computer's (%(language)s)": "Wie dieser Computer (%(language)s)",
|
||||
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.",
|
||||
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.",
|
||||
"Move to folder…": "In Ordner verschieben…",
|
||||
"No folder": "Kein Ordner",
|
||||
"New folder…": "Neuer Ordner…",
|
||||
"Create": "Erstellen",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ und die Ordner darin werden gelöscht. Ihre Chats bleiben, wieder in der Liste.",
|
||||
"Add": "Hinzufügen",
|
||||
"Data group": "Datengruppe",
|
||||
"Remove this memory?": "Diese Erinnerung entfernen?",
|
||||
"Noted by a model": "Von einem Modell notiert",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Was Ihre Modelle über Sie behalten, über alle Chats hinweg. Hier können Sie alles ändern oder entfernen.",
|
||||
"Something to remember…": "Etwas, das behalten werden soll…",
|
||||
"Nothing remembered yet.": "Noch nichts behalten.",
|
||||
"Tools and skills…": "Werkzeuge und Fähigkeiten…",
|
||||
"Nothing to switch in this chat.": "In diesem Chat gibt es nichts umzuschalten.",
|
||||
"Stop dictating": "Diktieren beenden",
|
||||
"Writing down…": "Wird aufgeschrieben…",
|
||||
"The microphone could not be opened: %(why)s": "Das Mikrofon konnte nicht geöffnet werden: %(why)s",
|
||||
"Read this aloud": "Vorlesen",
|
||||
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
|
||||
"What to draw…": "Was gezeichnet werden soll…",
|
||||
"Shape": "Form",
|
||||
"Square": "Quadratisch",
|
||||
"Wide": "Breit",
|
||||
"Tall": "Hoch",
|
||||
"Draw": "Zeichnen",
|
||||
"Drawing… a picture can take a minute or two.": "Wird gezeichnet… ein Bild kann ein bis zwei Minuten dauern.",
|
||||
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein Bild.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Ihr Server empfiehlt LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Die Version, die zur Version des Servers passt. Dies ist %(current)s.",
|
||||
"Open the release page": "Release-Seite öffnen",
|
||||
"A linked computer": "Ein verknüpfter Computer",
|
||||
"Away now: a run waits for it, then counts as missed.": "Gerade nicht da: Ein Lauf wartet auf ihn und zählt dann als verpasst.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Er nimmt keine geplante Arbeit an: Schalten Sie dort Accept scheduled work ein.",
|
||||
"This computer (%(name)s)": "Dieser Computer (%(name)s)",
|
||||
"Waiting for the computer": "Wartet auf den Computer",
|
||||
"approvals wait %(n)s min": "Freigaben warten %(n)s Min.",
|
||||
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
|
||||
"The server has not answered in %(n)s seconds.": "Der Server hat seit %(n)s Sekunden nicht geantwortet.",
|
||||
"The server's answer could not be read.": "Die Antwort des Servers konnte nicht gelesen werden.",
|
||||
"Choose a file to see it here.": "Wählen Sie eine Datei, um sie hier zu sehen.",
|
||||
"Details": "Details",
|
||||
},
|
||||
"es": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
||||
@@ -1119,6 +1352,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Días",
|
||||
"Time": "Hora",
|
||||
"No day ticked: every day.": "Ningún día marcado: todos los días.",
|
||||
"No archived chats.": "No hay chats archivados.",
|
||||
"Computer": "Equipo",
|
||||
"Folder on %(device)s": "Carpeta en %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "La ruta completa de la carpeta en ese equipo. Su agente solo abre las carpetas que se le han dado.",
|
||||
"Choose": "Elegir",
|
||||
"Choose a folder…": "Elegir una carpeta…",
|
||||
"Type a folder…": "Escribir una carpeta…",
|
||||
"Choose a folder first.": "Elija primero una carpeta.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.",
|
||||
"This computer's (%(language)s)": "La de este equipo (%(language)s)",
|
||||
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.",
|
||||
"The account's (%(language)s)": "El de la cuenta (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.",
|
||||
"Move to folder…": "Mover a una carpeta…",
|
||||
"No folder": "Sin carpeta",
|
||||
"New folder…": "Nueva carpeta…",
|
||||
"Create": "Crear",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "Se eliminan «%(name)s» y las carpetas que contiene. Sus chats se quedan, de vuelta en la lista.",
|
||||
"Add": "Añadir",
|
||||
"Data group": "Grupo de datos",
|
||||
"Remove this memory?": "¿Quitar este recuerdo?",
|
||||
"Noted by a model": "Anotado por un modelo",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Lo que sus modelos recuerdan de usted entre chats. Aquí puede cambiar o quitar cualquier cosa.",
|
||||
"Something to remember…": "Algo que recordar…",
|
||||
"Nothing remembered yet.": "Todavía no se recuerda nada.",
|
||||
"Tools and skills…": "Herramientas y habilidades…",
|
||||
"Nothing to switch in this chat.": "No hay nada que activar o desactivar en este chat.",
|
||||
"Stop dictating": "Dejar de dictar",
|
||||
"Writing down…": "Transcribiendo…",
|
||||
"The microphone could not be opened: %(why)s": "No se pudo abrir el micrófono: %(why)s",
|
||||
"Read this aloud": "Leer en voz alta",
|
||||
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
|
||||
"What to draw…": "Qué dibujar…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Cuadrada",
|
||||
"Wide": "Ancha",
|
||||
"Tall": "Alta",
|
||||
"Draw": "Dibujar",
|
||||
"Drawing… a picture can take a minute or two.": "Dibujando… una imagen puede tardar uno o dos minutos.",
|
||||
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Su servidor recomienda LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La versión que corresponde a la del servidor. Esta es %(current)s.",
|
||||
"Open the release page": "Abrir la página de la versión",
|
||||
"A linked computer": "Un ordenador vinculado",
|
||||
"Away now: a run waits for it, then counts as missed.": "Ausente ahora: una ejecución lo espera y luego cuenta como perdida.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "No acepta trabajo programado: active allí Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Este ordenador (%(name)s)",
|
||||
"Waiting for the computer": "Esperando al ordenador",
|
||||
"approvals wait %(n)s min": "las aprobaciones esperan %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
|
||||
"The server has not answered in %(n)s seconds.": "El servidor no ha respondido en %(n)s segundos.",
|
||||
"The server's answer could not be read.": "No se pudo leer la respuesta del servidor.",
|
||||
"Choose a file to see it here.": "Elige un archivo para verlo aquí.",
|
||||
"Details": "Detalles",
|
||||
},
|
||||
"fr": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
||||
@@ -1342,6 +1631,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Jours",
|
||||
"Time": "Heure",
|
||||
"No day ticked: every day.": "Aucun jour coché : tous les jours.",
|
||||
"No archived chats.": "Aucune conversation archivée.",
|
||||
"Computer": "Ordinateur",
|
||||
"Folder on %(device)s": "Dossier sur %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Le chemin complet du dossier sur cet ordinateur. Son agent n’ouvre que les dossiers qui lui ont été confiés.",
|
||||
"Choose": "Choisir",
|
||||
"Choose a folder…": "Choisir un dossier…",
|
||||
"Type a folder…": "Saisir un dossier…",
|
||||
"Choose a folder first.": "Choisissez d’abord un dossier.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.",
|
||||
"This computer's (%(language)s)": "Celle de cet ordinateur (%(language)s)",
|
||||
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.",
|
||||
"The account's (%(language)s)": "Celle du compte (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.",
|
||||
"Move to folder…": "Déplacer dans un dossier…",
|
||||
"No folder": "Aucun dossier",
|
||||
"New folder…": "Nouveau dossier…",
|
||||
"Create": "Créer",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "« %(name)s » et les dossiers qu’il contient sont supprimés. Leurs conversations restent, de retour dans la liste.",
|
||||
"Add": "Ajouter",
|
||||
"Data group": "Groupe de données",
|
||||
"Remove this memory?": "Supprimer ce souvenir ?",
|
||||
"Noted by a model": "Noté par un modèle",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Ce que vos modèles retiennent de vous, d’une conversation à l’autre. Modifiez ou supprimez ce que vous voulez ici.",
|
||||
"Something to remember…": "Quelque chose à retenir…",
|
||||
"Nothing remembered yet.": "Rien n’est encore retenu.",
|
||||
"Tools and skills…": "Outils et compétences…",
|
||||
"Nothing to switch in this chat.": "Rien à activer ou désactiver dans cette conversation.",
|
||||
"Stop dictating": "Arrêter la dictée",
|
||||
"Writing down…": "Transcription…",
|
||||
"The microphone could not be opened: %(why)s": "Le micro n’a pas pu être ouvert : %(why)s",
|
||||
"Read this aloud": "Lire à voix haute",
|
||||
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
|
||||
"What to draw…": "Que dessiner…",
|
||||
"Shape": "Forme",
|
||||
"Square": "Carrée",
|
||||
"Wide": "Large",
|
||||
"Tall": "Haute",
|
||||
"Draw": "Dessiner",
|
||||
"Drawing… a picture can take a minute or two.": "Dessin en cours… une image peut prendre une minute ou deux.",
|
||||
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Votre serveur recommande LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La version qui va avec celle du serveur. Celle-ci est la %(current)s.",
|
||||
"Open the release page": "Ouvrir la page de la version",
|
||||
"A linked computer": "Un ordinateur lié",
|
||||
"Away now: a run waits for it, then counts as missed.": "Absent pour l’instant : une exécution l’attend, puis compte comme manquée.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Il n’accepte pas le travail planifié : activez-y Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Cet ordinateur (%(name)s)",
|
||||
"Waiting for the computer": "En attente de l’ordinateur",
|
||||
"approvals wait %(n)s min": "les approbations attendent %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
|
||||
"The server has not answered in %(n)s seconds.": "Le serveur n’a pas répondu depuis %(n)s secondes.",
|
||||
"The server's answer could not be read.": "La réponse du serveur n’a pas pu être lue.",
|
||||
"Choose a file to see it here.": "Choisissez un fichier pour le voir ici.",
|
||||
"Details": "Détails",
|
||||
},
|
||||
"hu": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
||||
@@ -1565,6 +1910,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Napok",
|
||||
"Time": "Időpont",
|
||||
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.",
|
||||
"No archived chats.": "Nincs archivált beszélgetés.",
|
||||
"Computer": "Számítógép",
|
||||
"Folder on %(device)s": "Mappa ezen: %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "A mappa teljes elérési útja azon a számítógépen. Az ügynöke csak a neki megadott mappákat nyitja meg.",
|
||||
"Choose": "Kiválasztás",
|
||||
"Choose a folder…": "Mappa kiválasztása…",
|
||||
"Type a folder…": "Mappa megadása…",
|
||||
"Choose a folder first.": "Először válasszon mappát.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.",
|
||||
"This computer's (%(language)s)": "A számítógépé (%(language)s)",
|
||||
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.",
|
||||
"The account's (%(language)s)": "A fióké (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.",
|
||||
"Move to folder…": "Áthelyezés mappába…",
|
||||
"No folder": "Nincs mappa",
|
||||
"New folder…": "Új mappa…",
|
||||
"Create": "Létrehozás",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "A(z) „%(name)s” és a benne lévő mappák törlődnek. A beszélgetéseik megmaradnak, vissza a listába.",
|
||||
"Add": "Hozzáadás",
|
||||
"Data group": "Adatcsoport",
|
||||
"Remove this memory?": "Eltávolítja ezt az emléket?",
|
||||
"Noted by a model": "Egy modell jegyezte fel",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Amit a modelljei megjegyeznek Önről a beszélgetéseken át. Itt bármit módosíthat vagy eltávolíthat.",
|
||||
"Something to remember…": "Valami, amit meg kell jegyezni…",
|
||||
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
|
||||
"Tools and skills…": "Eszközök és készségek…",
|
||||
"Nothing to switch in this chat.": "Ebben a beszélgetésben nincs mit átkapcsolni.",
|
||||
"Stop dictating": "Diktálás leállítása",
|
||||
"Writing down…": "Leírás…",
|
||||
"The microphone could not be opened: %(why)s": "A mikrofont nem sikerült megnyitni: %(why)s",
|
||||
"Read this aloud": "Felolvasás",
|
||||
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
|
||||
"What to draw…": "Mit rajzoljon…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Négyzet",
|
||||
"Wide": "Széles",
|
||||
"Tall": "Magas",
|
||||
"Draw": "Rajzolás",
|
||||
"Drawing… a picture can take a minute or two.": "Rajzolás… egy kép egy-két percig is eltarthat.",
|
||||
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "A szervere a LLeMbas Desktop %(version)s verziót ajánlja",
|
||||
"The release that goes with the server's version. This is %(current)s.": "A szerver verziójához tartozó kiadás. Ez a %(current)s.",
|
||||
"Open the release page": "Kiadás oldalának megnyitása",
|
||||
"A linked computer": "Egy összekapcsolt számítógép",
|
||||
"Away now: a run waits for it, then counts as missed.": "Most nem elérhető: a futás megvárja, aztán kihagyottnak számít.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nem fogad ütemezett munkát: kapcsolja be ott az Accept scheduled work beállítást.",
|
||||
"This computer (%(name)s)": "Ez a számítógép (%(name)s)",
|
||||
"Waiting for the computer": "A számítógépre vár",
|
||||
"approvals wait %(n)s min": "a jóváhagyások %(n)s percig várnak",
|
||||
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
|
||||
"The server has not answered in %(n)s seconds.": "A szerver %(n)s másodperce nem válaszolt.",
|
||||
"The server's answer could not be read.": "A szerver válasza nem olvasható.",
|
||||
"Choose a file to see it here.": "Válasszon egy fájlt, hogy itt lássa.",
|
||||
"Details": "Részletek",
|
||||
},
|
||||
"it": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
||||
@@ -1788,6 +2189,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Giorni",
|
||||
"Time": "Ora",
|
||||
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.",
|
||||
"No archived chats.": "Nessuna chat archiviata.",
|
||||
"Computer": "Computer",
|
||||
"Folder on %(device)s": "Cartella su %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Il percorso completo della cartella su quel computer. Il suo agente apre solo le cartelle che gli sono state date.",
|
||||
"Choose": "Scegli",
|
||||
"Choose a folder…": "Scegli una cartella…",
|
||||
"Type a folder…": "Digita una cartella…",
|
||||
"Choose a folder first.": "Scegli prima una cartella.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.",
|
||||
"This computer's (%(language)s)": "Quella di questo computer (%(language)s)",
|
||||
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.",
|
||||
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.",
|
||||
"Move to folder…": "Sposta in una cartella…",
|
||||
"No folder": "Nessuna cartella",
|
||||
"New folder…": "Nuova cartella…",
|
||||
"Create": "Crea",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "«%(name)s» e le cartelle al suo interno vengono eliminate. Le loro chat restano, di nuovo nell’elenco.",
|
||||
"Add": "Aggiungi",
|
||||
"Data group": "Gruppo di dati",
|
||||
"Remove this memory?": "Rimuovere questo ricordo?",
|
||||
"Noted by a model": "Annotato da un modello",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Ciò che i tuoi modelli ricordano di te tra una chat e l’altra. Qui puoi cambiare o rimuovere qualsiasi cosa.",
|
||||
"Something to remember…": "Qualcosa da ricordare…",
|
||||
"Nothing remembered yet.": "Ancora niente da ricordare.",
|
||||
"Tools and skills…": "Strumenti e abilità…",
|
||||
"Nothing to switch in this chat.": "Non c’è niente da attivare o disattivare in questa chat.",
|
||||
"Stop dictating": "Interrompi la dettatura",
|
||||
"Writing down…": "Trascrizione…",
|
||||
"The microphone could not be opened: %(why)s": "Impossibile aprire il microfono: %(why)s",
|
||||
"Read this aloud": "Leggi ad alta voce",
|
||||
"The server's answer was not audio.": "La risposta del server non era audio.",
|
||||
"What to draw…": "Cosa disegnare…",
|
||||
"Shape": "Forma",
|
||||
"Square": "Quadrata",
|
||||
"Wide": "Larga",
|
||||
"Tall": "Alta",
|
||||
"Draw": "Disegna",
|
||||
"Drawing… a picture can take a minute or two.": "Disegno in corso… un’immagine può richiedere un minuto o due.",
|
||||
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Il tuo server consiglia LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "La versione che va con quella del server. Questa è la %(current)s.",
|
||||
"Open the release page": "Apri la pagina della versione",
|
||||
"A linked computer": "Un computer collegato",
|
||||
"Away now: a run waits for it, then counts as missed.": "Assente ora: un’esecuzione lo aspetta, poi conta come persa.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Non accetta lavoro pianificato: attiva lì Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Questo computer (%(name)s)",
|
||||
"Waiting for the computer": "In attesa del computer",
|
||||
"approvals wait %(n)s min": "le approvazioni attendono %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
|
||||
"The server has not answered in %(n)s seconds.": "Il server non ha risposto in %(n)s secondi.",
|
||||
"The server's answer could not be read.": "Non è stato possibile leggere la risposta del server.",
|
||||
"Choose a file to see it here.": "Scegli un file per vederlo qui.",
|
||||
"Details": "Dettagli",
|
||||
},
|
||||
"pl": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
||||
@@ -2011,6 +2468,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dni",
|
||||
"Time": "Godzina",
|
||||
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.",
|
||||
"No archived chats.": "Brak zarchiwizowanych czatów.",
|
||||
"Computer": "Komputer",
|
||||
"Folder on %(device)s": "Folder na %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Pełna ścieżka folderu na tym komputerze. Jego agent otwiera tylko foldery, które mu udostępniono.",
|
||||
"Choose": "Wybierz",
|
||||
"Choose a folder…": "Wybierz folder…",
|
||||
"Type a folder…": "Wpisz folder…",
|
||||
"Choose a folder first.": "Najpierw wybierz folder.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Jak na tym komputerze (%(language)s)",
|
||||
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.",
|
||||
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.",
|
||||
"Move to folder…": "Przenieś do folderu…",
|
||||
"No folder": "Bez folderu",
|
||||
"New folder…": "Nowy folder…",
|
||||
"Create": "Utwórz",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s” i foldery w nim zostaną usunięte. Ich czaty zostaną, z powrotem na liście.",
|
||||
"Add": "Dodaj",
|
||||
"Data group": "Grupa danych",
|
||||
"Remove this memory?": "Usunąć to wspomnienie?",
|
||||
"Noted by a model": "Zanotowane przez model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Co Twoje modele pamiętają o Tobie we wszystkich czatach. Tutaj możesz wszystko zmienić lub usunąć.",
|
||||
"Something to remember…": "Coś do zapamiętania…",
|
||||
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
|
||||
"Tools and skills…": "Narzędzia i umiejętności…",
|
||||
"Nothing to switch in this chat.": "W tym czacie nie ma czego przełączać.",
|
||||
"Stop dictating": "Zatrzymaj dyktowanie",
|
||||
"Writing down…": "Zapisywanie…",
|
||||
"The microphone could not be opened: %(why)s": "Nie udało się otworzyć mikrofonu: %(why)s",
|
||||
"Read this aloud": "Przeczytaj na głos",
|
||||
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
|
||||
"What to draw…": "Co narysować…",
|
||||
"Shape": "Kształt",
|
||||
"Square": "Kwadrat",
|
||||
"Wide": "Szeroki",
|
||||
"Tall": "Wysoki",
|
||||
"Draw": "Narysuj",
|
||||
"Drawing… a picture can take a minute or two.": "Rysowanie… obraz może zająć minutę lub dwie.",
|
||||
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Twój serwer zaleca LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Wydanie pasujące do wersji serwera. To jest %(current)s.",
|
||||
"Open the release page": "Otwórz stronę wydania",
|
||||
"A linked computer": "Połączony komputer",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teraz nieobecny: uruchomienie na niego czeka, potem liczy się jako pominięte.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Nie przyjmuje zaplanowanej pracy: włącz tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Ten komputer (%(name)s)",
|
||||
"Waiting for the computer": "Czeka na komputer",
|
||||
"approvals wait %(n)s min": "zatwierdzenia czekają %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
|
||||
"The server has not answered in %(n)s seconds.": "Serwer nie odpowiedział od %(n)s sekund.",
|
||||
"The server's answer could not be read.": "Nie udało się odczytać odpowiedzi serwera.",
|
||||
"Choose a file to see it here.": "Wybierz plik, aby zobaczyć go tutaj.",
|
||||
"Details": "Szczegóły",
|
||||
},
|
||||
"sk": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
||||
@@ -2234,6 +2747,62 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Days": "Dni",
|
||||
"Time": "Čas",
|
||||
"No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.",
|
||||
"No archived chats.": "Žiadne archivované konverzácie.",
|
||||
"Computer": "Počítač",
|
||||
"Folder on %(device)s": "Priečinok na zariadení %(device)s",
|
||||
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.",
|
||||
"Choose": "Vybrať",
|
||||
"Choose a folder…": "Vybrať priečinok…",
|
||||
"Type a folder…": "Zadať priečinok…",
|
||||
"Choose a folder first.": "Najprv vyberte priečinok.",
|
||||
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.",
|
||||
"This computer's (%(language)s)": "Podľa tohto počítača (%(language)s)",
|
||||
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.",
|
||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.",
|
||||
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
|
||||
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
|
||||
"Move to folder…": "Presunúť do priečinka…",
|
||||
"No folder": "Bez priečinka",
|
||||
"New folder…": "Nový priečinok…",
|
||||
"Create": "Vytvoriť",
|
||||
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.",
|
||||
"Add": "Pridať",
|
||||
"Data group": "Skupina údajov",
|
||||
"Remove this memory?": "Odstrániť tento záznam z pamäte?",
|
||||
"Noted by a model": "Zaznamenal model",
|
||||
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
|
||||
"Something to remember…": "Niečo na zapamätanie…",
|
||||
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
|
||||
"Tools and skills…": "Nástroje a schopnosti…",
|
||||
"Nothing to switch in this chat.": "V tejto konverzácii nie je čo prepnúť.",
|
||||
"Stop dictating": "Zastaviť diktovanie",
|
||||
"Writing down…": "Zapisujem…",
|
||||
"The microphone could not be opened: %(why)s": "Mikrofón sa nepodarilo otvoriť: %(why)s",
|
||||
"Read this aloud": "Prečítať nahlas",
|
||||
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
|
||||
"What to draw…": "Čo nakresliť…",
|
||||
"Shape": "Tvar",
|
||||
"Square": "Štvorec",
|
||||
"Wide": "Široký",
|
||||
"Tall": "Vysoký",
|
||||
"Draw": "Nakresliť",
|
||||
"Drawing… a picture can take a minute or two.": "Kreslím… obrázok môže trvať minútu alebo dve.",
|
||||
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
|
||||
"Your server recommends LLeMbas Desktop %(version)s": "Váš server odporúča LLeMbas Desktop %(version)s",
|
||||
"The release that goes with the server's version. This is %(current)s.": "Vydanie, ktoré patrí k verzii servera. Toto je %(current)s.",
|
||||
"Open the release page": "Otvoriť stránku vydania",
|
||||
"A linked computer": "Prepojený počítač",
|
||||
"Away now: a run waits for it, then counts as missed.": "Teraz nie je k dispozícii: spustenie naň počká, potom sa počíta ako zmeškané.",
|
||||
"It does not accept scheduled work: turn on Accept scheduled work there.": "Neprijíma plánovanú prácu: zapnite tam Accept scheduled work.",
|
||||
"This computer (%(name)s)": "Tento počítač (%(name)s)",
|
||||
"Waiting for the computer": "Čaká na počítač",
|
||||
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
|
||||
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
|
||||
"The server has not answered in %(n)s seconds.": "Server neodpovedal do %(n)s sekúnd.",
|
||||
"The server's answer could not be read.": "Odpoveď servera sa nedala prečítať.",
|
||||
"Choose a file to see it here.": "Vyberte súbor a uvidíte ho tu.",
|
||||
"Details": "Podrobnosti",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2252,9 +2821,13 @@ def server_catalogue(server: Path, lang: str) -> dict[str, str]:
|
||||
messages = getattr(module, "MESSAGES", None)
|
||||
if not isinstance(messages, dict):
|
||||
sys.exit(f"gen-i18n: {path} has no MESSAGES")
|
||||
NAMES[lang] = str(getattr(module, "NAME", "") or lang)
|
||||
return messages
|
||||
|
||||
|
||||
NAMES: dict[str, str] = {} # each language's own name for itself, as the server's catalogue says
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas"))
|
||||
catalogue: dict[str, dict[str, str]] = {}
|
||||
@@ -2287,24 +2860,50 @@ def main() -> None:
|
||||
print(f"gen-i18n: {lang}: {len(table)} sentences, {from_server} of the page's {len(KEYS)} from the server")
|
||||
|
||||
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
names = json.dumps({"en": "English", **NAMES}, ensure_ascii=False, sort_keys=True)
|
||||
out = (
|
||||
"// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:\n"
|
||||
"// the sentences the server also has come from its web/i18n catalogues, the rest from the\n"
|
||||
"// script's own table. Do not edit; run the script.\n"
|
||||
"//\n"
|
||||
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n"
|
||||
"// missing entry is the English. The language is the system's (navigator.language),\n"
|
||||
"// English unless it is one of these. Elements marked `data-i18n` (their text) and\n"
|
||||
"// missing entry is the English. The language is the account's (kept from /me as\n"
|
||||
"// `lembas.account-language`) unless Settings chose this computer's or another\n"
|
||||
"// (`lembas.language`); the system's is navigator.language. English unless it is one of\n"
|
||||
"// these. Elements marked `data-i18n` (their text) and\n"
|
||||
"// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n"
|
||||
"// are translated once the page is parsed; this script is deferred, before the page's.\n"
|
||||
'"use strict"\n'
|
||||
"\n"
|
||||
f"const LEMBAS_I18N = {body}\n"
|
||||
"\n"
|
||||
"const LEMBAS_LANG = (() => {\n"
|
||||
"// Each language's name for itself, for the choice in Settings.\n"
|
||||
f"const LEMBAS_LANGS = {names}\n"
|
||||
"\n"
|
||||
"// What the system's language gives, and what was chosen here instead (\"\" for the system's).\n"
|
||||
"const LEMBAS_SYSTEM_LANG = (() => {\n"
|
||||
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
|
||||
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
|
||||
"})()\n"
|
||||
"// Settings' choice: \"\" for the account's language (as /me last said it), \"system\", or a code.\n"
|
||||
"const LEMBAS_CHOICE = (() => {\n"
|
||||
" try {\n"
|
||||
' return localStorage.getItem("lembas.language") || ""\n'
|
||||
" } catch {\n"
|
||||
' return ""\n'
|
||||
" }\n"
|
||||
"})()\n"
|
||||
"const LEMBAS_CHOSEN_LANG = (() => {\n"
|
||||
" const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)\n"
|
||||
' if (LEMBAS_CHOICE === "system") return ""\n'
|
||||
" if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE\n"
|
||||
' let account = ""\n'
|
||||
" try {\n"
|
||||
' account = localStorage.getItem("lembas.account-language") || ""\n'
|
||||
" } catch {}\n"
|
||||
' return has(account) ? account : ""\n'
|
||||
"})()\n"
|
||||
"const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n"
|
||||
"document.documentElement.lang = LEMBAS_LANG\n"
|
||||
"\n"
|
||||
"function t(text, fields) {\n"
|
||||
|
||||
Generated
+2
-1
@@ -2135,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "llembas-desktop"
|
||||
version = "1.0.0-beta.6"
|
||||
version = "1.0.0-beta.8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -2149,6 +2149,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tungstenite",
|
||||
"ureq",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "llembas-desktop"
|
||||
version = "1.0.0-beta.6"
|
||||
version = "1.0.0-beta.8"
|
||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||
authors = ["Jaroslav Beneš"]
|
||||
license = "MIT"
|
||||
@@ -32,6 +32,11 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The webview's own settings: getUserMedia is off in WebKitGTK until the app turns it on.
|
||||
# The same crate and version wry already builds on.
|
||||
webkit2gtk = { version = "2.0", features = ["v2_4"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
+287
-9
@@ -36,13 +36,14 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::webview::{PermissionKind, PermissionResponse};
|
||||
use tauri::window::Color;
|
||||
use tauri::{
|
||||
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
@@ -122,6 +123,9 @@ struct App {
|
||||
watch: watch::Watch,
|
||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||
dropped: Mutex<Vec<PathBuf>>,
|
||||
/// Until when the page's own Dictate button may have the microphone (`voice_arm`): a request
|
||||
/// for it at any other moment is refused.
|
||||
mic_until: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
struct TrayItems {
|
||||
@@ -153,6 +157,13 @@ impl App {
|
||||
Some((server.app, account.token))
|
||||
}
|
||||
|
||||
/// The server's own address and the app's token: for the `/v1` routes (voice, pictures).
|
||||
fn door_v1(&self) -> Option<(String, String)> {
|
||||
let server = self.server.lock().unwrap().clone()?;
|
||||
let account = self.account.lock().unwrap().clone()?;
|
||||
Some((server.base, account.token))
|
||||
}
|
||||
|
||||
/// The folders the agent may use: what the explorer and the viewer may read.
|
||||
fn roots(&self) -> Vec<String> {
|
||||
self.remote
|
||||
@@ -452,6 +463,77 @@ async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
// --- Voice and pictures (`/v1`, the app's token holds the `models` scope) -----------------------
|
||||
|
||||
/// Run a `/v1` call off the page's thread, as the signed-in account; a 401 signs the app out.
|
||||
async fn on_v1<T: Send + 'static>(
|
||||
handle: &AppHandle,
|
||||
f: impl FnOnce(&str, &str) -> Result<T, ApiError> + Send + 'static,
|
||||
) -> Result<T, ApiError> {
|
||||
let Some((base, token)) = handle.state::<App>().door_v1() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let result = tauri::async_runtime::spawn_blocking(move || f(&base, &token))
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||
if matches!(&result, Err(e) if e.status == 401) {
|
||||
token_refused(handle);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The page is about to ask for the microphone, because the person pressed Dictate: for the
|
||||
/// next few seconds a request for it is allowed (`permission`), and at no other time.
|
||||
#[tauri::command]
|
||||
fn voice_arm(app: tauri::State<'_, App>) {
|
||||
*app.mic_until.lock().unwrap() =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(10));
|
||||
}
|
||||
|
||||
/// A recording made in the composer, in words: its bytes as the raw body of the call, its media
|
||||
/// type in `x-type`.
|
||||
#[tauri::command]
|
||||
async fn transcribe(
|
||||
handle: AppHandle,
|
||||
request: tauri::ipc::Request<'_>,
|
||||
) -> Result<String, ApiError> {
|
||||
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
|
||||
return Err(ApiError::new(400, "No recording."));
|
||||
};
|
||||
if bytes.len() as u64 > server::RECORDING_LIMIT {
|
||||
return Err(ApiError::new(413, "That recording is too long."));
|
||||
}
|
||||
let kind = request
|
||||
.headers()
|
||||
.get("x-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = bytes.clone();
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::transcribe(base, token, &kind, &bytes)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// A reply read aloud, as a `data:` URL of its audio.
|
||||
#[tauri::command]
|
||||
async fn speak(handle: AppHandle, text: String) -> Result<String, ApiError> {
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::speak(base, token, &text)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pictures drawn by the instance for `prompt`, as `data:` URLs.
|
||||
#[tauri::command]
|
||||
async fn draw(handle: AppHandle, prompt: String, size: String) -> Result<Vec<String>, ApiError> {
|
||||
on_v1(&handle, move |base, token| {
|
||||
server::draw(base, token, &prompt, &size)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
|
||||
/// first word of "continue in a terminal" for an agent chat on this computer.
|
||||
#[tauri::command]
|
||||
@@ -690,6 +772,75 @@ async fn attach_dropped(
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// What an upload into the library came to: the directory's listing after the last file the
|
||||
/// server took, and the files it did not take, each with the reason.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LibraryUpload {
|
||||
listing: Option<Value>,
|
||||
sent: usize,
|
||||
failed: Vec<Uploaded>,
|
||||
}
|
||||
|
||||
/// Choose files with the system's dialog and send them into the person's library, into the
|
||||
/// directory `dir`. Nothing is sent when the dialog is cancelled.
|
||||
#[tauri::command]
|
||||
async fn library_upload(
|
||||
handle: AppHandle,
|
||||
dir: String,
|
||||
title: String,
|
||||
) -> Result<LibraryUpload, ApiError> {
|
||||
let h = handle.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let picked = h.dialog().file().set_title(title).blocking_pick_files();
|
||||
let paths: Vec<PathBuf> = picked
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|p| p.into_path().ok())
|
||||
.collect();
|
||||
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let mut out = LibraryUpload {
|
||||
listing: None,
|
||||
sent: 0,
|
||||
failed: Vec::new(),
|
||||
};
|
||||
for path in paths {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
let sent = match read_for_upload(&path) {
|
||||
Ok(bytes) => server::upload_to_library(&app_url, &token, &dir, &name, &bytes)
|
||||
.map_err(|e| {
|
||||
if e.status == 401 {
|
||||
token_refused(&h);
|
||||
}
|
||||
(None, e.detail)
|
||||
}),
|
||||
Err((reason, message)) => Err((Some(reason), message)),
|
||||
};
|
||||
match sent {
|
||||
Ok(listing) => {
|
||||
out.listing = Some(listing);
|
||||
out.sent += 1;
|
||||
}
|
||||
Err((reason, message)) => out.failed.push(Uploaded {
|
||||
name,
|
||||
size,
|
||||
attachment: None,
|
||||
error: Some(message),
|
||||
reason,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
|
||||
fn percent_decode(text: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
@@ -1180,7 +1331,39 @@ fn build_main(handle: &AppHandle) -> tauri::Result<WebviewWindow> {
|
||||
.decorations(false)
|
||||
.background_color(theme.background())
|
||||
.on_navigation(allowed)
|
||||
.on_permission_request(permission)
|
||||
.build()
|
||||
.inspect(media_stream)
|
||||
}
|
||||
|
||||
/// WebKitGTK answers no permission request its app does not, and has no prompt of its own. The
|
||||
/// microphone is allowed when the page's own Dictate button has just asked for it (`voice_arm`,
|
||||
/// which only the app's page can call: sealed frames have no bridge), once; nothing else is.
|
||||
fn permission(view: tauri::Webview, kind: PermissionKind) -> PermissionResponse {
|
||||
if kind != PermissionKind::Microphone {
|
||||
return PermissionResponse::Default;
|
||||
}
|
||||
let app = view.app_handle().state::<App>();
|
||||
let armed = app.mic_until.lock().unwrap().take();
|
||||
if armed.is_some_and(|until| std::time::Instant::now() <= until) {
|
||||
PermissionResponse::Allow
|
||||
} else {
|
||||
PermissionResponse::Deny
|
||||
}
|
||||
}
|
||||
|
||||
/// `getUserMedia` is off in WebKitGTK unless the app turns it on (it is on in other webviews).
|
||||
/// The settings are read with their own getter: the `settings` property is write-only.
|
||||
fn media_stream(window: &WebviewWindow) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = window.with_webview(|pv| {
|
||||
use webkit2gtk::{SettingsExt, WebViewExt};
|
||||
if let Some(settings) = pv.inner().settings() {
|
||||
settings.set_enable_media_stream(true);
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = window;
|
||||
}
|
||||
|
||||
/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever.
|
||||
@@ -1473,9 +1656,11 @@ fn watch_chats(handle: AppHandle) {
|
||||
} else {
|
||||
d.title.clone()
|
||||
};
|
||||
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body(if d.agent { &words.agent } else { &words.reply })
|
||||
.show();
|
||||
@@ -1483,6 +1668,56 @@ fn watch_chats(handle: AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A click on one of the app's notifications, or on one of its actions: a chat's opens that chat;
|
||||
/// a scheduled run's opens its task (`open`, or the notification itself), runs it again now
|
||||
/// (`run_again`) or pauses it (`pause`) -- the last two without opening the window.
|
||||
fn notification_action(handle: &AppHandle, done: &ActionPerformed) {
|
||||
let Some(id) = done.notification().map(|n| n.id()) else {
|
||||
return;
|
||||
};
|
||||
let Some(note) = handle.state::<App>().watch.notes.get(id) else {
|
||||
return;
|
||||
};
|
||||
match (note, done.action_id()) {
|
||||
(watch::Note::Chat(chat), "tap") => {
|
||||
show(handle);
|
||||
let _ = handle.emit("open-chat", chat);
|
||||
}
|
||||
(watch::Note::Run { schedule, run }, "tap" | "open") => {
|
||||
show(handle);
|
||||
let _ = handle.emit(
|
||||
"open-schedule",
|
||||
serde_json::json!({ "schedule": schedule, "run": run }),
|
||||
);
|
||||
}
|
||||
(watch::Note::Run { schedule, .. }, which @ ("run_again" | "pause")) => {
|
||||
let path = format!(
|
||||
"/schedules/{}/{}",
|
||||
terminal::encode(&schedule),
|
||||
if which == "pause" { "pause" } else { "run" }
|
||||
);
|
||||
let h = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||
return;
|
||||
};
|
||||
match server::request(
|
||||
&app_url,
|
||||
&token,
|
||||
"POST",
|
||||
&path,
|
||||
Some(&serde_json::json!({})),
|
||||
) {
|
||||
Err(e) if e.status == 401 => token_refused(&h),
|
||||
Err(e) => log::write(&format!("a notification's action failed: {}", e.detail)),
|
||||
Ok(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait before following the event stream again: after it closed or broke off, and
|
||||
/// (longer) on a server that has none.
|
||||
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
@@ -1520,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
|
||||
} else {
|
||||
r.title
|
||||
};
|
||||
let body = match r.told {
|
||||
watch::Told::Result => &words.scheduled,
|
||||
watch::Told::Failed => &words.scheduled_failed,
|
||||
watch::Told::Pause => &words.scheduled_pause,
|
||||
};
|
||||
let _ = handle
|
||||
let body = r.body.clone().unwrap_or_else(|| {
|
||||
match r.told {
|
||||
watch::Told::Result => &words.scheduled,
|
||||
watch::Told::Failed => &words.scheduled_failed,
|
||||
watch::Told::Pause => &words.scheduled_pause,
|
||||
}
|
||||
.clone()
|
||||
});
|
||||
let id = app.watch.notes.add(watch::Note::Run {
|
||||
schedule: r.schedule.clone(),
|
||||
run: r.run.clone(),
|
||||
});
|
||||
let mut note = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body(body)
|
||||
.show();
|
||||
.body(body);
|
||||
if !r.actions.is_empty() {
|
||||
// One action type per set of actions; registered again, it takes the labels
|
||||
// this run came with (the person's language may have changed).
|
||||
let kind = format!(
|
||||
"schedule-{}",
|
||||
r.actions
|
||||
.iter()
|
||||
.map(|(a, _)| a.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
);
|
||||
let actions = r
|
||||
.actions
|
||||
.iter()
|
||||
.map(|(a, label)| Action::builder(a, label).build())
|
||||
.collect();
|
||||
let _ = handle
|
||||
.notification()
|
||||
.register_action_types(vec![ActionType::builder(&kind)
|
||||
.actions(actions)
|
||||
.build()]);
|
||||
note = note.action_type_id(kind);
|
||||
}
|
||||
let _ = note.show();
|
||||
}
|
||||
true
|
||||
});
|
||||
@@ -1640,6 +1906,11 @@ pub fn run() {
|
||||
sealed_open,
|
||||
sealed_close,
|
||||
attach_pick,
|
||||
library_upload,
|
||||
voice_arm,
|
||||
transcribe,
|
||||
speak,
|
||||
draw,
|
||||
attach_dropped,
|
||||
attach_bytes,
|
||||
set_theme,
|
||||
@@ -1700,6 +1971,7 @@ pub fn run() {
|
||||
sealed: sealed::Pages::default(),
|
||||
watch: watch::Watch::default(),
|
||||
dropped: Mutex::new(Vec::new()),
|
||||
mic_until: Mutex::new(None),
|
||||
});
|
||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||
// starts: the title bar's menu has every item the tray has.
|
||||
@@ -1711,6 +1983,12 @@ pub fn run() {
|
||||
watch_updates(handle.clone());
|
||||
watch_chats(handle.clone());
|
||||
watch_events(handle.clone());
|
||||
{
|
||||
let h = handle.clone();
|
||||
let _ = handle
|
||||
.notification()
|
||||
.on_action(move |done| notification_action(&h, done));
|
||||
}
|
||||
{
|
||||
let h = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
+268
-11
@@ -650,12 +650,17 @@ fn header_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of a `multipart/form-data` request with one file in the field `file`.
|
||||
fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
/// The body of a `multipart/form-data` request with one file in the field `field`.
|
||||
fn multipart(boundary: &str, field: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
multipart_typed(boundary, field, name, "application/octet-stream", bytes)
|
||||
}
|
||||
|
||||
/// The same, with the file's own media type (a recording says what it is: `audio/webm`).
|
||||
fn multipart_typed(boundary: &str, field: &str, name: &str, kind: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(bytes.len() + 256);
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: {kind}\r\n\r\n",
|
||||
header_name(name)
|
||||
)
|
||||
.as_bytes(),
|
||||
@@ -685,6 +690,224 @@ pub fn upload(
|
||||
} else {
|
||||
format!("/attachments?chat={chat}")
|
||||
};
|
||||
let v = send_file(app, token, &path, "file", name, bytes)?;
|
||||
v.get("attachment")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
||||
}
|
||||
|
||||
/// Send one file into the person's library, into the directory `dir` (`/files/upload`, the
|
||||
/// explorer's upload): the server's listing of that directory afterwards, as JSON.
|
||||
pub fn upload_to_library(
|
||||
app: &str,
|
||||
token: &str,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Value, ApiError> {
|
||||
if !dir.starts_with('/') || dir.chars().any(|c| c.is_control()) {
|
||||
return Err(ApiError::new(400, format!("Not a folder: {dir}")));
|
||||
}
|
||||
let path = format!("/files/upload?path={}", crate::terminal::encode(dir));
|
||||
send_file(app, token, &path, "files", name, bytes)
|
||||
}
|
||||
|
||||
// --- Voice and pictures: the OpenAI-compatible routes of `/v1`, under the `models` scope ------------
|
||||
|
||||
/// The most a recording may be here: the server's own limit is 25 MB.
|
||||
pub const RECORDING_LIMIT: u64 = 25 * 1024 * 1024;
|
||||
/// The most a reply read aloud may be, as audio.
|
||||
pub const SPEECH_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
/// The most a generated picture may be.
|
||||
pub const DRAWN_LIMIT: u64 = 24 * 1024 * 1024;
|
||||
/// The media types a recording may say it is; anything else goes as `audio/webm`.
|
||||
const RECORDING_TYPES: [&str; 5] = [
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
];
|
||||
|
||||
/// A recording's media type as it may be sent: one of ours, its parameters (`;codecs=opus`) off.
|
||||
pub fn recording_type(kind: &str) -> &'static str {
|
||||
let bare = kind
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
RECORDING_TYPES
|
||||
.iter()
|
||||
.find(|t| **t == bare)
|
||||
.copied()
|
||||
.unwrap_or("audio/webm")
|
||||
}
|
||||
|
||||
/// A recording, in words: `POST /v1/audio/transcriptions` (the instance's own model, the
|
||||
/// account's language). The text the server heard.
|
||||
pub fn transcribe(base: &str, token: &str, kind: &str, bytes: &[u8]) -> Result<String, ApiError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(ApiError::new(400, "The recording was empty."));
|
||||
}
|
||||
let kind = recording_type(kind);
|
||||
let ext = kind
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("webm")
|
||||
.replace("mpeg", "mp3");
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let boundary = format!("llembas-desktop-{nanos:x}");
|
||||
let res = agent(Duration::from_secs(300))
|
||||
.post(&format!("{base}/v1/audio/transcriptions"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.set("accept", "application/json")
|
||||
.set(
|
||||
"content-type",
|
||||
&format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.send_bytes(&multipart_typed(
|
||||
&boundary,
|
||||
"file",
|
||||
&format!("speech.{ext}"),
|
||||
kind,
|
||||
bytes,
|
||||
))?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
Ok(v.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// `bytes` of a media type of `kinds` (by its prefix), as a `data:` URL; at most `limit`.
|
||||
fn as_data_url(res: ureq::Response, prefix: &str, limit: u64) -> Result<String, ApiError> {
|
||||
let kind = res
|
||||
.content_type()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let fits = if prefix == "image/" {
|
||||
IMAGE_TYPES.contains(&kind.as_str())
|
||||
} else {
|
||||
kind.starts_with(prefix)
|
||||
&& kind
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "/-+.".contains(c))
|
||||
};
|
||||
if !fits {
|
||||
return Err(ApiError::new(
|
||||
415,
|
||||
format!("Not what was asked for: {kind}"),
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
res.into_reader()
|
||||
.take(limit)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||
if bytes.len() as u64 >= limit {
|
||||
return Err(ApiError::new(413, "The server's answer is too large."));
|
||||
}
|
||||
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||
}
|
||||
|
||||
/// A reply read aloud: `POST /v1/audio/speech` in the account's own voice and speed. The audio,
|
||||
/// as a `data:` URL the page plays.
|
||||
pub fn speak(base: &str, token: &str, text: &str) -> Result<String, ApiError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(ApiError::new(400, "There is nothing to read aloud."));
|
||||
}
|
||||
let res = agent(Duration::from_secs(300))
|
||||
.post(&format!("{base}/v1/audio/speech"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.send_json(serde_json::json!({ "input": text, "response_format": "mp3" }))?;
|
||||
as_data_url(res, "audio/", SPEECH_LIMIT)
|
||||
}
|
||||
|
||||
/// The id of one of the person's own files in a URL `/v1/images/generations` answered with:
|
||||
/// `…/v1/files/<id>/content`, and nothing else. The picture is then fetched from this app's own
|
||||
/// server by that id -- never from the address the answer named.
|
||||
pub fn own_file_id(url: &str) -> Option<String> {
|
||||
let rest = url.split(['?', '#']).next()?;
|
||||
let tail = rest.strip_suffix("/content")?;
|
||||
let (head, id) = tail.rsplit_once('/')?;
|
||||
let ok = head.ends_with("/v1/files")
|
||||
&& !id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
ok.then(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Sizes a picture may be asked for: the instance rounds to what its workflow takes.
|
||||
const SIZES: [&str; 3] = ["1024x1024", "1344x768", "768x1344"];
|
||||
|
||||
/// Pictures drawn by the instance: `POST /v1/images/generations` (its own workflow and default
|
||||
/// checkpoint), each then read from the person's own files on this server. `data:` URLs.
|
||||
pub fn draw(base: &str, token: &str, prompt: &str, size: &str) -> Result<Vec<String>, ApiError> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err(ApiError::new(400, "Say what to draw."));
|
||||
}
|
||||
let mut body = serde_json::json!({ "prompt": prompt, "n": 1, "response_format": "url" });
|
||||
if SIZES.contains(&size) {
|
||||
body["size"] = Value::String(size.to_string());
|
||||
}
|
||||
let res = agent(Duration::from_secs(900))
|
||||
.post(&format!("{base}/v1/images/generations"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.set("accept", "application/json")
|
||||
.send_json(body)?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
let ids: Vec<String> = v
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|i| i.get("url").and_then(Value::as_str).and_then(own_file_id))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return Err(ApiError::new(
|
||||
0,
|
||||
"The server's answer had no picture of yours in it.",
|
||||
));
|
||||
}
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let res = agent(Duration::from_secs(60))
|
||||
.get(&format!("{base}/v1/files/{id}/content"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.call()?;
|
||||
as_data_url(res, "image/", DRAWN_LIMIT)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One `multipart/form-data` POST of one file to `path` on the app's door; the JSON answer.
|
||||
fn send_file(
|
||||
app: &str,
|
||||
token: &str,
|
||||
path: &str,
|
||||
field: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Value, ApiError> {
|
||||
let path = checked_path(path)?;
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
@@ -698,13 +921,9 @@ pub fn upload(
|
||||
"content-type",
|
||||
&format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.send_bytes(&multipart(&boundary, name, bytes))?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
v.get("attachment")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
||||
.send_bytes(&multipart(&boundary, field, name, bytes))?;
|
||||
res.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
|
||||
}
|
||||
|
||||
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||
@@ -957,9 +1176,45 @@ mod tests {
|
||||
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_picture_is_read_only_as_one_of_the_persons_own_files() {
|
||||
assert_eq!(
|
||||
own_file_id("https://ai.example/v1/files/abc_12-3/content").as_deref(),
|
||||
Some("abc_12-3")
|
||||
);
|
||||
assert_eq!(
|
||||
own_file_id("http://other/v1/files/x/content?t=1").as_deref(),
|
||||
Some("x")
|
||||
);
|
||||
assert_eq!(own_file_id("https://ai.example/v1/files/../content"), None);
|
||||
assert_eq!(own_file_id("https://ai.example/v1/files//content"), None);
|
||||
assert_eq!(own_file_id("https://ai.example/uploads/x/content"), None);
|
||||
assert_eq!(
|
||||
own_file_id("https://ai.example/v1/files/a%2Fb/content"),
|
||||
None
|
||||
);
|
||||
assert_eq!(own_file_id("javascript:alert(1)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_says_one_of_the_known_types() {
|
||||
assert_eq!(recording_type("audio/webm;codecs=opus"), "audio/webm");
|
||||
assert_eq!(recording_type("Audio/OGG"), "audio/ogg");
|
||||
assert_eq!(recording_type("text/html"), "audio/webm");
|
||||
let body = String::from_utf8(multipart_typed(
|
||||
"B",
|
||||
"file",
|
||||
"speech.webm",
|
||||
"audio/webm",
|
||||
b"x",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(body.contains("filename=\"speech.webm\"\r\nContent-Type: audio/webm\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_goes_as_one_multipart_field() {
|
||||
let body = multipart("XYZ", "a\"b\r\n.txt", b"hello");
|
||||
let body = multipart("XYZ", "file", "a\"b\r\n.txt", b"hello");
|
||||
let text = String::from_utf8(body).unwrap();
|
||||
assert!(text.starts_with(
|
||||
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n"
|
||||
@@ -967,6 +1222,8 @@ mod tests {
|
||||
assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
|
||||
assert_eq!(header_name(" "), "file");
|
||||
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf");
|
||||
let library = String::from_utf8(multipart("XYZ", "files", "b.md", b"x")).unwrap();
|
||||
assert!(library.contains("name=\"files\"; filename=\"b.md\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+172
-4
@@ -15,8 +15,15 @@
|
||||
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
||||
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
||||
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
||||
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
|
||||
//! the stream answers 404, and nothing is said.
|
||||
//! whether to pause it. A server that made the run's notification (`run.notification`, server
|
||||
//! 2.0.0-beta.9) gives its words: the task's name and the result's first lines -- shown here as on
|
||||
//! an unlocked phone, the desktop being the person's own -- and its actions with their labels in
|
||||
//! the person's language (`open`, `run_again`, and `pause` on a failure that asks). An older
|
||||
//! server's run is said by the schedule's title and the page's words, without actions. A server
|
||||
//! without the stream answers 404, and nothing is said.
|
||||
//!
|
||||
//! Every notification shown is remembered by its id (`Notes`), so a click on it or on one of its
|
||||
//! actions knows which chat or which run it was about.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -90,6 +97,8 @@ pub struct Watch {
|
||||
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
||||
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
||||
stop: Mutex<Arc<AtomicBool>>,
|
||||
/// The notifications shown, so a click on one knows what it was about.
|
||||
pub notes: Notes,
|
||||
}
|
||||
|
||||
/// How a scheduled task's run is told.
|
||||
@@ -108,6 +117,95 @@ pub enum Told {
|
||||
pub struct Ran {
|
||||
pub title: String,
|
||||
pub told: Told,
|
||||
pub schedule: String,
|
||||
pub run: String,
|
||||
/// The server's own words for it (`run.notification.body`): the result's first lines, or the
|
||||
/// failure said. None from a server that does not make them.
|
||||
pub body: Option<String>,
|
||||
/// The actions it offers, `(id, label)`, in the server's order: only those the app can do.
|
||||
pub actions: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The actions of a scheduled run's notification the app knows how to do.
|
||||
pub const RUN_ACTIONS: [&str; 3] = ["open", "run_again", "pause"];
|
||||
|
||||
/// The most of a result's first lines a notification carries: the server cuts them already.
|
||||
const BODY_MAX: usize = 400;
|
||||
|
||||
/// The words and actions of a run's own notification (`run.notification`), as far as they are
|
||||
/// usable: a body without control characters and not too long; actions the app knows, each with
|
||||
/// its label (the id when the server gave none).
|
||||
fn run_notification(run: &Value) -> (Option<String>, Vec<(String, String)>) {
|
||||
let Some(n) = run.get("notification").filter(|n| n.is_object()) else {
|
||||
return (None, Vec::new());
|
||||
};
|
||||
let body = n
|
||||
.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.map(|b| {
|
||||
b.chars()
|
||||
.map(|c| if c.is_control() && c != '\n' { ' ' } else { c })
|
||||
.take(BODY_MAX)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|b| !b.is_empty());
|
||||
let labels = n.get("labels");
|
||||
let actions = n
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|a| RUN_ACTIONS.contains(a))
|
||||
.map(|a| {
|
||||
let label = labels
|
||||
.and_then(|l| l.get(a))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.unwrap_or(a);
|
||||
(a.to_string(), label.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(body, actions)
|
||||
}
|
||||
|
||||
/// What a notification shown was about.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Note {
|
||||
/// A chat whose reply or work finished.
|
||||
Chat(String),
|
||||
/// A scheduled task's run.
|
||||
Run { schedule: String, run: String },
|
||||
}
|
||||
|
||||
/// The notifications shown, by id: the most recent `NOTES_KEPT`.
|
||||
#[derive(Default)]
|
||||
pub struct Notes {
|
||||
next: Mutex<i32>,
|
||||
by_id: Mutex<HashMap<i32, Note>>,
|
||||
}
|
||||
|
||||
const NOTES_KEPT: i32 = 200;
|
||||
|
||||
impl Notes {
|
||||
/// Remember a notification about to be shown; its id.
|
||||
pub fn add(&self, note: Note) -> i32 {
|
||||
let mut next = self.next.lock().unwrap();
|
||||
*next = next.checked_add(1).unwrap_or(1);
|
||||
let id = *next;
|
||||
let mut by_id = self.by_id.lock().unwrap();
|
||||
by_id.insert(id, note);
|
||||
by_id.retain(|k, _| *k > id - NOTES_KEPT);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i32) -> Option<Note> {
|
||||
self.by_id.lock().unwrap().get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
||||
@@ -212,6 +310,7 @@ impl Watch {
|
||||
.unwrap_or(0),
|
||||
);
|
||||
if let Some(told) = told {
|
||||
let (body, actions) = run_notification(run);
|
||||
out.push(Ran {
|
||||
title: row
|
||||
.get("title")
|
||||
@@ -219,6 +318,10 @@ impl Watch {
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
told,
|
||||
schedule: id.to_string(),
|
||||
run: run_id.to_string(),
|
||||
body,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -259,13 +362,75 @@ mod tests {
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn ran(id: &str, told: Told) -> Ran {
|
||||
fn ran(id: &str, run: &str, told: Told) -> Ran {
|
||||
Ran {
|
||||
title: format!("Task {id}"),
|
||||
told,
|
||||
schedule: id.into(),
|
||||
run: run.into(),
|
||||
body: None,
|
||||
actions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runs_own_notification_gives_its_first_lines_and_the_actions_the_app_can_do() {
|
||||
let w = Watch::default();
|
||||
let row = |run: Value| {
|
||||
json!({"version": "v", "schedules": [{
|
||||
"id": "s1", "title": "Waybread check", "notify": "matters", "failure_streak": 3,
|
||||
"last_run": run,
|
||||
}]})
|
||||
};
|
||||
w.look_schedules(&row(json!({"id": "r0", "status": "running"})));
|
||||
let out = w.look_schedules(&row(json!({"id": "r0", "status": "failed", "notified": true,
|
||||
"notification": {"title": "Waybread check", "body": "Failed 3 times\u{7} in a row. Pause it?",
|
||||
"actions": ["open", "run_again", "pause", "unknown"],
|
||||
"labels": {"open": "Otvoriť", "run_again": "Spustiť znova", "pause": ""}}})));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].told, Told::Pause);
|
||||
assert_eq!(
|
||||
(out[0].schedule.as_str(), out[0].run.as_str()),
|
||||
("s1", "r0")
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].body.as_deref(),
|
||||
Some("Failed 3 times in a row. Pause it?")
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].actions,
|
||||
vec![
|
||||
("open".to_string(), "Otvoriť".to_string()),
|
||||
("run_again".to_string(), "Spustiť znova".to_string()),
|
||||
("pause".to_string(), "pause".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notification_is_found_again_by_its_id() {
|
||||
let notes = Notes::default();
|
||||
let a = notes.add(Note::Chat("c1".into()));
|
||||
let b = notes.add(Note::Run {
|
||||
schedule: "s1".into(),
|
||||
run: "r1".into(),
|
||||
});
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(notes.get(a), Some(Note::Chat("c1".into())));
|
||||
assert_eq!(
|
||||
notes.get(b),
|
||||
Some(Note::Run {
|
||||
schedule: "s1".into(),
|
||||
run: "r1".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(notes.get(b + 1), None);
|
||||
for _ in 0..NOTES_KEPT {
|
||||
notes.add(Note::Chat("x".into()));
|
||||
}
|
||||
assert_eq!(notes.get(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
||||
let w = Watch::default();
|
||||
@@ -290,7 +455,10 @@ mod tests {
|
||||
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||
("d", "never", 0, Some(("r5", "ok", false))),
|
||||
]));
|
||||
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![ran("a", "r2", Told::Result), ran("b", "r3", Told::Result)]
|
||||
);
|
||||
// The same list again (the server told a device meanwhile): not said twice.
|
||||
assert!(w
|
||||
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
"csp": "default-src 'self'; img-src 'self' data:; media-src data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
||||
LLeMbas v2.0.0-beta.10 (6d1ab36371c8492ee2590fbe25d112d73513a9ae) 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
-2
@@ -8,6 +8,8 @@
|
||||
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
|
||||
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
|
||||
// not in the table is a 404, as an older server would answer.
|
||||
// hold ["GET /files?path=%2F", ...] -- answers that wait (a slow server) until the test
|
||||
// lets them go with `__mock.release(key)`; the table is read when they are let go.
|
||||
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
|
||||
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
|
||||
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
|
||||
@@ -29,7 +31,16 @@
|
||||
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
|
||||
}
|
||||
|
||||
const held = [] // [{ key, go }] answers waiting for `release`
|
||||
|
||||
function route(method, path) {
|
||||
if ((config.hold || []).includes(`${method} ${path}`)) {
|
||||
return new Promise((resolve) => held.push({ key: `${method} ${path}`, go: resolve })).then(() => answer(method, path))
|
||||
}
|
||||
return answer(method, path)
|
||||
}
|
||||
|
||||
function answer(method, path) {
|
||||
const routes = config.routes || {}
|
||||
const bare = path.split("?")[0]
|
||||
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
|
||||
@@ -100,8 +111,12 @@
|
||||
return ""
|
||||
case "terminal_word":
|
||||
return "lembas"
|
||||
default:
|
||||
return null
|
||||
default: {
|
||||
// A command the test answers itself: `commands: { name: value }` (an `__error` rejects).
|
||||
const own = (config.commands || {})[cmd]
|
||||
if (own && own.__error) return Promise.reject(own.__error)
|
||||
return own === undefined ? null : JSON.parse(JSON.stringify(own))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +141,17 @@
|
||||
|
||||
window.__mock = {
|
||||
calls,
|
||||
/** Let the held answers for `key` go, in the order they were asked; stop holding new ones. */
|
||||
release(key) {
|
||||
config.hold = (config.hold || []).filter((k) => k !== key)
|
||||
const going = held.filter((x) => x.key === key)
|
||||
for (const x of going) held.splice(held.indexOf(x), 1)
|
||||
for (const x of going) x.go()
|
||||
},
|
||||
/** Change the server's answer for a route from now on. */
|
||||
answer(key, value) {
|
||||
config.routes[key] = value
|
||||
},
|
||||
frame,
|
||||
emit: dispatch,
|
||||
/** The calls of one command, or of `api` with one method and path prefix. */
|
||||
|
||||
+1295
-20
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"tag": "v2.0.0-beta.8",
|
||||
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||
"sha256": "6fd6d9d530c005dc52ad7dfcd61b3515b29955aa6d55365d35df3936fb7f29da"
|
||||
"tag": "v2.0.0-beta.10",
|
||||
"commit": "6d1ab36371c8492ee2590fbe25d112d73513a9ae",
|
||||
"sha256": "9e5ac5c6f1fd04d0edba89f492b817194439f980566d329975f2035c2012c8cc"
|
||||
}
|
||||
+338
-32
@@ -431,6 +431,34 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
||||
.chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); }
|
||||
.chatrow__state.is-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; }
|
||||
.chatrow__state.is-new { background: var(--leaf); }
|
||||
/* A chat folder's head: chevron · folder · name · count, and its own ⋯ beside it on one track. */
|
||||
.list__folder { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
|
||||
.folderrow {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 16px minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-2) 0 calc(var(--sp-2) + var(--depth, 0) * var(--sp-4));
|
||||
text-align: left;
|
||||
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.folderrow:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
.folderrow__chev, .folderrow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||
.folderrow__chev .icon { width: 12px; height: 12px; }
|
||||
.folderrow__icon .icon { width: 14px; height: 14px; }
|
||||
.folderrow__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.folderrow__count { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.folderrow__more { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); color: var(--ink-faint); background: transparent; border: 0; border-radius: var(--radius-lg); cursor: pointer; opacity: 0; }
|
||||
.list__folder:hover .folderrow__more, .folderrow__more:focus-visible { opacity: 1; }
|
||||
.folderrow__more:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
@media (hover: none) { .folderrow__more { opacity: 1; } }
|
||||
.chatrow.is-filed { padding-left: calc(var(--sp-2) + var(--depth, 1) * var(--sp-4)); }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } }
|
||||
|
||||
@@ -469,7 +497,29 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
||||
.head__title { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.head__place { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
|
||||
.head__tools { display: flex; gap: var(--sp-1); align-items: center; justify-content: end; }
|
||||
.main[data-new="true"] .head { display: none; }
|
||||
/* The chat's header: the title, where it works, then its settings and tools at the right. The
|
||||
settings take what room is left after the title's minimum; where they do not fit they fold
|
||||
behind Chat settings (chat.js `fitHead`, measured), which opens them as a wrapped row of their
|
||||
own under the header. */
|
||||
.head.chathead {
|
||||
grid-template-columns: minmax(min(6rem, 30%), max-content) minmax(0, 1fr) minmax(0, max-content) auto auto;
|
||||
grid-template-areas: "title place set fold tools";
|
||||
column-gap: var(--sp-2);
|
||||
}
|
||||
.chathead > .head__title { grid-area: title; }
|
||||
.chathead > .head__place { grid-area: place; overflow: hidden; }
|
||||
.chathead > .head__tools { grid-area: tools; }
|
||||
.chathead__fold { grid-area: fold; }
|
||||
.chathead__fold[hidden] { display: none; }
|
||||
.chathead__settings { grid-area: set; display: flex; flex-wrap: nowrap; gap: var(--sp-1); align-items: center; justify-content: start; min-width: 0; overflow: hidden; }
|
||||
.chathead__settings > * { flex: none; }
|
||||
.chathead__settings:empty { display: none; }
|
||||
.chathead[data-fold="true"] .chathead__settings { display: none; }
|
||||
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title place set fold tools" "row row row row row"; row-gap: var(--sp-2); }
|
||||
.chathead[data-fold="true"][data-unfold="true"] .chathead__settings { grid-area: row; display: flex; flex-wrap: wrap; justify-content: start; overflow: visible; padding-bottom: var(--sp-1); }
|
||||
.chathead__settings .cchip { max-width: min(var(--w-field), 100%); }
|
||||
.head__place .cchip { min-width: 0; }
|
||||
.head__place > .cchip:last-child { flex: 0 1 auto; }
|
||||
.main > .head { grid-row: 1; }
|
||||
.main > .thread { grid-row: 2; }
|
||||
.main > .dock { grid-row: 3; }
|
||||
@@ -502,7 +552,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
||||
|
||||
/* A new chat is the same page, empty: the greeting low in the space above the composer, which
|
||||
sits in the middle of the window rather than at its foot. */
|
||||
.main[data-new="true"] { grid-template-rows: 0 minmax(0, 1fr) auto minmax(0, 0.8fr); }
|
||||
.main[data-new="true"] { grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 0.8fr); }
|
||||
.main[data-new="true"] .thread { display: grid; align-content: end; padding-bottom: var(--sp-6); }
|
||||
.hello { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; }
|
||||
.hello__mark { width: var(--sp-10); height: var(--sp-10); }
|
||||
@@ -1140,6 +1190,18 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dialog::backdrop { background: var(--scrim); }
|
||||
.dialog--wide { width: min(var(--w-prose), calc(100vw - 2 * var(--sp-4))); }
|
||||
|
||||
/* Draw a picture (draw.js): the prompt, a shape and two buttons on one line, the pictures below. */
|
||||
.draw .dialog__title { display: flex; gap: var(--sp-2); align-items: center; }
|
||||
.draw__prompt { width: 100%; }
|
||||
.draw__bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: var(--sp-2); align-items: center; }
|
||||
.draw__size { justify-self: start; max-width: 100%; }
|
||||
.draw__out { display: grid; gap: var(--sp-3); max-height: 60vh; overflow-y: auto; }
|
||||
.draw__out:empty { display: none; }
|
||||
.draw__pic { display: grid; gap: var(--sp-1); margin: 0; }
|
||||
.draw__pic img { display: block; width: 100%; height: auto; border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.draw__pic figcaption { font-size: var(--text-xs); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
|
||||
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
|
||||
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
|
||||
@@ -1177,6 +1239,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
||||
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
|
||||
/* A hit found inside: its title over the snippet, the words that matched marked. */
|
||||
.palette__label--two { display: grid; padding: var(--sp-1) 0; white-space: normal; }
|
||||
.palette__title, .palette__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.palette__snippet { font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
.palette__snippet mark { color: var(--ink); background: var(--surface-active); border-radius: var(--radius-sm); }
|
||||
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
|
||||
|
||||
@@ -1243,11 +1310,16 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.composer__files { justify-content: start; }
|
||||
.composer__bar {
|
||||
display: grid;
|
||||
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto auto; }
|
||||
/* Dictate: a round tool beside Send; while it records it says so in the accent. */
|
||||
.composer__mic { width: var(--round); height: var(--round); border-radius: var(--radius-full); }
|
||||
.composer .composer__mic.is-recording { color: var(--accent-ink); background: var(--accent); }
|
||||
.composer__mic.is-busy, .msg__speak.is-busy { opacity: 0.6; cursor: progress; }
|
||||
.msg__speak[aria-pressed="true"] { color: var(--accent); }
|
||||
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
|
||||
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
|
||||
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
|
||||
@@ -1364,44 +1436,206 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
}
|
||||
.tabs__tab[aria-selected="true"] { color: var(--ink); background: var(--surface-active); }
|
||||
|
||||
.files { display: grid; align-content: start; }
|
||||
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
|
||||
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
|
||||
.files__where { min-width: 0; }
|
||||
.files__where select { width: 100%; height: var(--control-h-sm); }
|
||||
.files__tree { min-height: 0; }
|
||||
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
|
||||
.pane__pick { width: 100%; }
|
||||
.pane__note { padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
.files__tree > .btn { justify-self: start; }
|
||||
|
||||
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
|
||||
.tree__row {
|
||||
width: 100%;
|
||||
height: var(--control-h-sm);
|
||||
/* --- Files: two explorers in the web's shape ------------------------------------------------
|
||||
The page's whole width under its head, like a file manager: where you are and what can be done
|
||||
there, then the folder tree, the list and the chosen file's details side by side, each its own
|
||||
scroller. The list is one grid with a track per column and every row a subgrid of it, so a long
|
||||
name never pushes the type, size or date out of line: the name's track is the one that gives.
|
||||
Narrower, the details go first (a file then opens in the viewer), then the tree (the crumbs
|
||||
are the way up), then the type and date columns. */
|
||||
.page__body.files { display: grid; grid-template-rows: minmax(0, 1fr); padding: 0; overflow: hidden; }
|
||||
.page__body.files > * { max-width: none; margin: 0; }
|
||||
.fx { display: grid; grid-template-rows: auto auto minmax(0, 1fr); min-width: 0; min-height: 0; container: fx / inline-size; }
|
||||
.fx > .fx__bar { grid-row: 1; }
|
||||
.fx > .fx__status { grid-row: 2; }
|
||||
.fx > .fx__panes { grid-row: 3; }
|
||||
.fx[hidden] { display: none; }
|
||||
.fx__bar {
|
||||
display: grid;
|
||||
grid-template-columns: calc(var(--depth, 0) * var(--sp-4)) 1rem 1rem minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "where crumbs tools";
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--header-height);
|
||||
padding: var(--sp-1) var(--sp-4);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.fx__where { grid-area: where; min-width: 0; max-width: 18rem; }
|
||||
.fx__bar > .crumbs { grid-area: crumbs; }
|
||||
.fx__where:empty { display: none; }
|
||||
.fx__tools { grid-area: tools; display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.fx__status { margin: 0; padding: var(--sp-2) var(--sp-4); border-bottom: var(--border-w) solid var(--border); white-space: pre-line; }
|
||||
.fx__status[hidden] { display: none; }
|
||||
.pane__pick { width: 100%; height: var(--control-h-sm); }
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
font: var(--text-sm) var(--font-mono);
|
||||
}
|
||||
.crumbs__sep { color: var(--ink-faint); }
|
||||
.crumbs__link {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-1);
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.crumbs__link:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
.crumbs__here { padding: 0 var(--sp-1); color: var(--ink); font-weight: 600; }
|
||||
|
||||
.fx__panes { display: grid; grid-template-columns: minmax(11rem, 14rem) minmax(0, 1fr) minmax(14rem, 18rem); min-width: 0; min-height: 0; }
|
||||
.fx__tree, .fx__list, .fx__details { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
||||
.fx__tree { padding: var(--sp-2); border-right: var(--border-w) solid var(--border); background: var(--surface); }
|
||||
.fx__list { padding: 0 var(--sp-4) var(--sp-8); container: fxlist / inline-size; }
|
||||
.fx__details { display: grid; align-content: start; gap: var(--sp-3); padding: var(--sp-4); border-left: var(--border-w) solid var(--border); background: var(--surface); }
|
||||
.fx[data-empty="true"] .fx__tree, .fx[data-empty="true"] .fx__details { display: none; }
|
||||
.fx[data-empty="true"] .fx__panes { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
/* The folder tree: a chevron opens a folder, its name lists it. */
|
||||
.ftree, .ftree ul { list-style: none; margin: 0; padding: 0; }
|
||||
.ftree__line { display: grid; grid-template-columns: calc(var(--depth, 0) * var(--sp-3)) 1.25rem minmax(0, 1fr); align-items: center; }
|
||||
.ftree__line::before { content: ""; }
|
||||
.ftree__chevron { display: grid; place-items: center; width: 1.25rem; height: var(--control-h-sm); padding: 0; color: var(--ink-faint); background: transparent; border: 0; cursor: pointer; }
|
||||
.ftree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); }
|
||||
.ftree__chevron[aria-expanded="true"] .icon { transform: rotate(90deg); }
|
||||
.ftree__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem minmax(0, 1fr);
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-2);
|
||||
text-align: left;
|
||||
font: var(--text-sm) var(--font-body);
|
||||
text-align: left;
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ftree__row:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
.ftree__row.is-current { color: var(--ink); background: var(--surface-active); font-weight: 600; }
|
||||
.ftree__root { margin-bottom: var(--sp-1); }
|
||||
.ftree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* The list. */
|
||||
.frows {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(3.5rem, auto) minmax(4.5rem, auto) minmax(6.5rem, auto) var(--control-h-sm);
|
||||
column-gap: var(--sp-4);
|
||||
}
|
||||
.frow {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
min-height: var(--row-h);
|
||||
margin: 0 calc(-1 * var(--sp-2));
|
||||
padding: 0 var(--sp-2);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.frow:not(.frow--head):hover { background: var(--surface-hover); }
|
||||
.frow.is-chosen { background: var(--surface-active); }
|
||||
.frow--head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg);
|
||||
color: var(--ink-faint);
|
||||
font: var(--text-xs) var(--font-ui);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 0;
|
||||
}
|
||||
.frow__name { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.frow__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
min-height: var(--row-h);
|
||||
padding: 0;
|
||||
font: var(--text-sm) var(--font-body);
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* The indent's track needs an item of its own, or the chevron would sit in it. */
|
||||
.tree__row::before { content: ""; }
|
||||
.tree__row:hover { background: var(--surface-hover); }
|
||||
.tree__row .icon { color: var(--ink-muted); }
|
||||
.tree__chevron { display: grid; place-items: center; width: 1rem; }
|
||||
.tree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); }
|
||||
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
|
||||
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
/* The whole row opens what it names; the buttons at its end stay on top. */
|
||||
.frow__open::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
|
||||
.frow__open:focus-visible { outline: none; }
|
||||
.frow__open:focus-visible::after { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); }
|
||||
.frow__name .badge, .frow__actions { position: relative; z-index: 1; }
|
||||
.frow__icon { display: inline-flex; color: var(--ink-muted); }
|
||||
.frow__icon--dir, .frow__icon--mount { color: var(--accent); }
|
||||
.frow__icon--code, .frow__icon--html { color: var(--leaf); }
|
||||
.frow__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.frow__type { font: var(--text-xs) var(--font-mono); color: var(--ink-faint); white-space: nowrap; }
|
||||
.frow__size, .frow__when { font-size: var(--text-sm); color: var(--ink-muted); white-space: nowrap; text-align: right; }
|
||||
.frow--head .frow__size, .frow--head .frow__when { font-size: var(--text-xs); }
|
||||
.frow__actions { display: flex; justify-content: flex-end; }
|
||||
.frow__more { opacity: 0; }
|
||||
.frow:hover .frow__more, .frow__more:focus-visible, .frow__more[aria-expanded="true"] { opacity: 1; }
|
||||
@media (hover: none) { .frow__more { opacity: 1; } }
|
||||
.badge { padding: 0 var(--sp-1); font: var(--text-xs) var(--font-ui); color: var(--ink-muted); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); white-space: nowrap; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fx__empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4); text-align: center; color: var(--ink-muted); }
|
||||
.fx__empty .icon { width: 24px; height: 24px; color: var(--leaf); }
|
||||
|
||||
/* The chosen file. */
|
||||
.fdetails__none { display: grid; justify-items: center; gap: var(--sp-2); padding: var(--sp-8) 0; text-align: center; color: var(--ink-muted); font-size: var(--text-sm); }
|
||||
.fdetails__none .icon { width: 24px; height: 24px; color: var(--ink-faint); }
|
||||
.fdetails__id { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; }
|
||||
.fdetails__name { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow-wrap: anywhere; }
|
||||
.fdetails__facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-sm); }
|
||||
.fdetails__facts dt { color: var(--ink-faint); }
|
||||
.fdetails__facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.fdetails__do { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.fdetails__preview { min-width: 0; }
|
||||
.fdetails__text { margin: 0; max-height: 24rem; overflow: auto; padding: var(--sp-3); font: var(--text-xs) / var(--leading-normal) var(--font-mono); white-space: pre; color: var(--ink); background: var(--bg); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); }
|
||||
.fdetails__note { font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
|
||||
/* Loading, still waiting, refused: the words, then Try again on the same line when it fits. */
|
||||
.fnote { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-1); padding-inline-start: calc(var(--sp-1) + var(--depth, 0) * var(--sp-3)); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
.ftree .fnote { font-size: var(--text-xs); }
|
||||
.fnote.is-error { color: var(--warning); }
|
||||
.fnote > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.fnote__retry { flex: none; }
|
||||
|
||||
/* By the room the page has, not the window's: the sidebar takes its share first. */
|
||||
@container fx (max-width: 60rem) {
|
||||
.fx__panes { grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr); }
|
||||
.fx__details { display: none; }
|
||||
}
|
||||
@container fx (max-width: 40rem) {
|
||||
.fx__panes { grid-template-columns: minmax(0, 1fr); }
|
||||
.fx__tree { display: none; }
|
||||
.fx__bar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "where where" "crumbs tools"; padding: var(--sp-1) var(--sp-3); }
|
||||
.fx__where { max-width: none; }
|
||||
.fx__list { padding: 0 var(--sp-3) var(--sp-8); }
|
||||
}
|
||||
/* A list narrower than its columns keeps the name and the size; the type and date go. */
|
||||
@container fxlist (max-width: 28rem) {
|
||||
.frows { grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm); column-gap: var(--sp-2); }
|
||||
.frows .frow__type, .frows .frow__when { display: none; }
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.frow, .frow__open { min-height: var(--tap-min); }
|
||||
}
|
||||
|
||||
/* An empty page: an icon, a heading, a sentence, centred. */
|
||||
.empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4) var(--sp-4); text-align: center; }
|
||||
@@ -1491,6 +1725,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.sched + .sched__history { margin-top: var(--sp-6); }
|
||||
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
|
||||
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
/* Where it runs: the computer over its folder; a computer that cannot take the run says so in colour. */
|
||||
.fact__path { display: block; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||
.fact.is-warning .fact__hint { color: var(--warning); }
|
||||
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
|
||||
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
|
||||
.sched__desc.is-error { color: var(--warning); }
|
||||
@@ -1629,6 +1866,73 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.findings__item { min-width: 0; overflow-wrap: anywhere; }
|
||||
.ask__subagent { color: var(--ink); }
|
||||
|
||||
/* --- Archived chats: icon · title over its folder and date · Unarchive · more, on known tracks. */
|
||||
|
||||
.arch { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||
.arow {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr) auto var(--control-h-sm);
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-1) 0;
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.arow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||
.arow__icon.is-agent { color: var(--leaf); }
|
||||
.arow__icon .icon { width: 14px; height: 14px; }
|
||||
.arow__open {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-1) 0;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.arow__open:hover .arow__title { text-decoration: underline; }
|
||||
.arow__title, .arow__sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.arow__title { font: var(--text-sm) / var(--leading-tight) var(--font-body); }
|
||||
.arow__sub { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.arch__more { margin-top: var(--sp-3); }
|
||||
.arch__page { display: grid; gap: var(--sp-3); }
|
||||
.head__unarchive { white-space: nowrap; }
|
||||
|
||||
/* --- A chat's tools and skills (scope.js): switches in a popover, the allowed commands below. */
|
||||
|
||||
.scope { max-height: min(70vh, 32rem); overflow-y: auto; }
|
||||
.scope .pop__actions { justify-content: flex-start; }
|
||||
.scope .pop__actions .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h-sm); }
|
||||
.scope__list { display: grid; gap: var(--sp-2); }
|
||||
.scope__row span { overflow-wrap: anywhere; }
|
||||
.scope__hint { margin: 0; font-size: var(--text-xs); }
|
||||
.scope__allow { display: grid; gap: var(--sp-1); margin: 0; padding: 0; list-style: none; }
|
||||
.scope__allow code { font: var(--text-xs) var(--font-mono); overflow-wrap: anywhere; }
|
||||
|
||||
/* --- Memory: a new one on top, then each record over its group and date · Edit · more. */
|
||||
|
||||
.mnew { display: grid; gap: var(--sp-2); }
|
||||
.mnew textarea { width: 100%; }
|
||||
.mnew__foot { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; }
|
||||
.mnew__foot select { justify-self: start; max-width: 100%; }
|
||||
.mrow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.mrow.is-editing { grid-template-columns: minmax(0, 1fr); }
|
||||
.mrow.is-editing textarea { width: 100%; }
|
||||
.mrow.is-marked { background: var(--surface-active); border-radius: var(--radius-lg); padding-inline: var(--sp-2); }
|
||||
.mrow__words { display: grid; gap: var(--sp-1); min-width: 0; }
|
||||
.mrow__text { margin: 0; font: var(--text-sm) / var(--leading-normal) var(--font-body); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.mrow__sub { margin: 0; font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
|
||||
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||
|
||||
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
||||
@@ -1646,6 +1950,8 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
||||
.head__place { grid-column: 1 / -1; grid-row: 2; }
|
||||
.head__place:empty { display: none; }
|
||||
.cchip { max-width: var(--w-field-xs); }
|
||||
.head.chathead { grid-template-columns: minmax(min(6rem, 30%), 1fr) minmax(0, max-content) auto auto; grid-template-areas: "title set fold tools" "place place place place"; }
|
||||
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title set fold tools" "place place place place" "row row row row"; }
|
||||
}
|
||||
|
||||
/* 48rem (--bp-narrow): the sidebar is a drawer over the page, opened from the title bar. */
|
||||
|
||||
+892
-3
File diff suppressed because it is too large.
Load diff
+51
-11
@@ -53,6 +53,8 @@
|
||||
<div class="side__nav">
|
||||
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
||||
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
||||
<button type="button" class="navrow" id="nav-archived" data-view="archived"><span class="navrow__icon"></span><span data-i18n>Archived</span></button>
|
||||
<button type="button" class="navrow" id="nav-memory" data-view="memory"><span class="navrow__icon"></span><span data-i18n>Memory</span></button>
|
||||
</div>
|
||||
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
||||
<div class="side__foot">
|
||||
@@ -62,9 +64,11 @@
|
||||
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
||||
|
||||
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
||||
<header class="head">
|
||||
<header class="head chathead" id="chat-head" data-fold="false" data-unfold="false">
|
||||
<h1 class="head__title" id="chat-title"></h1>
|
||||
<div class="head__place" id="chat-place"></div>
|
||||
<div class="chathead__settings" id="chat-settings" role="toolbar" aria-label="Chat settings" data-i18n-aria-label></div>
|
||||
<button type="button" class="tool chathead__fold" id="chat-fold" aria-expanded="false" aria-controls="chat-settings" aria-label="Chat settings" title="Chat settings" data-i18n-aria-label data-i18n-title></button>
|
||||
<div class="head__tools" id="chat-tools"></div>
|
||||
</header>
|
||||
<div class="thread" id="thread">
|
||||
@@ -82,6 +86,7 @@
|
||||
<div class="composer__bar">
|
||||
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||
<div class="composer__chips" id="composer-chips"></div>
|
||||
<button type="button" class="tool composer__mic" id="dictate" hidden aria-pressed="false"></button>
|
||||
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
@@ -97,19 +102,36 @@
|
||||
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
|
||||
</header>
|
||||
<div class="page__body files">
|
||||
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where" id="local-where"></div>
|
||||
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
<section class="fx" id="pane-local" aria-label="This computer" data-i18n-aria-label>
|
||||
<div class="fx__bar">
|
||||
<div class="fx__where" id="local-where"></div>
|
||||
<nav class="crumbs" id="local-crumbs" aria-label="Where you are" data-i18n-aria-label></nav>
|
||||
<div class="fx__tools">
|
||||
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx__panes">
|
||||
<aside class="fx__tree" id="local-tree" aria-label="Folders" data-i18n-aria-label></aside>
|
||||
<div class="fx__list" id="local-list"></div>
|
||||
<aside class="fx__details" id="local-details" aria-label="Details" data-i18n-aria-label></aside>
|
||||
</div>
|
||||
<div class="files__tree" id="local-tree"></div>
|
||||
</section>
|
||||
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where muted" id="server-where"></div>
|
||||
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
<section class="fx" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||
<div class="fx__bar">
|
||||
<div class="fx__where"></div>
|
||||
<nav class="crumbs" id="server-crumbs" aria-label="Where you are" data-i18n-aria-label></nav>
|
||||
<div class="fx__tools">
|
||||
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="status fx__status" id="server-status" hidden></p>
|
||||
<div class="fx__panes">
|
||||
<aside class="fx__tree" id="server-tree" aria-label="Folders" data-i18n-aria-label></aside>
|
||||
<div class="fx__list" id="server-list"></div>
|
||||
<aside class="fx__details" id="server-details" aria-label="Details" data-i18n-aria-label></aside>
|
||||
</div>
|
||||
<div class="files__tree" id="server-tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -123,6 +145,24 @@
|
||||
<div class="page__body" id="scheduled-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-archived" hidden aria-labelledby="archived-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="archived-title" data-i18n>Archived</h1>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><button type="button" class="tool" id="archived-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||
</header>
|
||||
<div class="page__body" id="archived-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-memory" hidden aria-labelledby="memory-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="memory-title" data-i18n>Memory</h1>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><button type="button" class="tool" id="memory-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||
</header>
|
||||
<div class="page__body" id="memory-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Archived chats: the ones put away from the sidebar with Archive chat, newest first. Each can be
|
||||
// opened (read, and written to, as any chat), taken back into the sidebar (Restore chat), or deleted.
|
||||
//
|
||||
// GET /chats?archived=true&limit=&before= { chats: [chat], more }
|
||||
// PATCH /chats/{id} { archived: false } { chat }
|
||||
// DELETE /chats/{id} 204
|
||||
//
|
||||
// A chat's title is the person's or a model's words, set as text.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
import { folderName } from "./list.js"
|
||||
|
||||
let ctx = null // { open(id), changed(), me() }
|
||||
let rows = []
|
||||
let more = false
|
||||
let error = ""
|
||||
let loading = false
|
||||
|
||||
/** The page, read again from the server. */
|
||||
export async function showArchived() {
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/chats?archived=true&limit=50")
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
rows = []
|
||||
more = false
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function loadMore(button) {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
button.disabled = true
|
||||
try {
|
||||
const r = await get(`/chats?archived=true&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Back into the sidebar. */
|
||||
export async function unarchive(chat) {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: false })
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
render()
|
||||
ctx?.changed(r?.chat || chat)
|
||||
return true
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
render()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(chat) {
|
||||
const yes = await ask({
|
||||
title: t("Delete this chat?"),
|
||||
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
|
||||
ok: t("Delete"),
|
||||
cancel: t("Cancel"),
|
||||
danger: true,
|
||||
})
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/chats/${encodeURIComponent(chat.id)}`)
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(c) {
|
||||
const agent = c.kind === "agent"
|
||||
const title = c.title || t("New chat")
|
||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||
const sub = [agent && c.device?.directory ? folderName(c.device.directory) : "", when(c.updated_at)].filter(Boolean).join(" · ")
|
||||
return h(
|
||||
"li",
|
||||
{ class: "arow", dataset: { id: c.id } },
|
||||
h("span", { class: `arow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "arow__open", title, onclick: () => ctx.open(c.id) },
|
||||
h("span", { class: "arow__title" }, h("bdi", { text: title })),
|
||||
h("span", { class: "arow__sub", text: sub }),
|
||||
),
|
||||
h("button", { type: "button", class: "btn btn--sm arow__unarchive", text: t("Restore chat"), onclick: () => unarchive(c) }),
|
||||
mayDelete
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool arow__more",
|
||||
"aria-label": t("More"),
|
||||
title: t("More"),
|
||||
onclick: (e) => menu(e.currentTarget, [{ label: t("Delete chat"), danger: true, run: () => remove(c) }]),
|
||||
},
|
||||
icon(ICONS.more),
|
||||
)
|
||||
: h("span", { class: "arow__more" }),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("archived-body"))
|
||||
const parts = []
|
||||
if (error) parts.push(h("p", { class: "status is-error", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("No archived chats.") }))
|
||||
if (rows.length) {
|
||||
parts.push(h("ul", { class: "arch", "aria-label": t("Archived") }, rows.map(row)))
|
||||
if (more) parts.push(h("button", { type: "button", class: "btn btn--sm arch__more", text: t("More"), onclick: (e) => loadMore(e.currentTarget) }))
|
||||
}
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
}
|
||||
|
||||
export function initArchived(context) {
|
||||
ctx = context
|
||||
}
|
||||
@@ -125,3 +125,10 @@ export async function openTerminal(target, cols, rows, { onOutput, onControl, on
|
||||
export function openLink(url) {
|
||||
return invoke("open_external", { url }).catch(() => {})
|
||||
}
|
||||
|
||||
/** Let go of every reply followed and every shell open (the page is about to load again). */
|
||||
export function letGoAll() {
|
||||
const ids = [...followers.keys()].map((id) => invoke("stream_close", { id }))
|
||||
const shells = [...terminals.keys()].map((id) => invoke("terminal_close", { id }))
|
||||
return Promise.allSettled([...ids, ...shells])
|
||||
}
|
||||
+269
-28
@@ -17,9 +17,12 @@ import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubag
|
||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||
import { folderName } from "./list.js"
|
||||
import { folderName, listed, folderTree, newFolder } from "./list.js"
|
||||
import { unarchive } from "./archived.js"
|
||||
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||
import { scopePopover } from "./scope.js"
|
||||
import { readAloudButton } from "./voice.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
@@ -41,12 +44,46 @@ function deviceOf(id) {
|
||||
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||
}
|
||||
|
||||
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
|
||||
function draftDevice() {
|
||||
/** This computer's device: the one linked right now, else the last one it was. */
|
||||
function hereDevice() {
|
||||
const mine = ctx.thisDevices()
|
||||
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||
}
|
||||
|
||||
/**
|
||||
* The device a new agent chat goes to: the one chosen on its chip while it is still linked, else
|
||||
* this computer's.
|
||||
*/
|
||||
function draftDevice() {
|
||||
if (draft?.device && deviceOf(draft.device)) return draft.device
|
||||
return hereDevice()
|
||||
}
|
||||
|
||||
/** Whether the new agent chat works here (the agent built into this app) or on another device. */
|
||||
function draftHere() {
|
||||
const d = draftDevice()
|
||||
return !d || ctx.thisDevices().includes(d)
|
||||
}
|
||||
|
||||
/** The other linked devices of this account: where else a new agent chat can work. */
|
||||
function otherDevices() {
|
||||
const mine = ctx.thisDevices()
|
||||
return (ctx.devices?.() || []).filter((d) => !mine.includes(d.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders an agent chat on another device has worked in: the sidebar's agent chats there,
|
||||
* newest first. This app cannot browse another computer; these and a typed path are the choice.
|
||||
*/
|
||||
function knownFolders(device) {
|
||||
const out = []
|
||||
for (const c of listed()) {
|
||||
const dir = c.kind === "agent" && c.device?.token_id === device ? c.device.directory : ""
|
||||
if (dir && !out.includes(dir)) out.push(dir)
|
||||
}
|
||||
return out.slice(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||
@@ -57,7 +94,7 @@ function terminalTarget() {
|
||||
if (draft.kind !== "agent" || !draft.dir) return null
|
||||
const device = deviceOf(draftDevice())
|
||||
if (!device?.terminal) return null
|
||||
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
|
||||
return { device: device.id, directory: draft.dir, name: device.name || (draftHere() ? t("This computer") : t("Another device")) }
|
||||
}
|
||||
const chat = open?.chat
|
||||
if (chat?.kind !== "agent" || !chat.device) return null
|
||||
@@ -256,6 +293,7 @@ function assistantMessage(m, chat) {
|
||||
"div",
|
||||
{ class: "msg__actions" },
|
||||
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
|
||||
readAloudButton(m.id, () => replyText(m)),
|
||||
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
||||
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
||||
),
|
||||
@@ -691,8 +729,51 @@ function renderHead(chat) {
|
||||
// "Continue in a terminal" stays in the chat's menu.
|
||||
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||
}
|
||||
// An archived chat says so where it is read, with the way back into the sidebar.
|
||||
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
||||
if (!chat.archived) tools.append(h("button", { type: "button", class: "tool", id: "scope-open", "aria-label": t("Tools and skills…"), title: t("Tools and skills…"), "aria-haspopup": "dialog", onclick: (e) => scopePopover(e.currentTarget, open?.chat || chat) }, icon(ICONS.tools)))
|
||||
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)))
|
||||
fitHead()
|
||||
}
|
||||
|
||||
// --- The chat's settings, top right ---------------------------------------------------------------
|
||||
//
|
||||
// The model, how hard it thinks, the subagents and an agent chat's mode sit in the chat's header,
|
||||
// beside the Terminal, the activity panel, the tools and skills and the chat's menu; the composer
|
||||
// keeps only what belongs to the message. Where the header has no room for them all on its line,
|
||||
// they fold behind one button (Chat settings), which opens them as a row of their own under the
|
||||
// header -- wrapped, every one reachable, never a line cut off at its end. Whether they fit is
|
||||
// measured, not guessed: a long model name or one more chip folds them at any width.
|
||||
|
||||
function fitHead() {
|
||||
const head = $("chat-head")
|
||||
const box = $("chat-settings")
|
||||
if (!head || !box) return
|
||||
const any = box.childElementCount > 0
|
||||
head.dataset.fold = "false"
|
||||
// Folded when the settings are cut at their end, or when they leave where the chat works (its
|
||||
// folder) less than its own width or 10rem, whichever is less.
|
||||
const place = $("chat-place")
|
||||
const room = Math.min(place.scrollWidth, 10 * parseFloat(getComputedStyle(document.documentElement).fontSize))
|
||||
const squeezed = place.childElementCount > 0 && place.clientWidth + 1 < room
|
||||
const fold = any && (box.scrollWidth > box.clientWidth + 1 || squeezed)
|
||||
head.dataset.fold = String(fold)
|
||||
if (!fold) head.dataset.unfold = "false"
|
||||
$("chat-fold").hidden = !fold
|
||||
$("chat-fold").setAttribute("aria-expanded", String(head.dataset.unfold === "true"))
|
||||
}
|
||||
|
||||
function initHead() {
|
||||
$("chat-fold").replaceChildren(icon(ICONS.sliders))
|
||||
$("chat-fold").hidden = true
|
||||
$("chat-fold").addEventListener("click", () => {
|
||||
const head = $("chat-head")
|
||||
head.dataset.unfold = String(head.dataset.unfold !== "true")
|
||||
$("chat-fold").setAttribute("aria-expanded", head.dataset.unfold)
|
||||
if (head.dataset.unfold === "true") $("chat-settings").querySelector("button")?.focus()
|
||||
})
|
||||
new ResizeObserver(() => fitHead()).observe($("chat-head"))
|
||||
}
|
||||
|
||||
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
||||
@@ -749,6 +830,47 @@ const MODE_WORDS = {
|
||||
plan: () => t("Plan — changes nothing"),
|
||||
}
|
||||
|
||||
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
|
||||
|
||||
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
|
||||
|
||||
/** Whether a chat here may be asked for a picture: the server draws for this account. */
|
||||
function picturesOffered() {
|
||||
const me = ctx.me()
|
||||
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
|
||||
}
|
||||
|
||||
function pictureChip() {
|
||||
const b = chip({
|
||||
ico: ICONS.picture,
|
||||
label: t("Draw a picture"),
|
||||
title: t("The reply to the next message is a picture drawn from it."),
|
||||
menuish: false,
|
||||
cls: "cchip--picture",
|
||||
onclick: () => {
|
||||
wantPicture = !wantPicture
|
||||
b.setAttribute("aria-pressed", String(wantPicture))
|
||||
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
|
||||
$("composer-text").focus()
|
||||
},
|
||||
})
|
||||
b.setAttribute("aria-pressed", String(wantPicture))
|
||||
return b
|
||||
}
|
||||
|
||||
/** The message's body, asking for a picture while the switch is on. */
|
||||
function takePicture(body) {
|
||||
return wantPicture ? { ...body, force_tool: "image_generate" } : body
|
||||
}
|
||||
|
||||
/** The message went: the switch goes off with it. */
|
||||
function pictureSent() {
|
||||
if (!wantPicture) return
|
||||
wantPicture = false
|
||||
$("composer-text").placeholder = t("Message")
|
||||
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
|
||||
}
|
||||
|
||||
function modeChip(value, onChange, disabled) {
|
||||
return chip({
|
||||
ico: ICONS.shield,
|
||||
@@ -836,11 +958,57 @@ function folderChip(roots, value, onChange) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which computer a new agent chat works on: this one (the agent built into this app), or another
|
||||
* linked device of the account, as `GET /devices` lists them. Shown only where there is another.
|
||||
*/
|
||||
function deviceChip(others) {
|
||||
const here = draftHere()
|
||||
const current = deviceOf(draftDevice())
|
||||
const choose = (id) => {
|
||||
if (id === draftDevice()) return
|
||||
draft.device = id
|
||||
const elsewhere = !ctx.thisDevices().includes(id)
|
||||
draft.dir = elsewhere ? knownFolders(id)[0] || "" : ""
|
||||
renderChips()
|
||||
renderHello()
|
||||
renderNotice()
|
||||
}
|
||||
const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }]
|
||||
for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) })
|
||||
const label = here ? t("This computer") : current?.name || current?.host || t("Another device")
|
||||
const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) })
|
||||
return b
|
||||
}
|
||||
|
||||
/** The folder of a new agent chat on another device: one it has worked in, or one typed. */
|
||||
function elsewhereFolderChip(device, value, onChange) {
|
||||
const type = async () => {
|
||||
const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") })
|
||||
if (dir) onChange(dir)
|
||||
}
|
||||
const known = knownFolders(device)
|
||||
if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type })
|
||||
return chip({
|
||||
ico: ICONS.folder,
|
||||
label: value ? folderName(value) : t("Choose a folder…"),
|
||||
title: value ? `${t("Folder")}: ${value}` : "",
|
||||
cls: value ? "" : "is-warn",
|
||||
onclick: (e) =>
|
||||
menu(e.currentTarget, [
|
||||
...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
|
||||
known.length ? null : undefined,
|
||||
{ label: t("Type a folder…"), run: type },
|
||||
].filter((x) => x !== undefined)),
|
||||
})
|
||||
}
|
||||
|
||||
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
||||
|
||||
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||
async function renderChips() {
|
||||
const box = $("composer-chips")
|
||||
const box = $("chat-settings")
|
||||
const message = $("composer-chips")
|
||||
const drawing = ++chipsDrawn
|
||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||
if (draft) {
|
||||
@@ -848,12 +1016,20 @@ async function renderChips() {
|
||||
const models = ctx.models()
|
||||
const model = models.find((m) => m.id === draft.model)
|
||||
const parts = []
|
||||
const where = [] // an agent chat's computer and folder: where it works, beside the title
|
||||
if (draft.kind === "agent") {
|
||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
const others = otherDevices()
|
||||
if (others.length) where.push(deviceChip(others))
|
||||
if (draftHere()) {
|
||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||
where.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
} else {
|
||||
where.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
}
|
||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||
}
|
||||
$("chat-place").replaceChildren(...where)
|
||||
const shell = terminalTarget()
|
||||
aimTerminal(shell)
|
||||
parts.push(modelChip(models, draft.model, (id) => {
|
||||
@@ -862,6 +1038,11 @@ async function renderChips() {
|
||||
renderChips()
|
||||
}, mayPick))
|
||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||
if (draft.kind === "chat" && picturesOffered()) message.replaceChildren(pictureChip())
|
||||
else {
|
||||
wantPicture = false
|
||||
message.replaceChildren()
|
||||
}
|
||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||
@@ -873,20 +1054,20 @@ async function renderChips() {
|
||||
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||
})
|
||||
}
|
||||
// Last, so the chat's own settings come first where the line scrolls.
|
||||
if (shell) {
|
||||
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||
b.dataset.terminalToggle = ""
|
||||
b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||
b.setAttribute("aria-controls", "terminal")
|
||||
parts.push(b)
|
||||
}
|
||||
// The Terminal, as an open chat has it: in the header's tools.
|
||||
$("chat-tools").replaceChildren(...(shell ? [terminalButton()] : []))
|
||||
box.replaceChildren(...parts.filter(Boolean))
|
||||
fitHead()
|
||||
return
|
||||
}
|
||||
const chat = open?.chat
|
||||
aimTerminal(terminalTarget())
|
||||
if (!chat?.kind) return box.replaceChildren()
|
||||
if (!chat?.kind) {
|
||||
box.replaceChildren()
|
||||
message.replaceChildren()
|
||||
fitHead()
|
||||
return
|
||||
}
|
||||
const save = async (body) => {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||
@@ -907,10 +1088,17 @@ async function renderChips() {
|
||||
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||
if (chat.kind === "chat" && picturesOffered() && !chat.archived) message.replaceChildren(pictureChip())
|
||||
else {
|
||||
wantPicture = false
|
||||
message.replaceChildren()
|
||||
}
|
||||
box.replaceChildren(...parts.filter(Boolean))
|
||||
fitHead()
|
||||
const offer = await teamOffered(chat)
|
||||
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||
fitHead()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -920,7 +1108,7 @@ async function renderChips() {
|
||||
function renderNotice() {
|
||||
const box = $("link-notice")
|
||||
const s = ctx.status()
|
||||
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||
const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||
box.hidden = !show
|
||||
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||
@@ -945,9 +1133,14 @@ export function chatMenu(anchor, chat) {
|
||||
const base = ctx.status()?.server
|
||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
|
||||
if (!chat.archived) {
|
||||
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
|
||||
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
|
||||
}
|
||||
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) })
|
||||
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
|
||||
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
|
||||
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
||||
menu(anchor, items)
|
||||
}
|
||||
@@ -980,6 +1173,34 @@ export async function continueInTerminal(chat) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Where a chat can go: no folder, any of the account's, or a new one. */
|
||||
function moveMenu(anchor, chat) {
|
||||
const here = chat.folder_id || ""
|
||||
menu(anchor, [
|
||||
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
|
||||
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
|
||||
null,
|
||||
{
|
||||
label: t("New folder…"),
|
||||
run: async () => {
|
||||
const id = await newFolder()
|
||||
if (id) setChat(chat, { folder_id: id })
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
|
||||
async function setChat(chat, body) {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
|
||||
ctx.changed(r?.chat)
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function renameChat(chat) {
|
||||
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
|
||||
if (!title || title === chat.title) return
|
||||
@@ -1004,6 +1225,15 @@ async function archiveChat(chat) {
|
||||
}
|
||||
}
|
||||
|
||||
/** An archived chat, open or in its page's menu, back into the sidebar. */
|
||||
async function unarchiveChat(chat) {
|
||||
if (!(await unarchive(chat))) return
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = { ...open.chat, archived: false }
|
||||
renderHead(open.chat)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(chat) {
|
||||
const yes = await ask({
|
||||
title: t("Delete this chat?"),
|
||||
@@ -1116,7 +1346,7 @@ export function closeChat() {
|
||||
|
||||
// --- A new chat ------------------------------------------------------------------------------------
|
||||
|
||||
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
|
||||
let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written
|
||||
let lastKind = "agent"
|
||||
|
||||
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
||||
@@ -1178,7 +1408,9 @@ function renderHello() {
|
||||
h(
|
||||
"p",
|
||||
{ class: "hello__sub" },
|
||||
kind === "agent"
|
||||
kind === "agent" && !draftHere()
|
||||
? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") })
|
||||
: kind === "agent"
|
||||
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
||||
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
||||
),
|
||||
@@ -1194,7 +1426,7 @@ export function newChat(kind) {
|
||||
const models = ctx.models()
|
||||
const start = models.find((m) => m.default) || models[0]
|
||||
lastKind = kind || lastKind
|
||||
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||
draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||
clear($("messages"))
|
||||
renderCards({ id: "" }, [])
|
||||
alertIn("")
|
||||
@@ -1221,14 +1453,20 @@ async function createChat(text, attachments) {
|
||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||
if (draft.kind === "agent") {
|
||||
const device = draftDevice()
|
||||
if (!device || !agentReady(ctx.status())) {
|
||||
renderNotice()
|
||||
throw { detail: agentState(ctx.status()).sentence }
|
||||
if (draftHere()) {
|
||||
if (!device || !agentReady(ctx.status())) {
|
||||
renderNotice()
|
||||
throw { detail: agentState(ctx.status()).sentence }
|
||||
}
|
||||
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||
} else {
|
||||
if (!deviceOf(device)) throw { detail: t("This device is not linked right now.") }
|
||||
if (!draft.dir) throw { detail: t("Choose a folder first.") }
|
||||
}
|
||||
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
||||
}
|
||||
const r = await post("/chats", body)
|
||||
const r = await post("/chats", draft.kind === "chat" ? takePicture(body) : body)
|
||||
pictureSent()
|
||||
draft = null
|
||||
$("new-chat").hidden = true
|
||||
$("main").dataset.new = "false"
|
||||
@@ -1265,9 +1503,11 @@ async function submit() {
|
||||
try {
|
||||
if (draft) await createChat(text, files)
|
||||
else if (open?.chat?.id) {
|
||||
const body = { text }
|
||||
let body = { text }
|
||||
if (files.length) body.attachments = files
|
||||
if (open.chat.kind === "chat") body = takePicture(body)
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
|
||||
pictureSent()
|
||||
if (r.message) $("messages").append(userMessage(r.message))
|
||||
if (r.reply) follower(open.chat, r.reply.id)
|
||||
} else return
|
||||
@@ -1317,5 +1557,6 @@ export function initChat(context) {
|
||||
}
|
||||
})
|
||||
onAgentChange(renderNotice)
|
||||
initHead()
|
||||
showComposer(false)
|
||||
}
|
||||
@@ -99,6 +99,16 @@ export const ICONS = {
|
||||
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
||||
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
||||
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
||||
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
|
||||
contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"],
|
||||
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
|
||||
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
|
||||
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
|
||||
tools: "M10.6 1.5a3.9 3.9 0 00-3.7 5.1L1.8 11.7a1.6 1.6 0 002.3 2.3l5.1-5.1a3.9 3.9 0 005.1-3.7l-.1-.9-2.1 2.1-1.9-.4-.4-1.9 2.1-2.1zM3 12.4l.6.6 5.3-5.3-.6-.6z",
|
||||
sliders: ["M2 4h7v1.5H2zM12 4h2v1.5h-2zM2 10.5h2V12H2zM7 10.5h7V12H7z", "M9.5 2.75h2v4h-2zM4.5 9.25h2v4h-2z"],
|
||||
fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"],
|
||||
fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"],
|
||||
drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-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()
|
||||
}
|
||||
+523
-71
@@ -1,72 +1,376 @@
|
||||
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
|
||||
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
|
||||
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||
// The Files page: two explorers, kept apart as two tabs -- this computer's (the agent's folders,
|
||||
// read through a command that reaches only them, src-tauri/src/files.rs) and the server's (the
|
||||
// person's files, as the chats see them, read through /api/v2). Each is the web explorer's shape,
|
||||
// using the window's width: where you are (crumbs) and what can be done there, the folder tree,
|
||||
// the list (name, type, size, changed), and the chosen file's details and preview. A file opens in
|
||||
// the viewer either way.
|
||||
//
|
||||
// Nothing here is ever an empty box: a directory being read says Loading…, past SLOW_MS it says
|
||||
// the server has not answered (with Try again, the late answer still drawn), and a refusal, an
|
||||
// answer that is not a listing or a failure to draw it is said in its place with Try again.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, get, why } from "./bridge.js"
|
||||
import { openViewer } from "./viewer.js"
|
||||
import { invoke, get, post, del, why } from "./bridge.js"
|
||||
import { menu, ask } from "./popup.js"
|
||||
import { openViewer, showKind, withheldWords } from "./viewer.js"
|
||||
|
||||
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
||||
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
|
||||
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
|
||||
// A path the person cannot read is the server's 404, in its words.
|
||||
// A path the person cannot read is the server's 404, in its words. `/` answers with where the
|
||||
// person's explorer opens (`fs.start`): `/` itself for an organisation's owner or administrator,
|
||||
// the person's home for anyone else -- its `path` says which.
|
||||
export const SERVER_FILES = {
|
||||
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
||||
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
|
||||
}
|
||||
|
||||
/** How long a directory may take before the pane says it is still waiting, and offers to ask again. */
|
||||
export const SLOW_MS = 8000
|
||||
|
||||
/** How long an answer is shared between the tree and the list after it came. */
|
||||
const SHARED_MS = 1500
|
||||
|
||||
/** The most of a file the details pane previews: the viewer has the rest. */
|
||||
const PREVIEW_CHARS = 6000
|
||||
|
||||
/** A directory in either tree: the server's mounts open like its folders. */
|
||||
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
|
||||
|
||||
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
||||
function tree(box, { list, open, rootPath }) {
|
||||
clear(box)
|
||||
const ul = h("ul", { class: "tree", role: "tree" })
|
||||
box.append(ul)
|
||||
const level = async (parent, path, depth) => {
|
||||
let entries
|
||||
try {
|
||||
entries = await list(path)
|
||||
} catch (err) {
|
||||
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
|
||||
return
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i
|
||||
|
||||
/** An entry's icon, by what the viewer would make of it (the web's `file_icon`). */
|
||||
function iconOf(e) {
|
||||
if (e.kind === "mount") return ICONS.drive
|
||||
if (isDir(e)) return ICONS.folder
|
||||
if ((e.mime || "").startsWith("image/") || IMAGE.test(e.name)) return ICONS.picture
|
||||
const { kind } = showKind(e.path || e.name)
|
||||
return kind === "code" || kind === "html" ? ICONS.fileCode : kind === "markdown" || kind === "text" ? ICONS.fileText : ICONS.file
|
||||
}
|
||||
|
||||
/** The Type column: a folder, a mount, or the extension (the web's `type_label`). */
|
||||
function typeOf(e) {
|
||||
if (e.kind === "mount") return t("mount")
|
||||
if (isDir(e)) return t("folder")
|
||||
const i = e.name.lastIndexOf(".")
|
||||
return i > 0 ? e.name.slice(i + 1).toLowerCase() : ""
|
||||
}
|
||||
|
||||
/** A stamp as a date the person reads, and the whole of it for a title. */
|
||||
function dateOf(stamp) {
|
||||
if (!stamp) return { short: "", full: "" }
|
||||
const d = new Date(stamp)
|
||||
if (Number.isNaN(d.getTime())) return { short: "", full: "" }
|
||||
const lang = document.documentElement.lang || undefined
|
||||
return {
|
||||
short: new Intl.DateTimeFormat(lang, { day: "numeric", month: "short", year: "numeric" }).format(d),
|
||||
full: new Intl.DateTimeFormat(lang, { dateStyle: "long", timeStyle: "short" }).format(d),
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the details pane on the screen (wide windows): a file is chosen there, else opened. */
|
||||
const detailsShown = (el) => !!el && getComputedStyle(el).display !== "none"
|
||||
|
||||
/**
|
||||
* Read something into `holder` and draw it, never leaving it empty: Loading… at once, the slow
|
||||
* note and Try again past SLOW_MS, a refusal or a failure to draw in its place. `tag` is the
|
||||
* element the notes are (an `li` inside a list). Only the latest reading of a holder is drawn:
|
||||
* one that comes after the holder was read again, or left the page, is dropped. Returns a promise
|
||||
* that settles once the reading is drawn or said.
|
||||
*/
|
||||
function reading(holder, { tag = "div", depth = 0, load, draw }) {
|
||||
const mine = (readings.get(holder) || 0) + 1
|
||||
readings.set(holder, mine)
|
||||
const stale = () => readings.get(holder) !== mine || !holder.isConnected
|
||||
const again = () => reading(holder, { tag, depth, load, draw })
|
||||
const retry = () => h("button", { type: "button", class: "btn btn--sm fnote__retry", text: t("Try again"), onclick: again })
|
||||
const note = (cls, role, text) => {
|
||||
const n = h(tag, { class: `fnote ${cls}`, role }, h("span", { text }))
|
||||
n.style.setProperty("--depth", String(depth))
|
||||
return n
|
||||
}
|
||||
clear(holder)
|
||||
const loading = note("is-loading", "status", t("Loading…"))
|
||||
loading.setAttribute("aria-live", "polite")
|
||||
holder.append(loading)
|
||||
const slow = setTimeout(() => {
|
||||
if (!stale()) loading.append(h("span", { text: t("The server has not answered in %(n)s seconds.", { n: Math.round(SLOW_MS / 1000) }) }), retry())
|
||||
}, SLOW_MS)
|
||||
const said = (text) => {
|
||||
const n = note("is-error", "alert", text)
|
||||
n.append(retry())
|
||||
clear(holder).append(n)
|
||||
}
|
||||
return Promise.resolve()
|
||||
.then(load)
|
||||
.then(
|
||||
(answer) => {
|
||||
clearTimeout(slow)
|
||||
if (stale()) return
|
||||
try {
|
||||
clear(holder)
|
||||
draw(answer)
|
||||
} catch (err) {
|
||||
said(err instanceof Error ? err.message : why(err))
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(slow)
|
||||
if (!stale()) said(why(err))
|
||||
},
|
||||
)
|
||||
}
|
||||
const readings = new WeakMap() // holder -> the reading it shows now (a number)
|
||||
|
||||
/**
|
||||
* One explorer, inside the pane's elements (`crumbs`, `tree`, `list`, `details`). `source`:
|
||||
* key "local" | "server" (the rows' data, the tests' handle)
|
||||
* list(path) -> { path, entries } (`path` as answered: the server's `/` says where it is)
|
||||
* top() the directory the explorer opens at (the server's: as `/` answered)
|
||||
* ask(path) the path to ask for a directory (the server's top is asked as `/`)
|
||||
* crumbs(path) -> [[name, path], ...] from the top down
|
||||
* rootName() the tree's first line
|
||||
* open(entry) in the viewer
|
||||
* preview(entry) -> { text, withheld, size }
|
||||
* actions(entry) (optional) -> menu items
|
||||
*/
|
||||
function explorer(el, source) {
|
||||
let current = null // the directory listed now (as answered)
|
||||
let chosen = null // the file whose details show
|
||||
// One request per directory for the tree and the list together: an answer is shared while it
|
||||
// is on its way and for a moment after (the tree drawn again once the top is known); a refusal
|
||||
// is never kept, so Try again asks afresh. Read again and a change forget them all.
|
||||
const pending = new Map() // asked path -> promise
|
||||
const fetchDir = (path) => {
|
||||
const asked = source.ask(path)
|
||||
if (!pending.has(asked)) {
|
||||
const p = Promise.resolve().then(() => source.list(asked))
|
||||
pending.set(asked, p)
|
||||
const forget = () => pending.get(asked) === p && pending.delete(asked)
|
||||
p.then(() => setTimeout(forget, SHARED_MS), forget)
|
||||
}
|
||||
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
|
||||
for (const e of entries) {
|
||||
const row = h(
|
||||
"button",
|
||||
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
|
||||
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
|
||||
icon(isDir(e) ? ICONS.folder : ICONS.file),
|
||||
h("span", { class: "tree__name", text: e.name }),
|
||||
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||
)
|
||||
row.style.setProperty("--depth", String(depth))
|
||||
const li = h("li", { role: "treeitem" }, row)
|
||||
if (isDir(e)) {
|
||||
let kids = null
|
||||
row.addEventListener("click", async () => {
|
||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
||||
row.setAttribute("aria-expanded", String(opening))
|
||||
if (!opening) {
|
||||
kids?.remove()
|
||||
kids = null
|
||||
return
|
||||
}
|
||||
kids = h("ul", { role: "group" })
|
||||
li.append(kids)
|
||||
await level(kids, e.path, depth + 1)
|
||||
})
|
||||
} else row.addEventListener("click", () => open(e))
|
||||
parent.append(li)
|
||||
return pending.get(asked)
|
||||
}
|
||||
|
||||
// --- The folder tree: folders only, opened lazily; a name lists its folder.
|
||||
const treeBox = el.tree
|
||||
const opened = new Set()
|
||||
const branches = new Map() // path -> { ul, depth }
|
||||
const drawTree = () => {
|
||||
clear(treeBox)
|
||||
const rootRow = h(
|
||||
"button",
|
||||
{ type: "button", class: "ftree__row ftree__root", dataset: { path: source.top() }, onclick: () => go(source.top()) },
|
||||
icon(ICONS.folder),
|
||||
h("span", { class: "ftree__name", text: source.rootName() }),
|
||||
)
|
||||
const ul = h("ul", { class: "ftree", role: "tree", "aria-label": t("Folders") })
|
||||
treeBox.append(rootRow, ul)
|
||||
branch(ul, source.top(), 0)
|
||||
mark()
|
||||
}
|
||||
const branch = (ul, path, depth) => {
|
||||
branches.set(path, { ul, depth })
|
||||
return reading(ul, {
|
||||
tag: "li",
|
||||
depth,
|
||||
load: () => fetchDir(path),
|
||||
draw: (answer) => {
|
||||
const dirs = (answer.entries || []).filter(isDir)
|
||||
for (const e of dirs) ul.append(twig(e, depth))
|
||||
mark()
|
||||
},
|
||||
})
|
||||
}
|
||||
const twig = (e, depth) => {
|
||||
const chevron = h("button", { type: "button", class: "ftree__chevron", "aria-label": e.name, "aria-expanded": "false", tabindex: "-1" }, icon(ICONS.chevron))
|
||||
const row = h(
|
||||
"button",
|
||||
{ type: "button", class: "ftree__row", title: e.path, dataset: { path: e.path } },
|
||||
icon(iconOf(e)),
|
||||
h("span", { class: "ftree__name", text: e.name }),
|
||||
)
|
||||
const line = h("div", { class: "ftree__line" }, chevron, row)
|
||||
line.style.setProperty("--depth", String(depth))
|
||||
const li = h("li", { role: "treeitem", dataset: { path: e.path } }, line)
|
||||
let kids = null
|
||||
const expand = (opening) => {
|
||||
chevron.setAttribute("aria-expanded", String(opening))
|
||||
li.setAttribute("aria-expanded", String(opening))
|
||||
kids?.remove()
|
||||
kids = null
|
||||
if (!opening) {
|
||||
opened.delete(e.path)
|
||||
branches.delete(e.path)
|
||||
return
|
||||
}
|
||||
opened.add(e.path)
|
||||
kids = h("ul", { role: "group" })
|
||||
li.append(kids)
|
||||
branch(kids, e.path, depth + 1)
|
||||
}
|
||||
chevron.addEventListener("click", () => expand(chevron.getAttribute("aria-expanded") !== "true"))
|
||||
row.addEventListener("click", () => {
|
||||
go(e.path)
|
||||
if (chevron.getAttribute("aria-expanded") !== "true") expand(true)
|
||||
})
|
||||
if (opened.has(e.path)) expand(true)
|
||||
return li
|
||||
}
|
||||
/** The tree's line for the folder listed now. */
|
||||
const mark = () => {
|
||||
for (const r of treeBox.querySelectorAll(".ftree__row")) {
|
||||
const here = r.dataset.path === current || (r.classList.contains("ftree__root") && current === source.top())
|
||||
r.classList.toggle("is-current", here)
|
||||
if (here) r.setAttribute("aria-current", "true")
|
||||
else r.removeAttribute("aria-current")
|
||||
}
|
||||
}
|
||||
return level(ul, rootPath, 0)
|
||||
|
||||
// --- Where you are.
|
||||
const drawCrumbs = () => {
|
||||
const parts = source.crumbs(current ?? source.top())
|
||||
clear(el.crumbs).append(
|
||||
...parts.flatMap(([name, at], i) => {
|
||||
const last = i === parts.length - 1
|
||||
const part = last
|
||||
? h("span", { class: "crumbs__here", "aria-current": "page", title: at, text: name })
|
||||
: h("button", { type: "button", class: "crumbs__link", title: at, text: name, onclick: () => go(at) })
|
||||
return i ? [h("span", { class: "crumbs__sep", "aria-hidden": "true", text: "/" }), part] : [part]
|
||||
}),
|
||||
)
|
||||
el.crumbs.scrollLeft = el.crumbs.scrollWidth
|
||||
}
|
||||
|
||||
// --- The list.
|
||||
const go = (path) => {
|
||||
current = path
|
||||
chosen = null
|
||||
drawDetails()
|
||||
drawCrumbs()
|
||||
mark()
|
||||
return reading(el.list, {
|
||||
load: () => fetchDir(path),
|
||||
draw: (answer) => {
|
||||
if (!answer || !Array.isArray(answer.entries)) throw { detail: t("The server's answer could not be read.") }
|
||||
if (answer.path !== undefined && answer.path !== path && source.ask(path) === source.ask(answer.path)) current = answer.path
|
||||
drawCrumbs()
|
||||
mark()
|
||||
drawRows(answer.entries)
|
||||
},
|
||||
})
|
||||
}
|
||||
const drawRows = (entries) => {
|
||||
if (!entries.length) {
|
||||
el.list.append(h("div", { class: "fx__empty" }, icon(ICONS.folder), h("p", { text: t("Nothing here yet. Not even a hobbit hole.") })))
|
||||
return
|
||||
}
|
||||
const head = h(
|
||||
"div",
|
||||
{ class: "frow frow--head", role: "row" },
|
||||
h("span", { role: "columnheader", text: t("Name") }),
|
||||
h("span", { role: "columnheader", class: "frow__type", text: t("Type") }),
|
||||
h("span", { role: "columnheader", class: "frow__size", text: t("Size") }),
|
||||
h("span", { role: "columnheader", class: "frow__when", text: t("Changed") }),
|
||||
h("span", { role: "columnheader" }, h("span", { class: "visually-hidden", text: t("Actions") })),
|
||||
)
|
||||
el.list.append(h("div", { class: "frows", role: "table", "aria-label": t("Contents") }, head, entries.map(row)))
|
||||
}
|
||||
const row = (e) => {
|
||||
const date = dateOf(e.updated_at)
|
||||
const items = source.actions?.(e) || []
|
||||
const openRow = h(
|
||||
"button",
|
||||
{ type: "button", class: "frow__open", title: e.path },
|
||||
h("span", { class: `frow__icon frow__icon--${e.kind === "mount" ? "mount" : isDir(e) ? "dir" : showKind(e.name).kind}` }, icon(iconOf(e))),
|
||||
h("span", { class: "frow__label", text: e.name }),
|
||||
)
|
||||
const r = h(
|
||||
"div",
|
||||
{ class: "frow", role: "row", dataset: { path: e.path, kind: e.kind } },
|
||||
h("span", { role: "cell", class: "frow__name" }, openRow, e.kind === "mount" ? h("span", { class: "badge", text: t("mount") }) : null),
|
||||
h("span", { role: "cell", class: "frow__type", text: typeOf(e) }),
|
||||
h("span", { role: "cell", class: "frow__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||
h("span", { role: "cell", class: "frow__when", title: date.full, text: date.short }),
|
||||
h(
|
||||
"span",
|
||||
{ role: "cell", class: "frow__actions" },
|
||||
items.length
|
||||
? h("button", { type: "button", class: "tool frow__more", "aria-label": t("More for %(name)s", { name: e.name }), title: t("More for %(name)s", { name: e.name }), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
|
||||
: null,
|
||||
),
|
||||
)
|
||||
openRow.addEventListener("click", () => {
|
||||
if (isDir(e)) go(e.path)
|
||||
else if (detailsShown(el.details)) choose(e)
|
||||
else source.open(e)
|
||||
})
|
||||
openRow.addEventListener("dblclick", () => {
|
||||
if (!isDir(e)) source.open(e)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// --- The chosen file's details and preview.
|
||||
const choose = (e) => {
|
||||
chosen = e
|
||||
for (const r of el.list.querySelectorAll(".frow[data-path]")) r.classList.toggle("is-chosen", r.dataset.path === e.path)
|
||||
drawDetails()
|
||||
}
|
||||
const drawDetails = () => {
|
||||
const box = el.details
|
||||
if (!box) return
|
||||
for (const r of el.list.querySelectorAll(".frow.is-chosen")) if (!chosen || r.dataset.path !== chosen.path) r.classList.remove("is-chosen")
|
||||
clear(box)
|
||||
if (!chosen) {
|
||||
box.append(h("div", { class: "fdetails__none" }, icon(ICONS.file), h("p", { text: t("Choose a file to see it here.") })))
|
||||
return
|
||||
}
|
||||
const e = chosen
|
||||
const date = dateOf(e.updated_at)
|
||||
const fact = (label, value, title) => (value ? [h("dt", { text: label }), h("dd", { title: title || value, text: value })] : [])
|
||||
const preview = h("div", { class: "fdetails__preview", "aria-label": t("Preview") })
|
||||
box.append(
|
||||
h("div", { class: "fdetails__id" }, h("span", { class: `frow__icon frow__icon--${showKind(e.name).kind}` }, icon(iconOf(e))), h("h2", { class: "fdetails__name", title: e.name, text: e.name })),
|
||||
h("dl", { class: "fdetails__facts" }, fact(t("Type"), typeOf(e)), fact(t("Size"), bytes(e.size)), fact(t("Changed"), date.short, date.full), fact(t("Path"), e.path)),
|
||||
h("div", { class: "fdetails__do" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Open in the viewer"), onclick: () => source.open(e) })),
|
||||
preview,
|
||||
)
|
||||
reading(preview, {
|
||||
load: () => source.preview(e),
|
||||
draw: (f) => {
|
||||
if (chosen !== e) return
|
||||
if (f.withheld) preview.append(h("p", { class: "fdetails__note", text: withheldWords(f.withheld, f.size ?? e.size) }))
|
||||
else {
|
||||
const text = String(f.text ?? "")
|
||||
preview.append(h("pre", { class: "fdetails__text", text: text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}\n…` : text }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Read again: the list, the tree as it is open, the file chosen. */
|
||||
const refresh = () => {
|
||||
pending.clear()
|
||||
const at = current ?? source.top()
|
||||
drawTree()
|
||||
return go(at)
|
||||
}
|
||||
/** A directory changed (an upload, a new folder, a rename, a delete): draw what is there now. */
|
||||
const changed = (dir) => {
|
||||
pending.clear()
|
||||
const b = branches.get(dir)
|
||||
if (b) branch(b.ul, dir, b.depth)
|
||||
if (dir === current || source.ask(dir) === source.ask(current ?? source.top())) return go(current ?? source.top())
|
||||
return Promise.resolve()
|
||||
}
|
||||
return { go, refresh, changed, drawTree, current: () => current ?? source.top() }
|
||||
}
|
||||
|
||||
// --- This computer ------------------------------------------------------------------------------
|
||||
|
||||
let local = { root: "", roots: [], fixed: false }
|
||||
let localPane = null
|
||||
|
||||
/**
|
||||
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
|
||||
@@ -78,32 +382,63 @@ export function showLocal({ roots, root, fixed }) {
|
||||
if (changed) renderLocal()
|
||||
}
|
||||
|
||||
const baseName = (p) => String(p).replace(/\/+$/, "").split("/").pop() || p
|
||||
|
||||
function renderLocal() {
|
||||
const head = clear($("local-where"))
|
||||
const box = $("local-tree")
|
||||
const els = { crumbs: $("local-crumbs"), tree: $("local-tree"), list: $("local-list"), details: $("local-details") }
|
||||
$("pane-local").dataset.empty = String(!local.roots.length)
|
||||
if (!local.roots.length) {
|
||||
clear(box).append(
|
||||
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||
clear(els.crumbs)
|
||||
clear(els.tree)
|
||||
clear(els.details)
|
||||
clear(els.list).append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "fx__empty" },
|
||||
icon(ICONS.folder),
|
||||
h("p", { text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||
),
|
||||
)
|
||||
localPane = null
|
||||
return
|
||||
}
|
||||
if (local.fixed) {
|
||||
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
|
||||
} else {
|
||||
const pick = h(
|
||||
"select",
|
||||
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
|
||||
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
|
||||
if (!local.fixed && local.roots.length > 1) {
|
||||
head.append(
|
||||
h(
|
||||
"select",
|
||||
{ class: "pane__pick", "aria-label": t("Folder"), title: local.root, onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
|
||||
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
|
||||
),
|
||||
)
|
||||
head.append(pick)
|
||||
}
|
||||
const root = local.root
|
||||
tree(box, {
|
||||
rootPath: "",
|
||||
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
||||
const where = t("This computer")
|
||||
localPane = explorer(els, {
|
||||
key: "local",
|
||||
top: () => "",
|
||||
ask: (path) => path || "",
|
||||
rootName: () => baseName(root),
|
||||
crumbs: (path) => {
|
||||
const out = [[baseName(root), ""]]
|
||||
let at = ""
|
||||
for (const part of String(path).split("/").filter(Boolean)) {
|
||||
at = at ? `${at}/${part}` : part
|
||||
out.push([part, at])
|
||||
}
|
||||
return out
|
||||
},
|
||||
list: async (path) => {
|
||||
const r = await invoke("local_list", { root, path })
|
||||
if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") }
|
||||
return {
|
||||
path: r.path ?? path,
|
||||
entries: r.entries.map((e) => ({ ...e, updated_at: e.modified ? new Date(e.modified * 1000).toISOString() : null })),
|
||||
}
|
||||
},
|
||||
preview: (e) => invoke("local_read", { root, path: e.path }),
|
||||
open: async (e) => {
|
||||
const where = t("This computer")
|
||||
try {
|
||||
const f = await invoke("local_read", { root, path: e.path })
|
||||
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
|
||||
@@ -112,6 +447,8 @@ function renderLocal() {
|
||||
}
|
||||
},
|
||||
})
|
||||
localPane.drawTree()
|
||||
localPane.go("")
|
||||
}
|
||||
|
||||
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
|
||||
@@ -125,13 +462,122 @@ export async function readServer(path) {
|
||||
return get(SERVER_FILES.read(path))
|
||||
}
|
||||
|
||||
// The library's writes, as the person (each answers with the directory's listing, or a refusal
|
||||
// in the server's words):
|
||||
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
|
||||
// POST /files/mkdir { path, name }
|
||||
// POST /files/rename { path, name }
|
||||
// DELETE /files?path= to the trash, as on the web
|
||||
|
||||
let server = null // the explorer drawn now
|
||||
let serverRoot = "/" // the directory the server answered `/` with: `/` or the person's home
|
||||
|
||||
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
|
||||
const reloadServer = (dir) => server?.changed(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) })
|
||||
else items.push({ label: t("Open in the viewer"), run: () => openServerFile(e.path, e.name) })
|
||||
if (e.kind !== "mount") items.push(null, { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
|
||||
return items
|
||||
}
|
||||
|
||||
export function renderServer() {
|
||||
const box = $("server-tree")
|
||||
tree(box, {
|
||||
rootPath: "/",
|
||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||
serverSaid("")
|
||||
const els = { crumbs: $("server-crumbs"), tree: $("server-tree"), list: $("server-list"), details: $("server-details") }
|
||||
let first = true
|
||||
server = explorer(els, {
|
||||
key: "server",
|
||||
top: () => serverRoot,
|
||||
// The top is asked as `/`, which is what the server opens a person at.
|
||||
ask: (path) => (!path || path === serverRoot ? "/" : path),
|
||||
rootName: () => (serverRoot === "/" ? t("All files") : t("Home")),
|
||||
crumbs: (path) => {
|
||||
const out = [[serverRoot === "/" ? t("All files") : t("Home"), serverRoot]]
|
||||
const rest = path.startsWith(serverRoot) ? path.slice(serverRoot.length) : path
|
||||
let at = serverRoot
|
||||
for (const part of rest.split("/").filter(Boolean)) {
|
||||
at = `${at.replace(/\/$/, "")}/${part}`
|
||||
out.push([part, at])
|
||||
}
|
||||
return out
|
||||
},
|
||||
list: async (path) => {
|
||||
const r = await get(SERVER_FILES.list(path))
|
||||
if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") }
|
||||
if (path === "/" && r.path && r.path !== serverRoot) {
|
||||
serverRoot = r.path
|
||||
// The top is known now: the tree and the crumbs start there.
|
||||
if (first) queueMicrotask(() => server?.drawTree())
|
||||
}
|
||||
first = false
|
||||
return r
|
||||
},
|
||||
preview: (e) => readServer(e.path),
|
||||
open: (e) => openServerFile(e.path, e.name),
|
||||
actions: serverActions,
|
||||
})
|
||||
server.drawTree()
|
||||
return server.go(serverRoot)
|
||||
}
|
||||
|
||||
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
|
||||
@@ -158,7 +604,7 @@ export async function openServerFile(path, name) {
|
||||
let tab = "local"
|
||||
let serverRead = false
|
||||
|
||||
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
|
||||
/** Show one of the two explorers: `local` (this computer) or `server`. The server's is read once. */
|
||||
export function showFilesTab(which) {
|
||||
tab = which === "server" ? "server" : "local"
|
||||
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
|
||||
@@ -174,9 +620,15 @@ export function initExplorer() {
|
||||
const tabBtn = (which, ico, label) =>
|
||||
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
|
||||
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
|
||||
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("server-refresh").addEventListener("click", renderServer)
|
||||
$("server-refresh").addEventListener("click", () => {
|
||||
serverSaid("")
|
||||
return server ? server.refresh() : renderServer()
|
||||
})
|
||||
$("server-upload").replaceChildren(icon(ICONS.send))
|
||||
$("server-upload").addEventListener("click", () => uploadInto(server?.current() ?? serverRoot))
|
||||
$("server-mkdir").replaceChildren(icon(ICONS.folder))
|
||||
$("server-mkdir").addEventListener("click", () => makeFolder(server?.current() ?? serverRoot))
|
||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("local-refresh").addEventListener("click", renderLocal)
|
||||
$("local-refresh").addEventListener("click", () => (localPane ? localPane.refresh() : renderLocal()))
|
||||
}
|
||||
+156
-9
@@ -1,15 +1,23 @@
|
||||
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
|
||||
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
|
||||
// on another device of the account says so in its title. The search line above narrows the list
|
||||
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
|
||||
// The sidebar's chats: agent chats and chats on the server in one list, newest first. The
|
||||
// pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the
|
||||
// server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark
|
||||
// and its project folder's name; one that works on another device of the account says so in its
|
||||
// title. The search line above narrows the list by title (and an agent chat by its folder) as it
|
||||
// is typed, flat; Ctrl+K searches everything.
|
||||
//
|
||||
// Folders are made, renamed and deleted here (`POST/PATCH/DELETE /folders`); a chat is pinned and
|
||||
// moved from its own menu (`PATCH /chats/{id}` `pinned`, `folder_id`). Deleting a folder never
|
||||
// deletes a chat: its chats go back to the list.
|
||||
|
||||
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
import { get, post, patch, del, why } from "./bridge.js"
|
||||
import { menu, ask } from "./popup.js"
|
||||
|
||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat), note(text) }
|
||||
let rows = []
|
||||
let more = false
|
||||
let query = ""
|
||||
let folders = [] // { id, name, parent_id, collapsed, position }
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
function fold(text) {
|
||||
@@ -22,9 +30,10 @@ function fold(text) {
|
||||
/** The list again, from the server. */
|
||||
export async function refreshList() {
|
||||
try {
|
||||
const r = await get("/chats?limit=50")
|
||||
const [r, f] = await Promise.all([get("/chats?limit=50"), get("/folders").catch(() => ({ folders: [] }))])
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
folders = f.folders || []
|
||||
render()
|
||||
} catch (err) {
|
||||
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
|
||||
@@ -77,6 +86,108 @@ function row(c) {
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
/** A folder's head: it folds and unfolds; its menu renames it, makes one inside, deletes it. */
|
||||
function folderHead(f, depth, count) {
|
||||
const open = !f.collapsed
|
||||
const head = h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "folderrow",
|
||||
dataset: { folder: f.id },
|
||||
style: depth ? `--depth: ${depth}` : null,
|
||||
"aria-expanded": String(open),
|
||||
title: f.name,
|
||||
onclick: () => foldFolder(f, !f.collapsed),
|
||||
},
|
||||
h("span", { class: "folderrow__chev" }, icon(open ? ICONS.chevronDown : ICONS.chevron)),
|
||||
h("span", { class: "folderrow__icon" }, icon(ICONS.folder)),
|
||||
h("span", { class: "folderrow__name", text: f.name }),
|
||||
h("span", { class: "folderrow__count", text: count ? String(count) : "" }),
|
||||
)
|
||||
head.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault()
|
||||
folderMenu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : head, f)
|
||||
})
|
||||
const more = h("button", { type: "button", class: "folderrow__more", "aria-label": t("More"), title: t("More"), onclick: (e) => folderMenu(e.currentTarget, f) }, icon(ICONS.more))
|
||||
return h("li", { class: "list__folder" }, head, more)
|
||||
}
|
||||
|
||||
async function foldFolder(f, collapsed) {
|
||||
f.collapsed = collapsed
|
||||
render()
|
||||
try {
|
||||
await patch(`/folders/${encodeURIComponent(f.id)}`, { collapsed })
|
||||
} catch {
|
||||
// Folding is the sidebar's own business: drawn as asked even where the server did not keep it.
|
||||
}
|
||||
}
|
||||
|
||||
function folderMenu(anchor, f) {
|
||||
menu(anchor, [
|
||||
{ label: t("Rename folder"), run: () => renameFolder(f) },
|
||||
{ label: t("New folder"), run: () => newFolder(f.id) },
|
||||
null,
|
||||
{ label: t("Delete folder"), danger: true, run: () => deleteFolder(f) },
|
||||
])
|
||||
}
|
||||
|
||||
/** A new folder, at the top level or inside `parent`: its id once made, else "". */
|
||||
export async function newFolder(parent = "") {
|
||||
const name = await ask({ title: t("New folder"), text: t("Folder name"), value: "", ok: t("Create"), cancel: t("Cancel") })
|
||||
if (!name) return ""
|
||||
try {
|
||||
const r = await post("/folders", parent ? { name, parent_id: parent } : { name })
|
||||
await refreshList()
|
||||
return r.folder?.id || ""
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolder(f) {
|
||||
const name = await ask({ title: t("Rename folder"), text: t("Folder name"), value: f.name, ok: t("Rename"), cancel: t("Cancel") })
|
||||
if (!name || name === f.name) return
|
||||
try {
|
||||
await patch(`/folders/${encodeURIComponent(f.id)}`, { name })
|
||||
await refreshList()
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(f) {
|
||||
const ok = await ask({ title: t("Delete folder"), text: t("“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", { name: f.name }), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||
if (!ok) return
|
||||
try {
|
||||
await del(`/folders/${encodeURIComponent(f.id)}`)
|
||||
await refreshList()
|
||||
} catch (err) {
|
||||
ctx.note?.(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's chat folders, as the list last read them. */
|
||||
export function listedFolders() {
|
||||
return folders
|
||||
}
|
||||
|
||||
/** The folders as a tree, depth first, each with its depth: what a menu of them lists. */
|
||||
export function folderTree() {
|
||||
const out = []
|
||||
const known = new Set(folders.map((f) => f.id))
|
||||
const walk = (parent, depth) => {
|
||||
const kids = folders.filter((f) => (f.parent_id && known.has(f.parent_id) ? f.parent_id : null) === parent).sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name))
|
||||
for (const f of kids) {
|
||||
out.push({ folder: f, depth })
|
||||
walk(f.id, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(null, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function isToday(stamp) {
|
||||
const d = new Date(stamp)
|
||||
if (Number.isNaN(d.getTime())) return false
|
||||
@@ -98,8 +209,44 @@ function render() {
|
||||
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||
return
|
||||
}
|
||||
const today = visible.filter((r) => isToday(r.updated_at))
|
||||
const earlier = visible.filter((r) => !isToday(r.updated_at))
|
||||
// Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest.
|
||||
let rest = visible
|
||||
if (!words.length) {
|
||||
const pinned = visible.filter((r) => r.pinned)
|
||||
if (pinned.length) append(list, [h("li", { class: "list__head", text: t("Pinned chats") }), pinned.map(row)])
|
||||
const known = new Set(folders.map((f) => f.id))
|
||||
const filed = (r) => !r.pinned && r.folder_id && known.has(r.folder_id)
|
||||
const tree = folderTree()
|
||||
if (tree.length) {
|
||||
const inside = (id) => {
|
||||
// A folder's count: its own chats and those of the folders inside it.
|
||||
const ids = new Set([id])
|
||||
for (const { folder } of tree) if (folder.parent_id && ids.has(folder.parent_id)) ids.add(folder.id)
|
||||
return visible.filter((r) => filed(r) && ids.has(r.folder_id)).length
|
||||
}
|
||||
const hidden = new Set() // folders under a folded one
|
||||
for (const { folder, depth } of tree) {
|
||||
if (folder.parent_id && hidden.has(folder.parent_id)) {
|
||||
hidden.add(folder.id)
|
||||
continue
|
||||
}
|
||||
list.append(folderHead(folder, depth, inside(folder.id)))
|
||||
if (folder.collapsed) {
|
||||
hidden.add(folder.id)
|
||||
continue
|
||||
}
|
||||
for (const r of visible.filter((x) => filed(x) && x.folder_id === folder.id)) {
|
||||
const li = row(r)
|
||||
li.firstChild.style.setProperty("--depth", String(depth + 1))
|
||||
li.firstChild.classList.add("is-filed")
|
||||
list.append(li)
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = visible.filter((r) => !r.pinned && !filed(r))
|
||||
}
|
||||
const today = rest.filter((r) => isToday(r.updated_at))
|
||||
const earlier = rest.filter((r) => !isToday(r.updated_at))
|
||||
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
||||
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
||||
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// The app's language and theme, as Settings chooses them.
|
||||
//
|
||||
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||
// `t()` as it is needed, so a new language opens the window again.
|
||||
//
|
||||
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||
|
||||
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||
import { terminalThemeChanged } from "./terminal.js"
|
||||
|
||||
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||
let me = null // /me, as last read
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) || ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value)
|
||||
else localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||
function known(code) {
|
||||
const c = String(code || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[-_]/)[0]
|
||||
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||
}
|
||||
|
||||
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||
export function accountThemes() {
|
||||
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||
}
|
||||
|
||||
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||
export function localTheme() {
|
||||
const name = read("lembas.theme")
|
||||
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||
}
|
||||
|
||||
/** The account's theme, as the server says it. */
|
||||
export function accountTheme() {
|
||||
return me?.theme || "moria"
|
||||
}
|
||||
|
||||
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||
export function applyTheme(name) {
|
||||
name = localTheme() || name
|
||||
if (!LOOKS.includes(name)) name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
return name
|
||||
}
|
||||
|
||||
function paint() {
|
||||
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||
}
|
||||
|
||||
/**
|
||||
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||
* it is now another, the window opens again in it.
|
||||
*/
|
||||
export function noteMe(next) {
|
||||
me = next || null
|
||||
paint()
|
||||
if (!me || !me.language) return
|
||||
const account = known(me.language)
|
||||
const before = read("lembas.account-language")
|
||||
write("lembas.account-language", account)
|
||||
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||
if (read("lembas.account-language") !== account) return
|
||||
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||
* server cannot be told, kept here ("" goes back to the account's).
|
||||
*/
|
||||
export async function chooseTheme(name) {
|
||||
if (accountThemes()) {
|
||||
const r = await patch("/me", { theme: name })
|
||||
me = r && r.theme ? r : { ...me, theme: name }
|
||||
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||
paint()
|
||||
}
|
||||
|
||||
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||
export function chosenLanguage() {
|
||||
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||
}
|
||||
|
||||
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||
export function accountLanguage() {
|
||||
return me?.language ? known(me.language) : ""
|
||||
}
|
||||
|
||||
/** Choose the language; the window opens again in it. */
|
||||
export function chooseLanguage(choice) {
|
||||
if (choice === chosenLanguage()) return
|
||||
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||
return reopen()
|
||||
}
|
||||
|
||||
/** Open the window again: the replies followed and the shells open let go first. */
|
||||
async function reopen() {
|
||||
await letGoAll()
|
||||
location.reload()
|
||||
}
|
||||
+62
-16
@@ -10,21 +10,26 @@ import { renderBar, initBar } from "./titlebar.js"
|
||||
import { renderSetup, initSetup } from "./setup.js"
|
||||
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||
import { initExplorer, showLocal, showFilesTab, openServerFile } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
|
||||
import { applyTheme, noteMe } from "./look.js"
|
||||
import { initAttach, allowAttach } from "./attach.js"
|
||||
import { initPalette, openPalette } from "./palette.js"
|
||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||
import { initAgent, link } from "./agent.js"
|
||||
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||
import { initArchived, showArchived } from "./archived.js"
|
||||
import { showMemory } from "./memory.js"
|
||||
import { initVoice, voiceFor } from "./voice.js"
|
||||
import { canDraw, openDraw } from "./draw.js"
|
||||
import { closePopover } from "./popup.js"
|
||||
import { noteSubagentsCap } from "./models.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "memory" | "settings"
|
||||
let me = null
|
||||
let models = []
|
||||
let devices = [] // this account's linked devices
|
||||
@@ -63,14 +68,6 @@ function thisDevices() {
|
||||
return remembered.slice()
|
||||
}
|
||||
|
||||
function applyTheme(name) {
|
||||
if (name !== "moria" && name !== "shire") name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
}
|
||||
|
||||
// --- Views ------------------------------------------------------------------------------------
|
||||
|
||||
function route() {
|
||||
@@ -98,8 +95,8 @@ async function startApp() {
|
||||
started = true
|
||||
try {
|
||||
me = await get("/me")
|
||||
applyTheme(me.theme)
|
||||
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
|
||||
noteMe(me)
|
||||
voiceFor(me)
|
||||
} catch (err) {
|
||||
$("thread-note").textContent = why(err)
|
||||
$("thread-note").hidden = false
|
||||
@@ -154,7 +151,7 @@ function accountButton() {
|
||||
|
||||
// --- The pages ----------------------------------------------------------------------------------
|
||||
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, the archived chats, or the settings. */
|
||||
function showPage(name) {
|
||||
page = name
|
||||
closePopover()
|
||||
@@ -162,6 +159,8 @@ function showPage(name) {
|
||||
$("main").hidden = name !== "chat"
|
||||
$("page-files").hidden = name !== "files"
|
||||
$("page-scheduled").hidden = name !== "scheduled"
|
||||
$("page-archived").hidden = name !== "archived"
|
||||
$("page-memory").hidden = name !== "memory"
|
||||
$("settings").hidden = name !== "settings"
|
||||
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||
if (name !== "chat") {
|
||||
@@ -209,6 +208,16 @@ function showSettings(on, section) {
|
||||
if (section) scrollToSection(section)
|
||||
}
|
||||
|
||||
function showArchivedPage() {
|
||||
showPage("archived")
|
||||
showArchived()
|
||||
}
|
||||
|
||||
function showMemoryPage(focus) {
|
||||
showPage("memory")
|
||||
showMemory(focus || "")
|
||||
}
|
||||
|
||||
function showFiles(which) {
|
||||
showPage("files")
|
||||
if (which) showFilesTab(which)
|
||||
@@ -223,8 +232,11 @@ function commands() {
|
||||
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||
...scheduledCommands(),
|
||||
{ label: t("Files"), run: () => showFiles() },
|
||||
{ label: t("Archived"), run: showArchivedPage },
|
||||
{ label: t("Memory"), run: () => showMemoryPage() },
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (canDraw(me)) out.push({ label: t("Draw a picture"), run: openDraw })
|
||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||
@@ -267,7 +279,12 @@ async function boot() {
|
||||
showChat(id)
|
||||
},
|
||||
menu: (anchor, c) => chatMenu(anchor, c),
|
||||
note: (text) => {
|
||||
$("thread-note").textContent = text
|
||||
$("thread-note").hidden = !text
|
||||
},
|
||||
})
|
||||
initVoice()
|
||||
initChat({
|
||||
status: () => status,
|
||||
thisDevices,
|
||||
@@ -291,8 +308,19 @@ async function boot() {
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
initPalette({ commands, open: (c) => showChat(c.id) })
|
||||
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||
initArchived({
|
||||
me: () => me,
|
||||
open: (id) => showChat(id),
|
||||
// Back in the sidebar: the list again.
|
||||
changed: () => refreshList(),
|
||||
})
|
||||
initPalette({
|
||||
commands,
|
||||
open: (c) => showChat(c.id),
|
||||
openFile: (path, name) => openServerFile(path, name),
|
||||
openMemory: (id) => showMemoryPage(id),
|
||||
})
|
||||
initScheduled({ status: () => status, models: () => models, me: () => me, thisDevices, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
@@ -326,6 +354,14 @@ async function boot() {
|
||||
})
|
||||
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||
$("nav-files").addEventListener("click", () => showFiles())
|
||||
$("nav-archived").querySelector(".navrow__icon").replaceChildren(icon(ICONS.archive))
|
||||
$("nav-archived").addEventListener("click", showArchivedPage)
|
||||
$("archived-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("archived-refresh").addEventListener("click", () => showArchived())
|
||||
$("nav-memory").querySelector(".navrow__icon").replaceChildren(icon(ICONS.bulb))
|
||||
$("nav-memory").addEventListener("click", () => showMemoryPage())
|
||||
$("memory-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("memory-refresh").addEventListener("click", () => showMemory())
|
||||
accountButton()
|
||||
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
@@ -374,6 +410,16 @@ async function boot() {
|
||||
listen("open-settings", () => {
|
||||
if (view === "app") showSettings(true)
|
||||
})
|
||||
// A notification clicked: the chat it told of, or the scheduled task whose run it was.
|
||||
listen("open-chat", (e) => {
|
||||
if (view === "app" && typeof e.payload === "string") showChat(e.payload)
|
||||
})
|
||||
listen("open-schedule", (e) => {
|
||||
const id = e.payload?.schedule
|
||||
if (view !== "app" || typeof id !== "string") return
|
||||
showPage("scheduled")
|
||||
showScheduled(id)
|
||||
})
|
||||
status = await invoke("get_status")
|
||||
applyTheme(status.theme)
|
||||
renderBar(status)
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// Memory: what the account's models remember about the person, as the web's Settings → Memory
|
||||
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
|
||||
// group chosen (where there is more than one).
|
||||
//
|
||||
// GET /memories { memories: [memory], groups: [{ id, name }] }
|
||||
// POST /memories { content, data_group_id? } -> { memory }
|
||||
// PATCH /memories/{id} { content } -> { memory }
|
||||
// DELETE /memories/{id} 204
|
||||
//
|
||||
// A record may be a model's words (`author` other than "user"): set as text, never markup.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, post, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
|
||||
let rows = []
|
||||
let groups = []
|
||||
let error = ""
|
||||
let loading = false
|
||||
let editing = "" // the record being changed
|
||||
let mark = "" // the record to bring into view (from a search hit)
|
||||
|
||||
/** The page, read again from the server; `focus` a record to bring into view. */
|
||||
export async function showMemory(focus = "") {
|
||||
mark = focus
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/memories")
|
||||
rows = r.memories || []
|
||||
groups = r.groups || []
|
||||
} catch (err) {
|
||||
rows = []
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function add(form) {
|
||||
const text = form.querySelector("textarea")
|
||||
const content = text.value.trim()
|
||||
if (!content) return text.focus()
|
||||
const group = form.querySelector("select")?.value
|
||||
form.querySelector("button[type=submit]").disabled = true
|
||||
try {
|
||||
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
|
||||
if (r?.memory) rows = [r.memory, ...rows]
|
||||
error = ""
|
||||
text.value = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
$("memory-new")?.focus()
|
||||
}
|
||||
|
||||
async function save(m, content) {
|
||||
content = content.trim()
|
||||
if (!content || content === m.content) {
|
||||
editing = ""
|
||||
return render()
|
||||
}
|
||||
try {
|
||||
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
|
||||
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
|
||||
editing = ""
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/memories/${encodeURIComponent(m.id)}`)
|
||||
rows = rows.filter((x) => x.id !== m.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(m) {
|
||||
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
|
||||
if (editing === m.id) {
|
||||
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
|
||||
box.value = m.content
|
||||
const done = () => save(m, box.value)
|
||||
box.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
editing = ""
|
||||
render()
|
||||
}
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
|
||||
})
|
||||
queueMicrotask(() => box.focus())
|
||||
return h(
|
||||
"li",
|
||||
{ class: "mrow is-editing", dataset: { id: m.id } },
|
||||
box,
|
||||
h(
|
||||
"div",
|
||||
{ class: "set__buttons" },
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
|
||||
),
|
||||
)
|
||||
}
|
||||
return h(
|
||||
"li",
|
||||
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("memory-body"))
|
||||
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
|
||||
const form = h(
|
||||
"form",
|
||||
{
|
||||
class: "mnew",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault()
|
||||
add(e.currentTarget)
|
||||
},
|
||||
},
|
||||
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "mnew__foot" },
|
||||
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
|
||||
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
|
||||
),
|
||||
)
|
||||
form.querySelector("textarea").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||
})
|
||||
parts.push(form)
|
||||
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
|
||||
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
+70
-8
@@ -1,12 +1,16 @@
|
||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
||||
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
|
||||
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
|
||||
// is there.
|
||||
// the chats of both kinds by title, newest first, and below them what the server's search finds
|
||||
// inside them, in the file library and in memory (`GET /search`, once two letters are typed and
|
||||
// the typing pauses). Up and Down move, Enter does it, Escape closes. The chats are asked of the
|
||||
// server each time it opens, so a chat started elsewhere is there.
|
||||
//
|
||||
// A hit's snippet is plain text with the ranges that matched (`marks`): drawn as text, the
|
||||
// ranges as <mark> elements. Titles and snippets may be a model's words; nothing is markup.
|
||||
|
||||
import { h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get } from "./bridge.js"
|
||||
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(chat), openFile(path, name), openMemory(id) }
|
||||
let dlg = null
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
@@ -17,6 +21,22 @@ function fold(text) {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** `text` with the `[start, end)` ranges of `marks` as <mark> elements: text nodes only. */
|
||||
function marked(text, marks) {
|
||||
const el = h("span", { class: "palette__snippet" })
|
||||
let at = 0
|
||||
const chars = [...String(text || "")]
|
||||
for (const [a, b] of (marks || []).filter((m) => Array.isArray(m) && m[1] > m[0]).sort((x, y) => x[0] - y[0])) {
|
||||
if (a < at) continue
|
||||
el.append(chars.slice(at, a).join(""), h("mark", { text: chars.slice(a, b).join("") }))
|
||||
at = b
|
||||
}
|
||||
el.append(chars.slice(at).join(""))
|
||||
return el
|
||||
}
|
||||
|
||||
const SEARCH_KIND = { chats: () => t("Chats"), files: () => t("Files"), memories: () => t("Memory") }
|
||||
|
||||
function matches(text, words) {
|
||||
const f = fold(text)
|
||||
return words.every((w) => f.includes(w))
|
||||
@@ -31,7 +51,11 @@ export async function openPalette() {
|
||||
let chats = []
|
||||
let shown = []
|
||||
let at = 0
|
||||
let found = [] // the server's hits for `asked`
|
||||
let asked = ""
|
||||
let timer = 0
|
||||
const close = () => {
|
||||
clearTimeout(timer)
|
||||
if (!dlg) return
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
@@ -44,16 +68,32 @@ export async function openPalette() {
|
||||
const draw = () => {
|
||||
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
||||
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
||||
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
||||
const titled = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
||||
shown = [
|
||||
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
|
||||
...found.map((c) => ({
|
||||
...titled.map((c) => ({
|
||||
kind: c.kind,
|
||||
label: c.title || t("New chat"),
|
||||
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
|
||||
run: () => ctx.open(c),
|
||||
})),
|
||||
]
|
||||
// What the server found inside: the chats already listed by their title once only.
|
||||
const listed = new Set(titled.map((c) => c.id))
|
||||
for (const group of input.value.trim() === asked ? found : []) {
|
||||
for (const hit of group.hits || []) {
|
||||
if (hit.kind === "chat" && listed.has(hit.id)) continue
|
||||
const kind = { chat: "found-chat", file: "found-file", memory: "found-memory" }[hit.kind]
|
||||
if (!kind) continue
|
||||
shown.push({
|
||||
kind,
|
||||
label: hit.title || (hit.kind === "chat" ? t("New chat") : hit.kind === "file" ? String(hit.path || "").split("/").pop() : t("Memory")),
|
||||
snippet: hit.snippet ? marked(hit.snippet, hit.marks) : null,
|
||||
hint: SEARCH_KIND[group.kind]?.() || "",
|
||||
run: () => (hit.kind === "chat" ? ctx.open({ id: hit.id, kind: hit.agent ? "agent" : "chat" }) : hit.kind === "file" ? ctx.openFile(hit.path, hit.title) : ctx.openMemory(hit.id)),
|
||||
})
|
||||
}
|
||||
}
|
||||
at = Math.min(at, Math.max(0, shown.length - 1))
|
||||
clear(list)
|
||||
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
|
||||
@@ -74,8 +114,10 @@ export async function openPalette() {
|
||||
}
|
||||
},
|
||||
},
|
||||
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||
h("span", { class: "palette__label", text: item.label }),
|
||||
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "found-memory" ? ICONS.bulb : item.kind.startsWith("found") ? ICONS.search : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||
item.snippet
|
||||
? h("span", { class: "palette__label palette__label--two" }, h("span", { class: "palette__title", text: item.label }), item.snippet)
|
||||
: h("span", { class: "palette__label", text: item.label }),
|
||||
h("span", { class: "palette__hint", text: item.hint }),
|
||||
),
|
||||
)
|
||||
@@ -87,9 +129,29 @@ export async function openPalette() {
|
||||
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
||||
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
// The server's search, once the typing pauses: an older server without it finds nothing more.
|
||||
const search = () => {
|
||||
clearTimeout(timer)
|
||||
const q = input.value.trim()
|
||||
if (q.length < 2) {
|
||||
found = []
|
||||
asked = ""
|
||||
return
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await get(`/search?q=${encodeURIComponent(q)}&in=all&limit=8`)
|
||||
if (!dlg || input.value.trim() !== q) return
|
||||
found = r.results || []
|
||||
asked = q
|
||||
draw()
|
||||
} catch {}
|
||||
}, 250)
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
at = 0
|
||||
draw()
|
||||
search()
|
||||
})
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
|
||||
+4
-3
@@ -199,7 +199,8 @@ function escPop(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
|
||||
* A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's
|
||||
* point; or above it, `up`), holding `content` (an element, or a function
|
||||
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
||||
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
||||
*/
|
||||
@@ -211,7 +212,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
||||
draw()
|
||||
document.body.append(el)
|
||||
const place = () => {
|
||||
const at = anchor.getBoundingClientRect()
|
||||
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y }
|
||||
const r = el.getBoundingClientRect()
|
||||
const left = align === "end" ? at.right - r.width : at.left
|
||||
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
||||
@@ -219,7 +220,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
||||
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||
}
|
||||
place()
|
||||
openPop = { el, back: anchor, onClose }
|
||||
openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose }
|
||||
document.addEventListener("pointerdown", outsidePop, true)
|
||||
document.addEventListener("keydown", escPop, true)
|
||||
el.querySelector("button, input, select")?.focus()
|
||||
|
||||
+103
-5
@@ -27,6 +27,15 @@
|
||||
// by the app (Rust, watch.rs), handed here as the
|
||||
// `schedules` event
|
||||
//
|
||||
// GET /devices { devices: [{ id, name, host, scheduled, max_mode }] }:
|
||||
// the linked computers, each saying whether it
|
||||
// accepts scheduled work
|
||||
//
|
||||
// Where it runs (`runs_on`, server 2.0.0-beta.9): null on the server, else `{ device, device_name,
|
||||
// cwd, mode, approval_wait_minutes }` -- an agent chat on that linked computer, in that folder and
|
||||
// mode (never Plan). A run waiting for the computer says `waiting_for: "device"`; one it refused
|
||||
// (it does not accept scheduled work) fails with the computer's own words.
|
||||
//
|
||||
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
||||
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
||||
// model's words, set as text; a result's Markdown goes through markdown.js.
|
||||
@@ -37,7 +46,7 @@ import { ask, menu, popover } from "./popup.js"
|
||||
import { avatar, levelsOf, levelWord } from "./models.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
|
||||
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
||||
let ctx = null // { status(), models(), me(), thisDevices(), openChat(id), show() }
|
||||
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
||||
let tasks = []
|
||||
let missing = false // the server has no scheduling routes
|
||||
@@ -83,7 +92,7 @@ const RUNS_PAGE = 20
|
||||
function statusChip(run, running) {
|
||||
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
||||
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||
const word = STATUS[run.status]?.() || run.status
|
||||
const word = run.status === "waiting" && run.waiting_for === "device" ? t("Waiting for the computer") : STATUS[run.status]?.() || run.status
|
||||
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
||||
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
||||
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
||||
@@ -326,7 +335,8 @@ async function drawTask(id) {
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
const models = (await schedulingModels())?.models || ctx.models()
|
||||
const [smodels, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||
const models = smodels?.models || ctx.models()
|
||||
const body = clear($("scheduled-body"))
|
||||
body.append(
|
||||
h(
|
||||
@@ -344,6 +354,7 @@ async function drawTask(id) {
|
||||
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||
"runs_on" in task ? whereFact(task.runs_on, devices) : null,
|
||||
),
|
||||
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
||||
upcoming(task.next_runs || []),
|
||||
@@ -352,6 +363,46 @@ async function drawTask(id) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Where it runs ------------------------------------------------------------------------------
|
||||
|
||||
const RUN_MODES = {
|
||||
manual: () => t("Manual — asks first"),
|
||||
edit: () => t("Edit — asks before commands"),
|
||||
auto: () => t("Auto — asks nothing"),
|
||||
}
|
||||
|
||||
/** The account's linked computers (`GET /devices`), or null where the server cannot say. */
|
||||
async function linkedDevices() {
|
||||
try {
|
||||
return (await get("/devices")).devices || []
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function deviceLabel(d) {
|
||||
const name = d.name || d.host || d.id
|
||||
return (ctx.thisDevices?.() || []).includes(d.id) ? t("This computer (%(name)s)", { name }) : name
|
||||
}
|
||||
|
||||
/** "Where it runs": the server, or a computer and a folder, with what it is doing about it now. */
|
||||
function whereFact(on, devices) {
|
||||
if (!on) return fact(t("Where it runs"), h("span", { text: t("On this server") }))
|
||||
const d = (devices || []).find((x) => x.id === on.device)
|
||||
const name = d ? deviceLabel(d) : on.device_name || t("A linked computer")
|
||||
let state = ""
|
||||
if (devices && !d) state = t("Away now: a run waits for it, then counts as missed.")
|
||||
else if (d && d.scheduled === false) state = t("It does not accept scheduled work: turn on Accept scheduled work there.")
|
||||
const hint = [RUN_MODES[on.mode]?.() || on.mode, t("approvals wait %(n)s min", { n: on.approval_wait_minutes }), state].filter(Boolean).join(" · ")
|
||||
return h(
|
||||
"div",
|
||||
{ class: `fact${state ? " is-warning" : ""}` },
|
||||
h("span", { class: "fact__label", text: t("Where it runs") }),
|
||||
h("span", { class: "fact__value" }, h("bdi", { text: name }), h("span", { class: "fact__path", title: on.cwd }, h("bdi", { text: on.cwd }))),
|
||||
h("span", { class: "fact__hint", text: hint }),
|
||||
)
|
||||
}
|
||||
|
||||
function fact(label, value, hint) {
|
||||
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
||||
}
|
||||
@@ -448,7 +499,7 @@ function runRow(task, run) {
|
||||
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||
),
|
||||
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || (task.runs_on ? task.runs_on.device_name || "" : "") }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||
open || h("span", { class: "run__error", title: note, text: note }),
|
||||
detail,
|
||||
)
|
||||
@@ -488,7 +539,10 @@ async function drawForm(task) {
|
||||
const editing = !!task
|
||||
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
||||
clear($("scheduled-tools"))
|
||||
const smodels = (await schedulingModels())?.models || []
|
||||
const [sm, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||
const smodels = sm?.models || []
|
||||
// Offered where the server can run a schedule on a computer: its devices say whether they accept.
|
||||
const canPlace = !!devices && (devices.some((d) => "scheduled" in d) || !!task?.runs_on)
|
||||
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
||||
const draft = {
|
||||
request: task?.request || "",
|
||||
@@ -501,6 +555,7 @@ async function drawForm(task) {
|
||||
agents: task?.agents || null,
|
||||
notify: task?.notify || "matters",
|
||||
mode: task?.mode || "fresh",
|
||||
runs_on: task?.runs_on ? { device: task.runs_on.device, cwd: task.runs_on.cwd, mode: task.runs_on.mode, approval_wait_minutes: task.runs_on.approval_wait_minutes } : null,
|
||||
}
|
||||
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
||||
|
||||
@@ -555,6 +610,47 @@ async function drawForm(task) {
|
||||
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
||||
}
|
||||
|
||||
/** Where it runs: the server, or a linked computer that accepts scheduled work, its folder and mode. */
|
||||
const whereFields = () => {
|
||||
const on = draft.runs_on
|
||||
const listed = devices || []
|
||||
const where = h(
|
||||
"select",
|
||||
{
|
||||
id: "sched-where",
|
||||
onchange: (e) => {
|
||||
const id = e.target.value
|
||||
if (!id) draft.runs_on = null
|
||||
else {
|
||||
const mine = (ctx.thisDevices?.() || []).includes(id)
|
||||
const roots = ctx.status?.()?.remote?.roots || []
|
||||
draft.runs_on = { device: id, cwd: draft.runs_on?.cwd || (mine ? roots[0] || "" : ""), mode: draft.runs_on?.mode || "edit", approval_wait_minutes: draft.runs_on?.approval_wait_minutes || 30 }
|
||||
}
|
||||
drawSettings()
|
||||
settings.querySelector(draft.runs_on ? "#sched-cwd" : "#sched-where")?.focus()
|
||||
},
|
||||
},
|
||||
h("option", { value: "", text: t("On this server"), selected: !on }),
|
||||
listed.map((d) => h("option", { value: d.id, text: d.scheduled === false ? `${deviceLabel(d)} — ${t("does not accept scheduled work")}` : deviceLabel(d), disabled: d.scheduled === false && on?.device !== d.id, selected: on?.device === d.id })),
|
||||
on && !listed.some((d) => d.id === on.device) ? h("option", { value: on.device, text: `${task?.runs_on?.device_name || on.device} — ${t("not linked now")}`, selected: true }) : null,
|
||||
)
|
||||
const out = [field(t("Where it runs"), where, on ? t("A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).") : "", "sched-where")]
|
||||
if (!on) return out
|
||||
const cwd = h("input", { type: "text", id: "sched-cwd", value: on.cwd, placeholder: "/home/…", spellcheck: "false", oninput: (e) => (on.cwd = e.target.value.trim()) })
|
||||
const mode = h(
|
||||
"select",
|
||||
{ id: "sched-runmode", onchange: (e) => (on.mode = e.target.value) },
|
||||
Object.entries(RUN_MODES).map(([v, word]) => h("option", { value: v, text: word(), selected: on.mode === v })),
|
||||
)
|
||||
const wait = h("input", { type: "number", id: "sched-wait", min: "1", max: "1440", step: "1", value: String(on.approval_wait_minutes || 30), oninput: (e) => (on.approval_wait_minutes = Number(e.target.value) || 30) })
|
||||
out.push(
|
||||
field(t("Folder on the computer"), cwd, t("A full path, inside a folder the computer allows remote work in."), "sched-cwd"),
|
||||
field(t("Mode"), mode, t("Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it."), "sched-runmode"),
|
||||
field(t("An approval waits (minutes)"), wait, t("A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied."), "sched-wait"),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
const drawSettings = () => {
|
||||
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
||||
const select = (id, table, key, onChange) =>
|
||||
@@ -586,6 +682,7 @@ async function drawForm(task) {
|
||||
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||
)
|
||||
settings.replaceChildren(
|
||||
...(canPlace ? whereFields() : []),
|
||||
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||
@@ -656,6 +753,7 @@ async function drawForm(task) {
|
||||
notify: draft.notify,
|
||||
mode: draft.mode,
|
||||
}
|
||||
if (canPlace) body.runs_on = draft.runs_on ? { ...draft.runs_on } : null
|
||||
try {
|
||||
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
||||
go({ name: "task", id: r.schedule.id })
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
|
||||
// the server offers this chat now (web search among them) and the skills, each on or off for this
|
||||
// chat only, and the commands it was told to stop asking about.
|
||||
//
|
||||
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
|
||||
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
|
||||
// POST /chats/{chat}/allow/clear -> { scope }
|
||||
//
|
||||
// Narrowing only, on the server: switching on what the model, the permissions or the instance
|
||||
// left out reaches nothing. Labels, skill names and descriptions are set as text.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
import { get, post, why } from "./bridge.js"
|
||||
import { popover } from "./popup.js"
|
||||
|
||||
/** The switches of `chat`, in a popover under `anchor`. */
|
||||
export async function scopePopover(anchor, chat) {
|
||||
const path = `/chats/${encodeURIComponent(chat.id)}`
|
||||
let scope = null
|
||||
let error = ""
|
||||
let busy = false
|
||||
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
|
||||
|
||||
// Drawn again after each answer: the switch that was used keeps the focus.
|
||||
const redraw = (key) => {
|
||||
pop.redraw()
|
||||
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
|
||||
}
|
||||
const send = async (url, body) => {
|
||||
const key = document.activeElement?.dataset?.key || ""
|
||||
busy = true
|
||||
redraw(key)
|
||||
try {
|
||||
scope = (await post(url, body))?.scope || scope
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
busy = false
|
||||
redraw(key)
|
||||
}
|
||||
|
||||
const toggle = (kind, name, label, on) =>
|
||||
h(
|
||||
"label",
|
||||
{ class: "check scope__row", title: label.title || null },
|
||||
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
|
||||
h("span", { text: label.text }),
|
||||
)
|
||||
|
||||
function draw() {
|
||||
const body = h("div", { class: "pop__body scope" })
|
||||
if (!scope) {
|
||||
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
|
||||
return body
|
||||
}
|
||||
const families = scope.families || []
|
||||
const skills = scope.skills || []
|
||||
const allow = scope.allow || []
|
||||
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
|
||||
if (families.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Tools") }),
|
||||
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
|
||||
)
|
||||
}
|
||||
if (skills.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Skills") }),
|
||||
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
|
||||
)
|
||||
}
|
||||
if (allow.length) {
|
||||
body.append(
|
||||
h("p", { class: "pop__title", text: t("Always allowed here") }),
|
||||
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
|
||||
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
|
||||
)
|
||||
}
|
||||
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
|
||||
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||
return body
|
||||
}
|
||||
|
||||
try {
|
||||
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
pop.redraw()
|
||||
}
|
||||
+123
-2
@@ -1,10 +1,13 @@
|
||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
||||
// sign-in), the agent and the folders it may work in, notifications, and this release.
|
||||
// sign-in), the agent and the folders it may work in, the language and theme, notifications, and
|
||||
// this release.
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen } from "./bridge.js"
|
||||
import { invoke, listen, get, openLink } from "./bridge.js"
|
||||
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
|
||||
import { why } from "./bridge.js"
|
||||
|
||||
let folderError = ""
|
||||
let current = () => null
|
||||
@@ -13,6 +16,7 @@ const SECTIONS = [
|
||||
["account", ICONS.user, () => t("Account")],
|
||||
["agent", ICONS.terminal, () => t("Agent")],
|
||||
["folders", ICONS.folder, () => t("Project folders")],
|
||||
["look", ICONS.contrast, () => t("Appearance")],
|
||||
["notify", ICONS.bell, () => t("Notifications")],
|
||||
["about", ICONS.info, () => t("About")],
|
||||
]
|
||||
@@ -118,6 +122,8 @@ export function renderSettings(s) {
|
||||
)
|
||||
}
|
||||
|
||||
box.append(lookSection())
|
||||
|
||||
box.append(
|
||||
section(
|
||||
"notify",
|
||||
@@ -130,6 +136,7 @@ export function renderSettings(s) {
|
||||
),
|
||||
)
|
||||
|
||||
const advised = recommendation(s)
|
||||
box.append(
|
||||
section(
|
||||
"about",
|
||||
@@ -139,8 +146,122 @@ export function renderSettings(s) {
|
||||
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||
),
|
||||
advised
|
||||
? line(
|
||||
t("Your server recommends LLeMbas Desktop %(version)s", { version: advised.version }),
|
||||
t("The release that goes with the server's version. This is %(current)s.", { current: s.version }),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Open the release page"), onclick: () => openLink(advised.page) }),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
if (s.account && appsFor !== s.server) askApps(s.server)
|
||||
}
|
||||
|
||||
// --- The release the server recommends (GET /apps) --------------------------------------------------
|
||||
//
|
||||
// GET /apps { server: { version, channel }, apps: { desktop: { version, tag, channel, page } | null } }
|
||||
//
|
||||
// Shown when it is newer than this app. A stable install is never offered a beta; a beta install
|
||||
// may be. Only the project's own release pages are opened.
|
||||
|
||||
let apps = null // the server's recommendation; null: none (an older server, or nothing newer)
|
||||
let appsFor = null // the server it was asked of
|
||||
const RELEASES = "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/"
|
||||
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/
|
||||
|
||||
/** A sortable key for `X.Y.Z` / `X.Y.Z-beta.N` (a release above its betas); null for anything else. */
|
||||
export function versionKey(text) {
|
||||
const m = VERSION.exec(String(text || "").trim())
|
||||
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? Infinity : +m[4]] : null
|
||||
}
|
||||
|
||||
function newer(a, b) {
|
||||
for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] > b[i]
|
||||
return false
|
||||
}
|
||||
|
||||
async function askApps(server) {
|
||||
appsFor = server
|
||||
apps = null
|
||||
try {
|
||||
const r = (await get("/apps"))?.apps?.desktop || null
|
||||
if (appsFor === server) apps = r
|
||||
} catch {
|
||||
apps = null
|
||||
}
|
||||
if (!$("settings").hidden) renderSettings(current())
|
||||
}
|
||||
|
||||
/** The recommended release, where it is newer than this one and on a channel this install takes. */
|
||||
function recommendation(s) {
|
||||
if (!apps) return null
|
||||
const mine = versionKey(s.version)
|
||||
const theirs = versionKey(apps.version)
|
||||
if (!mine || !theirs) return null
|
||||
if (theirs[3] !== Infinity && mine[3] === Infinity) return null
|
||||
if (!newer(theirs, mine)) return null
|
||||
const page = String(apps.page || "")
|
||||
if (!page.startsWith(RELEASES) || !/^[A-Za-z0-9._-]+$/.test(page.slice(RELEASES.length))) return null
|
||||
return { version: apps.version, page }
|
||||
}
|
||||
|
||||
let lookError = ""
|
||||
|
||||
/**
|
||||
* The language and the theme. The language: the account's (the default), this computer's, or one
|
||||
* the app has, kept here. The theme: the account's, saved to it; kept here where the server
|
||||
* cannot be told.
|
||||
*/
|
||||
function lookSection() {
|
||||
const named = (code) => LEMBAS_LANGS[code] || code
|
||||
const account = accountLanguage()
|
||||
const languages = [
|
||||
["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })],
|
||||
["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })],
|
||||
...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]),
|
||||
]
|
||||
const language = h(
|
||||
"select",
|
||||
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
|
||||
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
|
||||
)
|
||||
const scheme = (s) => (s === "light" ? t("light") : t("dark"))
|
||||
const offered = accountThemes()
|
||||
const themes = offered
|
||||
? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`])
|
||||
: [
|
||||
["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })],
|
||||
["moria", `Moria — ${t("dark")}`],
|
||||
["shire", `Shire — ${t("light")}`],
|
||||
]
|
||||
const chosen = offered ? accountTheme() : localTheme()
|
||||
const theme = h(
|
||||
"select",
|
||||
{
|
||||
id: "set-theme",
|
||||
"aria-label": t("Theme"),
|
||||
onchange: async (e) => {
|
||||
const el = e.target
|
||||
el.disabled = true
|
||||
try {
|
||||
await chooseTheme(el.value)
|
||||
lookError = ""
|
||||
} catch (err) {
|
||||
lookError = why(err)
|
||||
}
|
||||
renderSettings(current())
|
||||
},
|
||||
},
|
||||
themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })),
|
||||
)
|
||||
return section(
|
||||
"look",
|
||||
t("Appearance"),
|
||||
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
|
||||
line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme),
|
||||
lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null,
|
||||
)
|
||||
}
|
||||
|
||||
export function initSettings(getStatus) {
|
||||
|
||||
+4
-1
@@ -280,6 +280,9 @@ export async function showTerminal(on) {
|
||||
ctx.layout(on)
|
||||
markOpen()
|
||||
if (!on) return
|
||||
// Whether this showing is to open a shell: none was open as it began. One that ends meanwhile
|
||||
// (before the frame below) is said, not quietly replaced.
|
||||
const wanted = !session
|
||||
try {
|
||||
await load()
|
||||
} catch {
|
||||
@@ -291,7 +294,7 @@ export async function showTerminal(on) {
|
||||
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||
requestAnimationFrame(() => {
|
||||
refit()
|
||||
connect()
|
||||
if (wanted && terminalOpen()) connect()
|
||||
term.focus()
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export function closeViewer() {
|
||||
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
|
||||
* language already.
|
||||
*/
|
||||
function withheldWords(withheld, size) {
|
||||
export function withheldWords(withheld, size) {
|
||||
if (typeof withheld === "object" && withheld.message) return String(withheld.message)
|
||||
const reason = typeof withheld === "object" ? withheld.reason : withheld
|
||||
return reason === "too_big" || reason === "too_large"
|
||||
|
||||
+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()
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
/* LLeMbas v2.0.0-beta.8 (d06ca353b801) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||
/* LLeMbas v2.0.0-beta.10 (6d1ab36371c8) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||
The font-face rules are left out; the app bundles no font. */
|
||||
/*
|
||||
Design tokens.
|
||||
@@ -114,6 +114,8 @@
|
||||
--w-field-sm: 10rem; /* a short select's floor */
|
||||
--w-field: 14rem; /* a capped field, a label column, a small menu */
|
||||
--w-chip: 16rem; /* a name that truncates: a model, a path */
|
||||
--w-chip-sm: 12rem; /* the same name where the bar is narrow */
|
||||
--w-select-sm: 9rem; /* a short select in a toolbar (the chat's mode) */
|
||||
--w-menu-sm: 20rem; /* a compact popover, an attachment chip */
|
||||
--w-menu: 24rem; /* a popover, a results list, a card's column */
|
||||
--w-menu-lg: 28rem; /* a wide popover */
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"tag": "v2.0.0-beta.8",
|
||||
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||
"tag": "v2.0.0-beta.10",
|
||||
"commit": "6d1ab36371c8492ee2590fbe25d112d73513a9ae",
|
||||
"files": {
|
||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||
|
||||
Reference in new issue
Block a user