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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:14:23 +00:00
HomerandClaude Opus 5.5 effcc2c2fd tests/ui: a failure's annotation on GitHub names the line of run.mjs
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:07:48 +00:00
HomerandClaude Opus 5.5 97c2301bab A new agent chat on another linked computer (as the web and Android can)
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Where the account has another computer linked (GET /devices), a chip before the folder
chooses it; this computer stays the default. Its folder is one its agent chats have worked
in, or a path typed in; the Terminal before the first message opens there too. Tests D4;
failing UI tests are also GitHub annotations.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:19:35 +00:00
HomerandClaude Opus 5.5 cf13d49ed6 LLeMbas Desktop 1.0.0-beta.6: CLI 2.0.0-beta.6, the look and words of LLeMbas 2.0.0-beta.8
ci / ui (push) Waiting to run
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:46:44 +00:00
HomerandClaude Opus 5.5 4b5b22c7d4 Subagents (C4): the tree in the activity panel, read-only sessions, findings, stop; agent chats' setting
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
On the server's /api/v2 (3.4.0, server dev 5d7b79c):
- The activity panel reads GET /chats/{chat}/tree beside the activity and
  draws ui/js/tree.js's rows in a Subagents section, the server panel's words
  and layout: nested by parent, position, phase, context %, state, model,
  mode + request options, messages and the last one, open slots, the ended
  ones folded and dimmed. A team's members are no longer drawn again among
  the tasks. The stream's `tree` frame redraws it.
- A subagent's conversation (/subagents/{id}/transcript, read again every 3 s
  while it works) and its findings open read-only in the panel; Stop asks,
  then POSTs /stop. With an agent chat's device away: the kept tree with the
  server's note, and its 409 sentence for the rest.
- A new agent chat's Subagents chip from /models?kind=agent&device= (each
  model's `subagents`, `agent_subagents.offered` and `device_runs`); an open
  agent chat's from its own `subagents`, as before.
- An approval item's `subagent` names it on the card.
- Progress keys are the spec's own ({{field}}), as the server's catalogues
  have them; SERVER_PENDING is gone, every sentence is translated.

tests/ui reads the server's contract fixtures (scripts/fetch-fixtures.sh ->
tests/ui/fixtures, SOURCE says the commit): status frames, tree, tree frame,
transcript, findings, models?kind=agent, a subagent's approval; six C4 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:44:35 +00:00
HomerandClaude Opus 5.5 89c769c806 Subagents (C4, ahead of the server): the 3.4.0 tree as rows, not yet in the panel
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
ui/js/tree.js draws subagents.md §12's data: the main agent at the root, each
subagent under its parent in the order added (name, id, position, state in
team.read.states' words, model, mode with request options, context % and a
meter, its progress line, its room messages and the last one), open slots, and
the ended ones folded apart, dimmed, with how each ended; open its session,
its findings, stop a live one. Not wired into the activity panel yet: the
server's /api/v2 does not carry the tree, the transcript or the findings yet.

tests/ui: the spec's own example tree, drawn and acted on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:38:52 +00:00
HomerandClaude Opus 5.5 a91394f71d CI: the page under a headless browser on every push (tests/ui, the ui job)
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
ci / ui (push) Waiting to run
A job of its own on ubuntu-24.04: Node 20, playwright-core 1.65.0 installed
into the runner's temp directory for the run (the repository keeps no
package.json), its own Chromium with the system libraries it needs, then
node tests/ui/run.mjs. Nothing is built and no server started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:11:21 +00:00
HomerandClaude Opus 5.5 3baae016e1 Progress: the reply's line is the 3.4.0 progress object, {{text}} · {{elapsed}}
ci / start (push) Failing after 2s
ci / check (push) Failing after 3s
ui/js/progress.js draws harness spec 3.4.0's `progress` (progress.md) with the
spec's texts as the page's sentences: the thirteen phases, their variants and
field words, the line with its clock in whole seconds (5s, 1m 05s, 1h 02m),
none for stopped and failed, retrying counted down. A live reply takes
`progress` from any stream frame that carries it and ticks once a second; with
no progress (an older server, or null) it draws the status text as before.

The sentences are the server's to translate: gen-i18n.py takes them from its
catalogues once they are there (SERVER_PENDING), English until then, without
failing.

tests/ui: every case of the spec's conformance/progress.json (copied, 3.4.0),
and a live stream's progress, clock, fallback and stopped line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:10:20 +00:00
HomerandClaude Opus 5.5 3ef0bbaad3 Terminal: the socket's end says itself whether the sign-in was refused
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
The closing callback gets the End as it is (End::unauthorized), instead of
lib.rs reading the code back out of the JSON sent to the page -- which left
End::unauthorized unused and CI's -D warnings refused the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:09:21 +00:00
HomerandClaude Opus 5.5 ac0d4ce623 Terminal: a shell on the agent chat's computer, before the first message too
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s
The Terminal panel opens a shell on the device an agent chat works on, in its
folder: for a new agent chat as soon as the device and folder are chosen (the
server's /api/v2/devices/{device}/terminal/ws?directory=, which the chat's first
message takes over -- the same socket carries on), and for an open agent chat
from the header (/api/v2/chats/{chat}/terminal/ws, the web page's shell).
Offered where GET /devices says the device opens a terminal.

The socket is Rust's (src-tauri/src/terminal.rs, tungstenite on the rustls
ureq already brings): the bearer token goes in the handshake and never reaches
the page, which gets the frames as `terminal` events and sends keys, sizes and
close through commands. 4401 signs the app out as a 401 does.

xterm and its fit addon are the server's vendored build, pinned through
fetch-vendor.sh like CodeMirror and loaded on the first open. Send to chat puts
the selection, the last marked command and its output, or the screen into the
composer, never sent. The terminal and the activity panel take turns in the
panel column. Continue in a terminal stays in the chat's menu.

tests/ui: seven B3 cases with a mocked socket, at 1280 and 390 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:06:01 +00:00
HomerandClaude Opus 5.5 ec32bca644 Subagents: a new chat reads /models again, so subagents switched on later show
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
The Subagents chip on a new chat follows `/models` -> model.subagents.offered, the same
team_run.offered the web page renders from. The app read /models once, at sign-in, and kept
it while it stayed open in the tray: subagents switched on afterwards (or a model added)
never reached a new chat until a restart. startNew() now re-reads it (at most every 5 s,
keeping the last list if the read fails) and redraws the draft's chips (modelsChanged()).
An open chat already re-reads its own `subagents` with GET /chats/{id}.

tests/ui: A5 cases -- parallel model offered: chip, slider to the server's cap, no note;
one-request model offered named_only: the `own` note; offered false everywhere: no chip;
switched on while open: the next new chat has it; an open chat follows its `subagents`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:17:30 +00:00
HomerandClaude Opus 5.5 5a87eec28d Approval cards show again: the buttons no longer throw, the command is the item's detail
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
An agent chat's bash approval left "Waiting for you to allow bash…" with no card: cardOf()
called append(actions, a, b, c) while append() took one array, so `children.flat` threw for
every approval and plan card (questions used el.append and worked). renderCards() had already
unhidden #cards, which stayed empty. append() now takes children as rest arguments too.

- The editable command is `detail` (services/device_link.py puts the fixed "Run a command" in
  the title); it is sent back as the item's text only when changed. Non-editable approvals
  show the title and the device's input. `purpose` ("It says: …"), `reason` and the asking
  subagent (`job`) are shown, as on the web page.
- renderCards() keeps a card already on the screen (by id) instead of drawing every card again
  on each `ask` frame, which the server re-sends whenever the countdown changes; a card that
  cannot be drawn shows the error instead of an empty box.
- tests/ui/: headless harness with a mocked Tauri bridge; five A1 cases (device bash card
  shows and is visible, Allow/edit/Deny post to /chats/{id}/interactions/{card}, redraw keeps
  edits, reopen gets the card from the stream's first pass, 390 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:13:02 +00:00
75 changed files with 10305 additions and 158 deletions

No files matched your search

+26 -4
View File
@@ -1,7 +1,8 @@
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
# page's scripts, the pins (tokens, CodeMirror), the public-repository hygiene -- and the app
# started for real, on X11 and on Wayland, to prove it gets as far as its window. The packages
# are built only by release.yml, from a tag.
# page's scripts, the pins (tokens, CodeMirror, xterm), the public-repository hygiene, the page
# itself under a headless browser (tests/ui) -- and the app started for real, on X11 and on
# Wayland, to prove it gets as far as its window. The packages are built only by release.yml,
# from a tag.
name: ci
on:
push:
@@ -40,11 +41,32 @@ jobs:
run: cargo test --locked
- name: The page's scripts parse, and none sets HTML
run: scripts/check-ui.sh
- name: The design tokens and CodeMirror are the server's, as pinned
- name: The design tokens, CodeMirror and xterm are the server's, as pinned
run: |
scripts/fetch-tokens.sh
scripts/fetch-vendor.sh
# The page (ui/) under a headless Chromium, against a stand-in for the app's bridge
# (tests/ui/mock-tauri.js) with the server's answers as a table: nothing is built and no server
# is started. Playwright is the library only, installed for the run and not a dependency of
# the repository (it has no package.json), with the Chromium it was made for.
ui:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Playwright and its Chromium
run: |
npm install --no-save --no-package-lock --prefix "$RUNNER_TEMP/pw" playwright-core@1.65.0
node "$RUNNER_TEMP/pw/node_modules/playwright-core/cli.js" install --with-deps chromium
- name: The page under a headless browser
env:
NODE_PATH: ${{ runner.temp }}/pw/node_modules
run: node tests/ui/run.mjs
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
+118 -1
View File
@@ -4,7 +4,124 @@ All notable changes to LLeMbas Desktop. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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.
### Fixed
- **Approvals show again.** An agent asking to run a command, edit a file or carry out a plan
stopped with "Waiting for you to allow bash…" and no card to answer it: drawing the card's
buttons failed, and the space for it stayed empty. Stopping and asking again gave the same.
Now the card shows, with the command in the box to correct before it runs (it used to show the
words "Run a command" there instead), what the model says it is for, why it is asking, and which
subagent asks when one does. A card already on the screen is kept as it is while the reply goes
on, so a command being corrected or an option picked is no longer lost when the server sends
the list again; a card that cannot be drawn says why instead of leaving an empty space.
- **Subagents after they are switched on.** The app read which models a new chat is offered
subagents on only when it signed in, so subagents switched on (or a model added) on the server
while the app stayed open in the tray never showed on a new chat until it was restarted. A new
chat now asks the server again. (An agent chat has no Subagents setting: its subagents run on
the computer, and the server offers none for it.)
### Added
- **Terminal.** An agent chat has a Terminal panel: a shell on the computer the chat works on, in
its folder, beside the chat (over it in a narrow window). A new agent chat offers it as soon as
the folder is chosen, before the first message -- and the chat that message makes carries on in
the same shell. An open agent chat opens its own from the header, the same shell the web
interface shows. **Send to chat** puts the selection, or the last command and its output, into
the message box to read before sending; the panel copies them too, and steps between commands
where the shell marks them. The terminal and the activity panel take turns beside the chat;
closing either keeps the shell. Offered where the computer allows a terminal (needs the server's
2.0.0-beta.8). The app opens the connection itself, with its sign-in: the page never holds it.
"Continue in a terminal" is now in the chat's menu only.
- **What a reply is doing, the same way everywhere.** The line under a reply being written now
says it in the words every LLeMbas client uses (harness spec 3.4.0): queued, waiting for a
model and why, loading it, reading the prompt (with how far), thinking, writing (with its
speed), running a tool and what it runs, waiting for you, waiting for subagents, compacting,
retrying (counting down), stopped or failed -- each with how long it has lasted, kept moving
every second. From a server that sends no such line (before 2.0.0-beta.8), the app shows the
server's own words as before. In every language the server has.
- **Subagents, the whole tree** (harness spec 3.4.0, needs the server's 2.0.0-beta.8). The
activity panel shows the main agent and every subagent under the one it reports to: its name and
id, its position, what it is doing right now, how full its context is, its state, model and
mode (with what it may ask for), its messages in the room and the last one, how many slots are
still open, and the ones that ended -- dimmed, with how they ended. Any subagent's conversation
opens read-only, live while it works and after it ended, and so do its findings; a working one
can be stopped (it and those under it). An agent chat's tree comes from its computer; while that
computer is away the panel shows the tree as it last was and says the rest is kept there.
- **Subagents for agent chats.** A new agent chat offers the Subagents setting where the server
says this computer can run them, and an open agent chat where the server offers it for that
chat.
- **A subagent's approval names it**: "Subagent parser (s1), reviews the parser, asks:".
- `tests/ui/`: the page under a headless browser against a stand-in for the app's bridge, with
the server's answers as a table (`node tests/ui/run.mjs`, needs Playwright and a Chromium). CI
runs it on every push (the `ui` job), with Playwright and its Chromium installed for the run.
## [1.0.0-beta.5] — 2026-11-09
+1 -1
View File
@@ -1 +1 @@
2.0.0-beta.5
2.0.0-beta.7
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Copy the server's /api/v2 contract fixtures that tests/ui reads into tests/ui/fixtures/:
#
# scripts/fetch-fixtures.sh <server-ref> (a tag such as v2.0.0-beta.8, or a branch)
#
# The server is a git checkout, LLeMbas beside this repository by default (LEMBAS_SERVER names
# another); the files are read from the ref, not from its working tree. SOURCE records the ref and
# the commit, so the tests say which server they hold the app to. CI has no server checkout: the
# copies are committed.
set -euo pipefail
cd "$(dirname "$0")/.."
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
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}")" \
|| { echo "fetch-fixtures: $1 is not a commit in $SERVER" >&2; exit 1; }
mkdir -p "$OUT"
for f in $FILES; do
git -C "$SERVER" show "$commit:$FROM/$f" > "$OUT/$f" || { echo "fetch-fixtures: $f is not at $1" >&2; exit 1; }
done
printf 'LLeMbas %s (%s) %s\n' "$1" "$commit" "$FROM" > "$OUT/SOURCE"
echo "fetch-fixtures: $(echo $FILES | wc -w) files from LLeMbas $1 (${commit:0:12}) -> $OUT"
+16 -4
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Put the server's vendored CodeMirror build where the app's own pages read it from:
# Put the server's vendored CodeMirror build and xterm where the app's own pages read them from:
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
# ui/vendor/xterm.js, xterm.css, xterm-addon-fit.js (the terminal panel's, copied unchanged:
# classic scripts the panel loads when it is first opened)
#
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
@@ -47,8 +49,8 @@ if [ $# -eq 0 ]; then
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
bad=1
fi
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK")
for f in "$OUT"/*.mjs; do
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.(mjs|js|css))": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\3/p' "$LOCK")
for f in "$OUT"/*; do
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
done
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
@@ -87,6 +89,16 @@ for name in $names; do
fi
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
done
# xterm and its fit addon, for the terminal panel: copied as they are, each checked against the
# server's pin like the modules.
for name in xterm.js xterm.css xterm-addon-fit.js; do
git -C "$SERVER" show "$commit:$SOURCE/$name" > "$work/out/$name" \
|| { echo "fetch-vendor: $name is not in $SOURCE at $REF" >&2; exit 1; }
pinned="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], {}).get("sha256", ""))' "$work/server-lock.json" "$name")"
have="$(sha "$work/out/$name")"
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$have")")
done
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
stub="src/lembas/web/static/js/node-process.mjs"
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
@@ -101,7 +113,7 @@ done
rm -rf "$OUT"
mkdir -p "$OUT"
cp "$work"/out/*.mjs "$OUT/"
cp "$work"/out/*.mjs "$work"/out/xterm.js "$work"/out/xterm.css "$work"/out/xterm-addon-fit.js "$OUT/"
{
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
last=$((${#entries[@]} - 1))
+635 -4
View File
@@ -61,6 +61,8 @@ KEYS = (
"The agent asks to run %(tool)s:",
"The agent asks:",
"Command",
"%(job)s asks:",
"It says: %(purpose)s",
"Recommended",
"Or type an answer",
"Your answer",
@@ -374,6 +376,173 @@ KEYS = (
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.",
"Team: %(size)s of at most %(max)s subagents",
"How many work on it at once, the run's own agent among them, on the same model.",
# terminal.js
"Terminal",
"Close terminal",
"Send to chat",
"Connecting…",
"Connected.",
"Connected. This shell is also open in another tab, and they share a size.",
"Disconnected. Close and reopen to reconnect.",
"This terminal closed.",
"Reconnecting…",
"The terminal could not be loaded.",
"Nothing has been run in this shell yet.",
"Nothing to send: select some output first.",
"Copied the last of the screen, as it appeared. This shell does not mark where commands begin.",
"Copied.",
"The previous command",
"The previous command (Ctrl+Shift+↑)",
"The next command",
"The next command (Ctrl+Shift+↓)",
"Copy the last command and its output",
"Put the last command and its output into the message box",
"Sign in again: the server no longer accepts this app's sign-in.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.",
# progress.js (harness spec 3.4.0: loop.json progress.texts, keyed as the spec writes them)
"Queued",
"Queued: {{ahead}} ahead",
"Waiting for {{model}}: {{reason}}",
"Loading {{model}}",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
"Reading the prompt",
"Writing: {{rate}} tok/s",
"Writing",
"Running {{tool}}: {{what}}",
"Running {{tool}}",
"Waiting for you: {{what}}",
"Waiting for subagents: {{running}} running",
"Compacting the conversation",
"Retrying in {{in}} s: {{reason}}",
"Stopped",
"Stopped: {{reason}}",
"Failed: {{reason}}",
"it serves one request at a time and is answering another",
"all its slots are in use",
"its server holds another model now",
"allow {{tool}}?",
"an approval",
"a question",
"the plan",
"by you",
"step limit",
"budget",
# tree.js, activity.js (the subagent tree), chat.js (a subagent's approval)
"waiting for a slot",
"finished",
"idle",
"paused: context full",
"out of context",
"the main agent",
"%(size)s of %(max)s",
"Open slots: %(n)s",
"They run on the computer this chat runs on.",
"Ended",
"Context used",
"messages: %(n)s",
"findings: %(n)s",
"Findings",
"Stop %(name)s and the subagents under it?",
"This device is not linked right now.",
"Subagent %(name)s (%(id)s), %(position)s, asks:",
# archived.js
"Archived",
"Restore chat",
"No archived chats.",
# chat.js: a new agent chat on another computer
"Computer",
"Folder on %(device)s",
"The folder's full path on that computer. Its agent opens only folders it has been given.",
"Choose",
"Choose a folder…",
"Type a folder…",
"Choose a folder first.",
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.",
# settings.js: language and theme
"Appearance",
"Language",
"Theme",
"dark",
"light",
"The interface only. Models answer in the language you write to them in, whatever this says.",
"This computer's (%(language)s)",
"The account's (%(theme)s)",
"Kept in this app on this computer. The window opens again in the language chosen.",
"Kept in this app on this computer; the account's theme stays as it is.",
"The account's (%(language)s)",
"Saved to your account: the web interface and the phone follow it too.",
# list.js, chat.js: pinned chats and chat folders
"Pinned chats",
"Pin chat",
"Unpin chat",
"New folder",
"Rename folder",
"Delete folder",
"Folder name",
"Move to folder…",
"No folder",
"New folder…",
"Create",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.",
# palette.js, memory.js: search inside, and the memory page
"Memory",
"Add",
"Data group",
"Remove this memory?",
"Noted by a model",
"What your models remember about you, across chats. Change or remove anything here.",
"Something to remember…",
"Nothing remembered yet.",
# explorer.js, scope.js: the library's writes, and a chat's tools and skills
"Upload",
"Moved to the trash.",
"Move to the trash",
"Move “%(name)s” to the trash? It can be restored from there.",
"Tools and skills…",
"Skills",
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.",
"Always allowed here",
"Ask me about these again",
"Nothing to switch in this chat.",
# voice.js, draw.js: dictation, read aloud, and drawing a picture
"Dictate a message",
"Stop dictating",
"Writing down…",
"The microphone could not be opened: %(why)s",
"Read this aloud",
"The server's answer was not audio.",
"Draw a picture",
"What to draw…",
"Shape",
"Square",
"Wide",
"Tall",
"Draw",
"Drawing… a picture can take a minute or two.",
"The server's answer had no picture in it.",
# settings.js: the release the server recommends
"Your server recommends LLeMbas Desktop %(version)s",
"The release that goes with the server's version. This is %(current)s.",
"Open the release page",
# scheduled.js: where a task runs
"Where it runs",
"On this server",
"does not accept scheduled work",
"not linked now",
"A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).",
"Folder on the computer",
"A full path, inside a folder the computer allows remote work in.",
"Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it.",
"An approval waits (minutes)",
"A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied.",
"A linked computer",
"Away now: a run waits for it, then counts as missed.",
"It does not accept scheduled work: turn on Accept scheduled work there.",
"This computer (%(name)s)",
"Waiting for the computer",
"approvals wait %(n)s min",
# chat.js: a picture as the next reply
"The reply to the next message is a picture drawn from it.",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -381,6 +550,8 @@ KEYS = (
# the device, as the server's catalogue has it.
OWN: dict[str, dict[str, str]] = {
"cs": {
"Sign in again: the server no longer accepts this app's sign-in.": "Přihlaste se znovu: server už nepřijímá přihlášení této aplikace.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potřebuje novější server LLeMbas. Požádejte jeho správce o aktualizaci.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Tento model vyřizuje najednou jeden požadavek: podagenti potřebují jiný model na jiném serveru, pojmenovaný v konverzaci.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Čísla počítají i vlastního agenta konverzace: 1 je agent sám, bez podagentů; Max 4 je agent a až 3 podagenti. Podagent běží na modelu konverzace, na jiném jen tehdy, když ho v konverzaci pojmenujete.",
"Min": "Min",
@@ -600,8 +771,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í.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Das Terminal braucht einen neueren LLeMbas-Server. Bitten Sie seinen Administrator, ihn zu aktualisieren.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Dieses Modell bedient eine Anfrage zur Zeit: Subagenten brauchen ein anderes Modell auf einem anderen Server, im Chat genannt.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Die Zahlen zählen den eigenen Agenten des Chats mit: 1 ist der Agent allein, ohne Subagenten; Max 4 ist der Agent und bis zu 3 Subagenten. Ein Subagent läuft auf dem Modell des Chats, auf einem anderen nur, wenn Sie es im Chat nennen.",
"Min": "Min",
@@ -821,8 +1046,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.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "El terminal necesita un servidor LLeMbas más reciente. Pida a su administrador que lo actualice.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Este modelo atiende una petición a la vez: los subagentes necesitan otro modelo, en otro servidor, nombrado en el chat.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Los números cuentan el agente propio del chat: 1 es el agente solo, sin subagentes; Máx. 4 es el agente y hasta 3 subagentes. Un subagente usa el modelo del chat, u otro solo si lo nombra en el chat.",
"Min": "Mín.",
@@ -1042,8 +1321,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.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Le terminal nécessite un serveur LLeMbas plus récent. Demandez à son administrateur de le mettre à jour.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ce modèle traite une requête à la fois : les sous-agents ont besoin d’un autre modèle, sur un autre serveur, nommé dans la conversation.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Les nombres comptent l’agent de la conversation : 1, c’est l’agent seul, sans sous-agents ; Max 4, c’est l’agent et jusqu’à 3 sous-agents. Un sous-agent utilise le modèle de la conversation, ou un autre seulement si vous le nommez dans la conversation.",
"Min": "Min",
@@ -1263,8 +1596,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.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "A terminálhoz újabb LLeMbas-szerver kell. Kérje meg a rendszergazdáját, hogy frissítse.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ez a modell egyszerre egy kérést szolgál ki: az alügynököknek másik modell kell, másik szerveren, a beszélgetésben megnevezve.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "A számokba a beszélgetés saját ügynöke is beleszámít: az 1 maga az ügynök, alügynökök nélkül; a Max. 4 az ügynök és legfeljebb 3 alügynök. Az alügynök a beszélgetés modelljén fut, másikon csak akkor, ha megnevezi a beszélgetésben.",
"Min": "Min.",
@@ -1484,8 +1871,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.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Il terminale richiede un server LLeMbas più recente. Chiedi al suo amministratore di aggiornarlo.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Questo modello serve una richiesta alla volta: i sottoagenti hanno bisogno di un altro modello, su un altro server, nominato nella chat.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "I numeri contano anche l’agente della chat: 1 è l’agente da solo, senza sottoagenti; Max 4 è l’agente più fino a 3 sottoagenti. Un sottoagente usa il modello della chat, o un altro solo se lo nomini nella chat.",
"Min": "Min",
@@ -1705,8 +2146,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.",
},
"pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminal wymaga nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ten model obsługuje jedno żądanie naraz: podagenci potrzebują innego modelu, na innym serwerze, wskazanego w czacie.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Liczby obejmują własnego agenta czatu: 1 to sam agent, bez podagentów; Maks 4 to agent i do 3 podagentów. Podagent działa na modelu czatu, na innym tylko wtedy, gdy wskażesz go w czacie.",
"Min": "Min",
@@ -1926,8 +2421,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.",
},
"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.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potrebuje novší server LLeMbas. Požiadajte jeho správcu o aktualizáciu.",
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Tento model vybavuje naraz jednu požiadavku: podagenti potrebujú iný model na inom serveri, pomenovaný v konverzácii.",
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Čísla počítajú aj vlastného agenta konverzácie: 1 je agent sám, bez podagentov; Max 4 je agent a až 3 podagenti. Podagent beží na modeli konverzácie, na inom len vtedy, keď ho v konverzácii pomenujete.",
"Min": "Min",
@@ -2147,6 +2696,58 @@ OWN: dict[str, dict[str, str]] = {
"Days": "Dni",
"Time": "Čas",
"No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.",
"No archived chats.": "Žiadne archivované konverzácie.",
"Computer": "Počítač",
"Folder on %(device)s": "Priečinok na zariadení %(device)s",
"The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.",
"Choose": "Vybrať",
"Choose a folder…": "Vybrať priečinok…",
"Type a folder…": "Zadať priečinok…",
"Choose a folder first.": "Najprv vyberte priečinok.",
"An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.",
"This computer's (%(language)s)": "Podľa tohto počítača (%(language)s)",
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
"Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.",
"Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.",
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
"Move to folder…": "Presunúť do priečinka…",
"No folder": "Bez priečinka",
"New folder…": "Nový priečinok…",
"Create": "Vytvoriť",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.",
"Add": "Pridať",
"Data group": "Skupina údajov",
"Remove this memory?": "Odstrániť tento záznam z pamäte?",
"Noted by a model": "Zaznamenal model",
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
"Something to remember…": "Niečo na zapamätanie…",
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
"Tools and skills…": "Nástroje a schopnosti…",
"Nothing to switch in this chat.": "V tejto konverzácii nie je čo prepnúť.",
"Stop dictating": "Zastaviť diktovanie",
"Writing down…": "Zapisujem…",
"The microphone could not be opened: %(why)s": "Mikrofón sa nepodarilo otvoriť: %(why)s",
"Read this aloud": "Prečítať nahlas",
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
"What to draw…": "Čo nakresliť…",
"Shape": "Tvar",
"Square": "Štvorec",
"Wide": "Široký",
"Tall": "Vysoký",
"Draw": "Nakresliť",
"Drawing… a picture can take a minute or two.": "Kreslím… obrázok môže trvať minútu alebo dve.",
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
"Your server recommends LLeMbas Desktop %(version)s": "Váš server odporúča LLeMbas Desktop %(version)s",
"The release that goes with the server's version. This is %(current)s.": "Vydanie, ktoré patrí k verzii servera. Toto je %(current)s.",
"Open the release page": "Otvoriť stránku vydania",
"A linked computer": "Prepojený počítač",
"Away now: a run waits for it, then counts as missed.": "Teraz nie je k dispozícii: spustenie naň počká, potom sa počíta ako zmeškané.",
"It does not accept scheduled work: turn on Accept scheduled work there.": "Neprijíma plánovanú prácu: zapnite tam Accept scheduled work.",
"This computer (%(name)s)": "Tento počítač (%(name)s)",
"Waiting for the computer": "Čaká na počítač",
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
},
}
@@ -2165,9 +2766,13 @@ def server_catalogue(server: Path, lang: str) -> dict[str, str]:
messages = getattr(module, "MESSAGES", None)
if not isinstance(messages, dict):
sys.exit(f"gen-i18n: {path} has no MESSAGES")
NAMES[lang] = str(getattr(module, "NAME", "") or lang)
return messages
NAMES: dict[str, str] = {} # each language's own name for itself, as the server's catalogue says
def main() -> None:
server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas"))
catalogue: dict[str, dict[str, str]] = {}
@@ -2197,27 +2802,53 @@ def main() -> None:
print(f"gen-i18n: {lang}: OWN has {key!r}, which no page uses", file=sys.stderr)
problems += 1
catalogue[lang] = table
print(f"gen-i18n: {lang}: {len(table)} of {len(KEYS)} ({from_server} from the server)")
print(f"gen-i18n: {lang}: {len(table)} sentences, {from_server} of the page's {len(KEYS)} from the server")
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
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"
+86 -4
View File
@@ -279,6 +279,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -511,6 +520,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
@@ -623,6 +638,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.37.0"
@@ -686,6 +710,12 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -775,8 +805,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@@ -1552,6 +1593,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
@@ -2085,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "llembas-desktop"
version = "1.0.0-beta.5"
version = "1.0.0-beta.7"
dependencies = [
"libc",
"serde",
@@ -2097,7 +2147,9 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tungstenite",
"ureq",
"webkit2gtk",
]
[[package]]
@@ -3488,6 +3540,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3496,7 +3559,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -4539,6 +4602,25 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"rustls",
"rustls-native-certs 0.8.5",
"rustls-pki-types",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "typeid"
version = "1.0.3"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "llembas-desktop"
version = "1.0.0-beta.5"
version = "1.0.0-beta.7"
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
authors = ["Jaroslav Beneš"]
license = "MIT"
@@ -26,10 +26,17 @@ tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
# The terminal panel's socket (`terminal.rs`): a blocking WebSocket on the TLS ureq already brings.
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
[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
+360 -6
View File
@@ -22,6 +22,7 @@ mod remote;
mod sealed;
mod server;
mod settings;
mod terminal;
mod watch;
use std::collections::HashMap;
@@ -35,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;
@@ -115,10 +117,15 @@ struct App {
signing_in: Mutex<Option<Arc<AtomicBool>>>,
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
next_stream: AtomicU64,
/// The terminal panel's sockets (`terminal.rs`), by the page's id for each.
terminals: terminal::Sessions,
sealed: sealed::Pages,
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 {
@@ -150,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
@@ -271,6 +285,7 @@ async fn set_server(handle: AppHandle, address: String) -> Result<Status, String
if before.as_deref() != Some(d.base.as_str()) {
app.agent.stop();
app.watch.reset();
app.terminals.close_all();
}
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
*app.server.lock().unwrap() = Some(d);
@@ -380,6 +395,7 @@ fn sign_out(handle: AppHandle) {
let account = app.account.lock().unwrap().take();
server::forget_account(&app.config_dir);
app.watch.reset();
app.terminals.close_all();
if let Some(a) = account {
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
}
@@ -391,6 +407,7 @@ fn token_refused(handle: &AppHandle) {
let app = handle.state::<App>();
if app.account.lock().unwrap().take().is_some() {
server::forget_account(&app.config_dir);
app.terminals.close_all();
announce(handle);
}
}
@@ -446,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]
@@ -511,6 +599,71 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
}
}
// --- The terminal panel ------------------------------------------------------------------------
/// Open a shell on a linked device, as the terminal panel asks: an agent chat's (`chat`), or the
/// one before the chat exists (`device` and `directory`). The socket is opened here, with the
/// app's token in its handshake; the page gets its frames as `terminal` events with this id --
/// `output` (base64), `control` (the server's JSON), and a last `end`.
#[tauri::command]
fn terminal_open(
handle: AppHandle,
chat: Option<String>,
device: Option<String>,
directory: Option<String>,
cols: u16,
rows: u16,
) -> Result<u64, ApiError> {
let app = handle.state::<App>();
let Some((app_url, token)) = app.door() else {
return Err(ApiError::new(401, "Not signed in."));
};
let target = match (chat, device) {
(Some(chat), None) => terminal::Target::Chat(chat),
(None, Some(device)) => terminal::Target::Device {
device,
directory: directory.unwrap_or_default(),
},
_ => return Err(ApiError::new(400, "A chat or a device, not both.")),
};
let url = terminal::url(&app_url, &target, cols, rows)?;
let h = handle.clone();
let on_end = handle.clone();
Ok(app.terminals.open(
url,
token,
move |down| {
let _ = h.emit("terminal", down);
},
move |id, end| {
on_end.state::<App>().terminals.forget(id);
if end.unauthorized() {
token_refused(&on_end);
}
},
))
}
/// Keys typed (or pasted) into a terminal, as text: sent to the shell as its bytes.
#[tauri::command]
fn terminal_input(app: tauri::State<'_, App>, id: u64, data: String) -> bool {
app.terminals
.send(id, terminal::Up::Keys(data.into_bytes()))
}
/// The terminal's size changed.
#[tauri::command]
fn terminal_resize(app: tauri::State<'_, App>, id: u64, cols: u16, rows: u16) -> bool {
app.terminals.send(id, terminal::Up::Resize(cols, rows))
}
/// The page let go of a terminal: its socket is closed (the shell on the device lives on, as
/// the server keeps it).
#[tauri::command]
fn terminal_close(app: tauri::State<'_, App>, id: u64) {
app.terminals.close(id);
}
// --- Attachments ----------------------------------------------------------------------------------
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
@@ -619,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();
@@ -1109,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.
@@ -1402,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();
@@ -1412,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);
@@ -1449,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
} else {
r.title
};
let body = match r.told {
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,
};
let _ = handle
}
.clone()
});
let id = app.watch.notes.add(watch::Note::Run {
schedule: r.schedule.clone(),
run: r.run.clone(),
});
let mut note = handle
.notification()
.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
});
@@ -1560,11 +1897,20 @@ pub fn run() {
terminal_word,
stream_open,
stream_close,
terminal_open,
terminal_input,
terminal_resize,
terminal_close,
local_list,
local_read,
sealed_open,
sealed_close,
attach_pick,
library_upload,
voice_arm,
transcribe,
speak,
draw,
attach_dropped,
attach_bytes,
set_theme,
@@ -1621,9 +1967,11 @@ pub fn run() {
signing_in: Mutex::new(None),
streams: Mutex::new(HashMap::new()),
next_stream: AtomicU64::new(0),
terminals: terminal::Sessions::default(),
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.
@@ -1635,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
View File
@@ -650,12 +650,17 @@ fn header_name(name: &str) -> String {
}
}
/// The body of a `multipart/form-data` request with one file in the field `file`.
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]
+444
View File
@@ -0,0 +1,444 @@
//! The terminal panel's socket: a shell on a linked device, through the server's app door.
//!
//! The server has two WebSockets for an app (`/api/v2`, behind the app's token):
//!
//! - `/chats/{chat}/terminal/ws` -- an agent chat's shell, the one the web page's panel opens
//! too, so the two share it;
//! - `/devices/{device}/terminal/ws?directory=` -- before the chat exists, on the device and in
//! the folder a new agent chat has chosen. The chat's first message takes that shell over.
//!
//! Both take `cols` and `rows`. Down: the shell's output as binary frames, and text frames
//! `{"t": "ready" | "closed" | "behind" | "error", ...}`. Up: keystrokes as binary,
//! `{"t": "resize", "cols", "rows"}` as text. A refused token closes with 4401.
//!
//! **The token stays here.** The socket is opened in Rust with `Authorization: Bearer` in the
//! handshake, and the page only ever gets the frames (as `terminal` events) and sends keys,
//! sizes and "close" through commands -- the same rule as every other request the page makes.
//! A page cannot open the socket itself: its content security policy allows it no connection
//! but the bridge.
//!
//! One thread per terminal owns its socket: it reads with a short timeout and, between reads,
//! sends what the page queued on its channel. Blocking sockets, no async runtime of its own.
use std::collections::HashMap;
use std::io::ErrorKind;
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{json, Value};
use tungstenite::http::Uri;
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{ClientRequestBuilder, Error as WsError, HandshakeError, Message, WebSocket};
use crate::server::{base64, ApiError};
/// The server's close code for a token it refused (`api/v2/terminal.py`).
pub const CLOSE_UNAUTHORIZED: u16 = 4401;
/// The most one keystroke message may be: the server's own limit (a paste, at most).
pub const MAX_INPUT: usize = 256 * 1024;
/// The longest directory the server takes (`api/terminal.py` `MAX_DIR_CHARS`).
const MAX_DIR: usize = 4096;
/// How long a read waits before the thread looks at what the page queued.
const POLL: Duration = Duration::from_millis(40);
/// How long a close waits for the server's answer before the socket is simply dropped.
const CLOSE_WAIT: Duration = Duration::from_secs(2);
type Socket = WebSocket<MaybeTlsStream<TcpStream>>;
/// Where the shell is: an agent chat's, or a device's folder before the chat exists.
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
Chat(String),
Device { device: String, directory: String },
}
/// An id as the server makes them (a chat's, a device token's): letters, digits, `-` and `_`.
fn id_ok(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// A query value, percent-encoded: every byte but the unreserved ones (RFC 3986 §2.3).
pub fn encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for b in text.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The socket's address on the app door (`https://host/api/v2` -> `wss://host/api/v2/...`).
pub fn url(app: &str, target: &Target, cols: u16, rows: u16) -> Result<String, ApiError> {
let door = if let Some(rest) = app.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = app.strip_prefix("http://") {
format!("ws://{rest}")
} else {
return Err(ApiError::new(
400,
"The server's address is not http or https.",
));
};
let cols = cols.clamp(2, 1000);
let rows = rows.clamp(1, 500);
match target {
Target::Chat(chat) => {
if !id_ok(chat) {
return Err(ApiError::new(400, "Not a chat."));
}
Ok(format!(
"{door}/chats/{chat}/terminal/ws?cols={cols}&rows={rows}"
))
}
Target::Device { device, directory } => {
if !id_ok(device) {
return Err(ApiError::new(400, "Not a device."));
}
let directory = directory.trim();
if directory.is_empty() || directory.len() > MAX_DIR || directory.contains('\0') {
return Err(ApiError::new(400, "Not a folder."));
}
Ok(format!(
"{door}/devices/{device}/terminal/ws?directory={}&cols={cols}&rows={rows}",
encode(directory)
))
}
}
}
/// What the page asked the socket's thread to do.
pub enum Up {
Keys(Vec<u8>),
Resize(u16, u16),
Close,
}
/// One frame for the page, as the `terminal` event: `output` (the bytes, base64), `control`
/// (the server's JSON), or `end` (how it ended: `{code, reason, status, error}`).
#[derive(Clone, Serialize)]
pub struct Down {
pub id: u64,
pub kind: &'static str,
pub data: Value,
}
/// How a terminal's socket ended. `code` is the close frame's (4401: the token was refused),
/// `status` an HTTP status the handshake was answered with, `error` why it could not connect or
/// broke off.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct End {
pub code: Option<u16>,
pub reason: String,
pub status: Option<u16>,
pub error: Option<String>,
}
impl End {
fn failed(error: impl Into<String>) -> Self {
End {
error: Some(error.into()),
..End::default()
}
}
/// The server refused the app's token: the page signs in again.
pub fn unauthorized(&self) -> bool {
self.code == Some(CLOSE_UNAUTHORIZED) || self.status == Some(401)
}
}
/// The open terminals: what each one's thread listens to.
#[derive(Default)]
pub struct Sessions {
next: AtomicU64,
open: Mutex<HashMap<u64, Sender<Up>>>,
}
impl Sessions {
/// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last;
/// `ended` is told how it ended just before. The id is the page's handle on it.
pub fn open(
&self,
url: String,
token: String,
emit: impl Fn(Down) + Send + 'static,
ended: impl FnOnce(u64, &End) + Send + 'static,
) -> u64 {
let id = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let (tx, rx) = mpsc::channel();
self.open.lock().unwrap().insert(id, tx);
std::thread::spawn(move || {
let send = |kind: &'static str, data: Value| emit(Down { id, kind, data });
let end = run(&url, &token, &rx, &send);
ended(id, &end);
send("end", serde_json::to_value(&end).unwrap_or(Value::Null));
});
id
}
/// Hand the page's keys, size or close to the terminal's thread. False: no such terminal.
pub fn send(&self, id: u64, up: Up) -> bool {
self.open
.lock()
.unwrap()
.get(&id)
.is_some_and(|tx| tx.send(up).is_ok())
}
/// Close one terminal (the page's panel let go of it).
pub fn close(&self, id: u64) {
if let Some(tx) = self.open.lock().unwrap().remove(&id) {
let _ = tx.send(Up::Close);
}
}
/// A terminal's thread has ended: forget it.
pub fn forget(&self, id: u64) {
self.open.lock().unwrap().remove(&id);
}
/// Every terminal closed (signed out, or another server chosen).
pub fn close_all(&self) {
for (_, tx) in self.open.lock().unwrap().drain() {
let _ = tx.send(Up::Close);
}
}
}
/// The TCP socket under the WebSocket, to set its read timeout.
fn tcp(ws: &Socket) -> Option<&TcpStream> {
match ws.get_ref() {
MaybeTlsStream::Plain(s) => Some(s),
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
_ => None,
}
}
fn connect(url: &str, token: &str) -> Result<Socket, End> {
let uri: Uri = url
.parse()
.map_err(|e| End::failed(format!("Not an address: {e}")))?;
let host = uri
.host()
.ok_or_else(|| End::failed("The address has no host."))?
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let port = uri
.port_u16()
.unwrap_or(if uri.scheme_str() == Some("wss") {
443
} else {
80
});
let addrs = (host.as_str(), port)
.to_socket_addrs()
.map_err(|e| End::failed(format!("Could not reach the server: {e}")))?;
let mut last = String::from("no address");
let mut stream = None;
for addr in addrs {
match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = e.to_string(),
}
}
let stream =
stream.ok_or_else(|| End::failed(format!("Could not reach the server: {last}")))?;
// The handshake may take its time; reads only poll once it is done.
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_nodelay(true);
let request = ClientRequestBuilder::new(uri)
.with_header("Authorization", format!("Bearer {token}"))
.with_header(
"User-Agent",
format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION")),
);
match tungstenite::client_tls(request, stream) {
Ok((ws, _)) => Ok(ws),
Err(HandshakeError::Failure(WsError::Http(res))) => Err(End {
status: Some(res.status().as_u16()),
..End::default()
}),
Err(HandshakeError::Failure(e)) => Err(End::failed(format!("Could not connect: {e}"))),
Err(HandshakeError::Interrupted(_)) => Err(End::failed("The server did not answer.")),
}
}
/// A read that only timed out: nothing came in this poll.
fn idle(e: &WsError) -> bool {
matches!(e, WsError::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
}
fn run(url: &str, token: &str, rx: &Receiver<Up>, send: &dyn Fn(&'static str, Value)) -> End {
let mut ws = match connect(url, token) {
Ok(ws) => ws,
Err(end) => return end,
};
match tcp(&ws) {
Some(s) => {
let _ = s.set_read_timeout(Some(POLL));
}
None => return End::failed("An unexpected kind of connection."),
}
let mut closed_by_server: Option<(u16, String)> = None;
let mut closing: Option<Instant> = None;
loop {
// What the page queued, until the server has said goodbye.
if closing.is_none() && closed_by_server.is_none() {
loop {
let sent = match rx.try_recv() {
Ok(Up::Keys(bytes)) if bytes.len() <= MAX_INPUT => {
ws.send(Message::binary(bytes))
}
Ok(Up::Keys(_)) => Ok(()),
Ok(Up::Resize(cols, rows)) => ws.send(Message::text(
json!({ "t": "resize", "cols": cols, "rows": rows }).to_string(),
)),
Ok(Up::Close) | Err(TryRecvError::Disconnected) => {
closing = Some(Instant::now());
let _ = ws.close(None);
break;
}
Err(TryRecvError::Empty) => break,
};
if let Err(e) = sent {
if !idle(&e) {
return End::failed(format!("The connection broke off: {e}"));
}
}
}
}
match ws.read() {
Ok(Message::Binary(bytes)) => send("output", Value::String(base64(&bytes))),
Ok(Message::Text(text)) => {
if let Ok(value) = serde_json::from_str::<Value>(text.as_str()) {
send("control", value);
}
}
Ok(Message::Close(frame)) => {
let (code, reason) = frame
.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))
.unwrap_or((1005, String::new()));
closed_by_server = Some((code, reason));
}
Ok(_) => {}
Err(e) if idle(&e) => {
if closing.is_some_and(|at| at.elapsed() > CLOSE_WAIT) {
break;
}
}
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => break,
Err(e) => {
if closing.is_some() || closed_by_server.is_some() {
break;
}
return End::failed(format!("The connection broke off: {e}"));
}
}
}
match closed_by_server {
Some((code, reason)) => End {
code: Some(code),
reason,
..End::default()
},
None => End::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chats_terminal_is_on_the_app_door_with_its_size() {
let t = Target::Chat("chat_1-a".into());
assert_eq!(
url("https://ai.example/api/v2", &t, 80, 24).unwrap(),
"wss://ai.example/api/v2/chats/chat_1-a/terminal/ws?cols=80&rows=24"
);
assert_eq!(
url("http://ai.example:8080/sub/api/v2", &t, 0, 9999).unwrap(),
"ws://ai.example:8080/sub/api/v2/chats/chat_1-a/terminal/ws?cols=2&rows=500"
);
}
#[test]
fn a_terminal_before_the_chat_carries_its_folder_encoded() {
let t = Target::Device {
device: "token1".into(),
directory: " /home/dev/my project&x=1 ".into(),
};
assert_eq!(
url("https://ai.example/api/v2", &t, 100, 30).unwrap(),
"wss://ai.example/api/v2/devices/token1/terminal/ws?directory=%2Fhome%2Fdev%2Fmy%20project%26x%3D1&cols=100&rows=30"
);
assert_eq!(encode("C:\\Users\\Ádám"), "C%3A%5CUsers%5C%C3%81d%C3%A1m");
}
#[test]
fn nothing_but_an_id_and_a_folder_reaches_the_address() {
let door = "https://ai.example/api/v2";
for bad in ["", "../me", "a/b", "a?b", "a b", "x#y"] {
assert!(
url(door, &Target::Chat(bad.into()), 80, 24).is_err(),
"{bad}"
);
let dev = Target::Device {
device: bad.into(),
directory: "/tmp".into(),
};
assert!(url(door, &dev, 80, 24).is_err(), "{bad}");
}
for dir in ["", " ", "a\0b"] {
let dev = Target::Device {
device: "token1".into(),
directory: dir.into(),
};
assert!(url(door, &dev, 80, 24).is_err(), "{dir:?}");
}
assert!(url("ftp://ai.example", &Target::Chat("c".into()), 80, 24).is_err());
}
#[test]
fn a_refused_token_is_told_apart_from_a_dropped_line() {
let refused = End {
code: Some(CLOSE_UNAUTHORIZED),
..End::default()
};
assert!(refused.unauthorized());
assert!(End {
status: Some(401),
..End::default()
}
.unauthorized());
assert!(!End::failed("reset").unauthorized());
assert!(!End {
code: Some(1000),
..End::default()
}
.unauthorized());
}
#[test]
fn keys_for_a_closed_terminal_go_nowhere() {
let s = Sessions::default();
assert!(!s.send(7, Up::Keys(b"ls\n".to_vec())));
s.close(7);
s.close_all();
}
}
+172 -4
View File
@@ -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)))]))
+1 -1
View File
@@ -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"
]
+333
View File
@@ -0,0 +1,333 @@
{
"about": "Progress (progress.md, spec 3.4.0): the text and the line every client draws for a progress object. `progress`: the object; `now`: the client's clock (Unix ms). Expect `text` — the phase's text from loop.json progress.texts, its fields filled (numbers written plainly: integers as they are, a rate with one decimal) — and `line` — `{{text}} · {{elapsed}}` with elapsed = now − since in whole seconds (5s, 1m 05s, 1h 02m), or the text alone for stopped and failed. retrying's `in` is counted down: in − floor((now − since) / 1000), never below 0. LLeMbas: services/progress.py; CLI: session/progress.ts.",
"cases": [
{
"about": "Queued, nothing more known.",
"progress": {
"phase": "queued",
"since": 1762790400000
},
"now": 1762790405000,
"expect": {
"text": "Queued",
"line": "Queued · 5s"
}
},
{
"about": "Queued behind two turns.",
"progress": {
"phase": "queued",
"since": 1762790400000,
"ahead": 2
},
"now": 1762790465000,
"expect": {
"text": "Queued: 2 ahead",
"line": "Queued: 2 ahead · 1m 05s"
}
},
{
"about": "A one-request server answering another request.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "Qwen 27B",
"reason": "busy"
},
"now": 1762790412400,
"expect": {
"text": "Waiting for Qwen 27B: it serves one request at a time and is answering another",
"line": "Waiting for Qwen 27B: it serves one request at a time and is answering another · 12s"
}
},
{
"about": "Every slot taken.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "coder",
"reason": "slots"
},
"now": 1762790401000,
"expect": {
"text": "Waiting for coder: all its slots are in use",
"line": "Waiting for coder: all its slots are in use · 1s"
}
},
{
"about": "Another model on the one-model server.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "coder",
"reason": "group"
},
"now": 1762790400000,
"expect": {
"text": "Waiting for coder: its server holds another model now",
"line": "Waiting for coder: its server holds another model now · 0s"
}
},
{
"about": "Loading.",
"progress": {
"phase": "loading_model",
"since": 1762790400000,
"model": "Gemma 12B"
},
"now": 1762794125000,
"expect": {
"text": "Loading Gemma 12B",
"line": "Loading Gemma 12B · 1h 02m"
}
},
{
"about": "Reading the prompt: the percentage is floored.",
"progress": {
"phase": "reading_prompt",
"since": 1762790400000,
"done": 1999,
"total": 3000
},
"now": 1762790402000,
"expect": {
"text": "Reading the prompt: 1999 / 3000 tokens (66%)",
"line": "Reading the prompt: 1999 / 3000 tokens (66%) · 2s"
}
},
{
"about": "Reading the prompt with no counts from the server.",
"progress": {
"phase": "reading_prompt",
"since": 1762790400000
},
"now": 1762790402000,
"expect": {
"text": "Reading the prompt",
"line": "Reading the prompt · 2s"
}
},
{
"about": "Thinking.",
"progress": {
"phase": "thinking",
"since": 1762790400000
},
"now": 1762790459999,
"expect": {
"text": "Thinking",
"line": "Thinking · 59s"
}
},
{
"about": "Writing, the rate with one decimal.",
"progress": {
"phase": "writing",
"since": 1762790400000,
"rate": 12.46
},
"now": 1762790404000,
"expect": {
"text": "Writing: 12.5 tok/s",
"line": "Writing: 12.5 tok/s · 4s"
}
},
{
"about": "Writing before a rate is measured.",
"progress": {
"phase": "writing",
"since": 1762790400000
},
"now": 1762790400400,
"expect": {
"text": "Writing",
"line": "Writing · 0s"
}
},
{
"about": "A tool with what it does.",
"progress": {
"phase": "running_tool",
"since": 1762790400000,
"tool": "bash",
"what": "bun test"
},
"now": 1762790461000,
"expect": {
"text": "Running bash: bun test",
"line": "Running bash: bun test · 1m 01s"
}
},
{
"about": "A tool without a title.",
"progress": {
"phase": "running_tool",
"since": 1762790400000,
"tool": "recall"
},
"now": 1762790401000,
"expect": {
"text": "Running recall",
"line": "Running recall · 1s"
}
},
{
"about": "An approval, naming the tool.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "approval",
"tool": "bash"
},
"now": 1762790430000,
"expect": {
"text": "Waiting for you: allow bash?",
"line": "Waiting for you: allow bash? · 30s"
}
},
{
"about": "An approval with no tool named.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "approval"
},
"now": 1762790400000,
"expect": {
"text": "Waiting for you: an approval",
"line": "Waiting for you: an approval · 0s"
}
},
{
"about": "A question.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "question"
},
"now": 1762790403000,
"expect": {
"text": "Waiting for you: a question",
"line": "Waiting for you: a question · 3s"
}
},
{
"about": "The plan.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "plan"
},
"now": 1762790403000,
"expect": {
"text": "Waiting for you: the plan",
"line": "Waiting for you: the plan · 3s"
}
},
{
"about": "Subagents.",
"progress": {
"phase": "waiting_for_subagents",
"since": 1762790400000,
"running": 3
},
"now": 1762790490000,
"expect": {
"text": "Waiting for subagents: 3 running",
"line": "Waiting for subagents: 3 running · 1m 30s"
}
},
{
"about": "Compacting.",
"progress": {
"phase": "compacting",
"since": 1762790400000
},
"now": 1762790407000,
"expect": {
"text": "Compacting the conversation",
"line": "Compacting the conversation · 7s"
}
},
{
"about": "Retrying: `in` counts down from since.",
"progress": {
"phase": "retrying",
"since": 1762790400000,
"reason": "502 Bad Gateway",
"in": 10,
"attempt": 2
},
"now": 1762790403000,
"expect": {
"text": "Retrying in 7 s: 502 Bad Gateway",
"line": "Retrying in 7 s: 502 Bad Gateway · 3s"
}
},
{
"about": "Retrying: never below 0.",
"progress": {
"phase": "retrying",
"since": 1762790400000,
"reason": "overloaded",
"in": 2
},
"now": 1762790405000,
"expect": {
"text": "Retrying in 0 s: overloaded",
"line": "Retrying in 0 s: overloaded · 5s"
}
},
{
"about": "Stopped draws no elapsed time.",
"progress": {
"phase": "stopped",
"since": 1762790400000
},
"now": 1762790405000,
"expect": {
"text": "Stopped",
"line": "Stopped"
}
},
{
"about": "Stopped by the person.",
"progress": {
"phase": "stopped",
"since": 1762790400000,
"reason": "person"
},
"now": 1762790405000,
"expect": {
"text": "Stopped: by you",
"line": "Stopped: by you"
}
},
{
"about": "Stopped at the step limit.",
"progress": {
"phase": "stopped",
"since": 1762790400000,
"reason": "steps"
},
"now": 1762790400000,
"expect": {
"text": "Stopped: step limit",
"line": "Stopped: step limit"
}
},
{
"about": "Failed.",
"progress": {
"phase": "failed",
"since": 1762790400000,
"reason": "connection refused"
},
"now": 1762790405000,
"expect": {
"text": "Failed: connection refused",
"line": "Failed: connection refused"
}
}
]
}
+1
View File
@@ -0,0 +1 @@
LLeMbas v2.0.0-beta.9 (3b7da5fc7ac72d5de9229919793dd299c03da6a8) tests/fixtures/api_v2
+20
View File
@@ -0,0 +1,20 @@
{
"server": {
"version": "2.1.0-beta.3",
"channel": "beta"
},
"apps": {
"android": {
"version": "1.1.0-beta.2",
"tag": "v1.1.0-beta.2",
"channel": "beta",
"page": "https://github.com/LLeMbas/LLeMbas-Android/releases/tag/v1.1.0-beta.2"
},
"desktop": {
"version": "1.0.1",
"tag": "v1.0.1",
"channel": "stable",
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"server": {
"version": "2.0.0",
"channel": "stable"
},
"apps": {
"android": null,
"desktop": {
"version": "1.0.1",
"tag": "v1.0.1",
"channel": "stable",
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"id": "chat1",
"kind": "chat",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": false,
"mode": "",
"effort": "low",
"device": null,
"device_session_id": null,
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": {
"offered": true,
"named_only": false,
"cap": 32
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
+31
View File
@@ -0,0 +1,31 @@
{
"id": "chat2",
"kind": "agent",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": true,
"mode": "edit",
"effort": "low",
"device": {
"token_id": "token1",
"directory": "/home/dev/project"
},
"device_session_id": "ses_01waybread",
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": {
"offered": true,
"named_only": false,
"cap": 32
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
+29
View File
@@ -0,0 +1,29 @@
{
"chats": [
{
"id": "chat1",
"kind": "chat",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": false,
"mode": "",
"effort": "low",
"device": null,
"device_session_id": null,
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": null,
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
],
"more": false
}
+162
View File
@@ -0,0 +1,162 @@
{
"cards": [
{
"id": "card1",
"kind": "question",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "q0",
"kind": "question",
"tool": "ask_user",
"title": "Honey or not?",
"header": "Taste",
"detail": "",
"reason": "",
"purpose": "",
"options": [
{
"label": "Honey",
"description": "",
"recommended": true
},
{
"label": "Plain",
"description": "As the elves have it",
"recommended": false
}
],
"multiple": false,
"free_text": true,
"editable": false,
"plan": null,
"mode_choice": true,
"job": "",
"subagent": null
}
]
},
{
"id": "card2",
"kind": "approval",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "0",
"kind": "approval",
"tool": "bash",
"title": "Run a command",
"header": "",
"detail": "rm -rf build",
"reason": "Edit mode asks before anything that runs a command.",
"purpose": "Start clean",
"options": [],
"multiple": false,
"free_text": false,
"editable": true,
"plan": null,
"mode_choice": true,
"job": "explore (job1)",
"subagent": null
}
]
},
{
"id": "card3",
"kind": "plan",
"why": "",
"dismissable": true,
"expires_in": null,
"items": [
{
"index": 0,
"key": "p0",
"kind": "plan",
"tool": "plan_submit",
"title": "Bake lembas",
"header": "",
"detail": "",
"reason": "",
"purpose": "",
"options": [],
"multiple": false,
"free_text": true,
"editable": false,
"plan": {
"version": 2,
"title": "Bake lembas",
"summary": "",
"findings": [],
"objectives": [],
"phases": [
{
"id": "p1",
"title": "Plan",
"status": "pending",
"tasks": [
{
"id": "t1",
"text": "Gather mallorn leaves",
"status": "todo",
"note": ""
},
{
"id": "t2",
"text": "Bake slowly",
"status": "todo",
"note": ""
}
]
}
],
"steps": [
"Gather mallorn leaves",
"Bake slowly"
]
},
"mode_choice": true,
"job": "",
"subagent": null
}
]
},
{
"id": "card4",
"kind": "approval",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "0",
"kind": "approval",
"tool": "bash",
"title": "Run a command",
"header": "",
"detail": "git push",
"reason": "Edit mode asks before anything that runs a command.",
"purpose": "",
"options": [],
"multiple": false,
"free_text": false,
"editable": true,
"plan": null,
"mode_choice": true,
"job": "",
"subagent": {
"id": "s1",
"name": "parser",
"position": "reviews the parser"
}
}
]
}
]
}
+110
View File
@@ -0,0 +1,110 @@
{
"version": "8bee85746004dc19",
"schedules": [
{
"id": "sched1",
"title": "Waybread check",
"instruction": "Check the waybread is wrapped.",
"request": "every second Monday at 7:30",
"rule": {
"at": {
"nth": [
{
"n": 2,
"weekday": 0
}
],
"times": [
"07:30"
]
}
},
"description": "On the second Monday of each month at 07:30",
"timezone": "UTC",
"enabled": true,
"next_run": "2026-01-12T07:30:00+00:00",
"next_runs": [
"2026-01-12T07:30:00+00:00",
"2026-02-09T07:30:00+00:00",
"2026-03-09T07:30:00+00:00",
"2026-04-13T07:30:00+00:00",
"2026-05-11T07:30:00+00:00"
],
"target": "chat",
"mode": "fresh",
"notify": "matters",
"catch_up": "one",
"grace_minutes": null,
"model": "qwen",
"effort": "high",
"agents": null,
"tools": [
"files",
"recall",
"search",
"fetch",
"memory",
"skills",
"plan"
],
"origin": "user",
"chat_id": "chat1",
"fired_count": 3,
"failure_streak": 0,
"watch": null,
"runs_on": null,
"last_error": null,
"last_run": {
"id": "run1",
"schedule_id": "sched1",
"status": "ok",
"timing": "late",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": "2026-01-02T03:04:05+00:00",
"finished_at": "2026-01-02T03:04:05+00:00",
"waiting_for": null,
"retry_at": null,
"progress": null,
"attempts": 1,
"model": {
"id": "gpt-oss",
"name": "GPT-OSS",
"reason": "switched",
"switched_from": "Qwen"
},
"tokens": {
"prompt": 1200,
"completion": 340
},
"cost": null,
"chat_id": "chat1",
"message_id": "msg2",
"report_path": null,
"preview": "All the waybread is wrapped.",
"error": null,
"notified": true,
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"watch": null
},
"running": false,
"created_at": "2026-01-02T03:04:05+00:00"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"text": "Running bake --slow",
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "bash",
"what": "bake --slow"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"text": "",
"progress": null
}
@@ -0,0 +1,8 @@
{
"text": "Waiting for you to allow bash…",
"progress": {
"phase": "waiting_for_person",
"since": 1767323045000,
"what": "question"
}
}
@@ -0,0 +1,8 @@
{
"text": "",
"progress": {
"phase": "stopped",
"since": 1767323045000,
"reason": "person"
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"tree": {
"min": 1,
"max": 4,
"size": 2,
"open_slots": 1,
"agents": [
{
"id": "lead",
"name": "lead",
"position": "the main agent",
"parent": null,
"state": "working",
"model": "qwen-27b",
"mode": "edit",
"request": [],
"manager": true,
"context": {
"used": 41200,
"window": 131072
},
"progress": {
"phase": "thinking",
"since": 1767323045000
},
"findings": 0,
"messages": 2
},
{
"id": "s2",
"name": "parser",
"position": "reviews the parser",
"parent": "lead",
"state": "working",
"model": "qwen-27b",
"mode": "manual",
"task": "Review src/parse.ts.",
"request": [
"write"
],
"manager": false,
"context": {
"used": 9100,
"window": 131072
},
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "read",
"what": "src/parse.ts"
},
"findings": 2,
"messages": 3
}
],
"ended": [
{
"id": "s1",
"name": "scout",
"position": "maps the code",
"parent": "lead",
"state": "done",
"model": "qwen-27b",
"mode": "edit",
"task": "Map it.",
"request": [],
"manager": false,
"progress": null,
"findings": 1,
"messages": 1,
"ended": 1767323045000
}
]
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"path": "/home/frodo",
"parent": "/home",
"entries": [
{
"name": "recipes",
"path": "/home/frodo/recipes",
"kind": "dir",
"size": 0,
"mime": "",
"updated_at": "2026-01-02T03:04:05+00:00"
},
{
"name": "lembas.md",
"path": "/home/frodo/lembas.md",
"kind": "file",
"size": 27,
"mime": "text/markdown",
"updated_at": "2026-01-02T03:04:05+00:00"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"folder": {
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"folders": [
{
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
},
{
"id": "fold2",
"name": "Recipes",
"parent_id": "fold1",
"kind": "chat",
"position": 1,
"collapsed": true
}
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"api": 2,
"version": "2.0.0",
"account": {
"id": "user1",
"name": "Frodo",
"email": "frodo@shire.test"
},
"language": "sk",
"theme": "shire",
"default_model": "qwen",
"permissions": {
"chat.create": true,
"chat.delete": false,
"chat.model_select": true,
"chat.system_prompt": true,
"chat.params": true,
"files.upload": true,
"tools.agent": true,
"audio.transcribe": true,
"audio.listen": true
},
"services": {
"stt": true,
"tts": false,
"search": true,
"fetch": true,
"images": false
},
"scopes": [
"app",
"models"
],
"language_setting": "sk",
"languages": [
{
"id": "en",
"name": "English"
},
{
"id": "sk",
"name": "Slovenčina"
}
],
"themes": [
{
"id": "moria",
"label": "Moria",
"scheme": "dark"
},
{
"id": "shire",
"label": "Shire",
"scheme": "light"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"memories": [
{
"id": "mem1",
"content": "Prefers lembas warm.",
"author": "user",
"data_group": {
"id": "default",
"name": "Default"
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
],
"groups": [
{
"id": "default",
"name": "Default"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"memory": {
"id": "mem1",
"content": "Prefers lembas warm.",
"author": "user",
"data_group": {
"id": "default",
"name": "Default"
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"models": [
{
"id": "qwen",
"name": "Qwen",
"vision": false,
"tools": true,
"efforts": [
"medium"
],
"thinking_switch": true,
"levels": [
"off",
"medium"
],
"effort": "medium",
"control": "switch",
"parallel": 2,
"context": 131072,
"image": "/api/v2/models/images/0a1b2c3d4e5f.png",
"default": true,
"subagents": {
"offered": true,
"named_only": false
}
},
{
"id": "gpt-oss",
"name": "gpt-oss",
"vision": false,
"tools": true,
"efforts": [
"low",
"medium",
"high"
],
"thinking_switch": false,
"levels": [
"low",
"medium",
"high"
],
"effort": "high",
"control": "slider",
"parallel": 1,
"context": 0,
"image": null,
"default": false,
"subagents": {
"offered": true,
"named_only": false
}
},
{
"id": "vision-small",
"name": "Vision",
"vision": true,
"tools": false,
"efforts": [],
"thinking_switch": false,
"levels": [
"default"
],
"effort": "default",
"control": "none",
"parallel": null,
"context": 8192,
"image": null,
"default": false,
"subagents": {
"offered": true,
"named_only": false
}
}
],
"subagents_cap": 32,
"agent_subagents": {
"offered": true,
"device_runs": true
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"schedule_id": "sched1",
"run_id": "run1",
"chat_id": "chat1",
"status": "ok",
"visibility": "private",
"actions": [
"open",
"run_again"
],
"ask": "",
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"pause_label": "Pause",
"title": "LLeMbas",
"body": "A scheduled task has finished.",
"url": "/scheduled/sched1?run=run1",
"kind": "schedule"
}
@@ -0,0 +1,37 @@
{
"schedule_id": "sched1",
"run_id": "run3",
"chat_id": "chat1",
"status": "failed",
"visibility": "private",
"actions": [
"open",
"run_again",
"pause"
],
"ask": "pause",
"notification": {
"title": "Waybread check",
"body": "Failed 3 times in a row. Pause it?",
"public": {
"title": "Waybread check",
"body": "Failed"
},
"actions": [
"open",
"run_again",
"pause"
],
"labels": {
"open": "Open",
"run_again": "Run again",
"pause": "Pause"
},
"ask": "pause"
},
"pause_label": "Pause",
"title": "LLeMbas",
"body": "A scheduled task failed 3 times in a row. Pause it?",
"url": "/scheduled/sched1?run=run3",
"kind": "schedule_failed"
}
+107
View File
@@ -0,0 +1,107 @@
{
"schedule": {
"id": "sched1",
"title": "Waybread check",
"instruction": "Check the waybread is wrapped.",
"request": "every second Monday at 7:30",
"rule": {
"at": {
"nth": [
{
"n": 2,
"weekday": 0
}
],
"times": [
"07:30"
]
}
},
"description": "On the second Monday of each month at 07:30",
"timezone": "UTC",
"enabled": true,
"next_run": "2026-01-12T07:30:00+00:00",
"next_runs": [
"2026-01-12T07:30:00+00:00",
"2026-02-09T07:30:00+00:00",
"2026-03-09T07:30:00+00:00",
"2026-04-13T07:30:00+00:00",
"2026-05-11T07:30:00+00:00"
],
"target": "chat",
"mode": "fresh",
"notify": "matters",
"catch_up": "one",
"grace_minutes": null,
"model": "qwen",
"effort": "high",
"agents": null,
"tools": [
"files",
"recall",
"search",
"fetch",
"memory",
"skills",
"plan"
],
"origin": "user",
"chat_id": "chat1",
"fired_count": 3,
"failure_streak": 0,
"watch": null,
"runs_on": null,
"last_error": null,
"last_run": {
"id": "run1",
"schedule_id": "sched1",
"status": "ok",
"timing": "late",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": "2026-01-02T03:04:05+00:00",
"finished_at": "2026-01-02T03:04:05+00:00",
"waiting_for": null,
"retry_at": null,
"progress": null,
"attempts": 1,
"model": {
"id": "gpt-oss",
"name": "GPT-OSS",
"reason": "switched",
"switched_from": "Qwen"
},
"tokens": {
"prompt": 1200,
"completion": 340
},
"cost": null,
"chat_id": "chat1",
"message_id": "msg2",
"report_path": null,
"preview": "All the waybread is wrapped.",
"error": null,
"notified": true,
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"watch": null
},
"running": false,
"created_at": "2026-01-02T03:04:05+00:00"
}
}
+98
View File
@@ -0,0 +1,98 @@
{
"schedule": {
"id": "sched3",
"title": "Nightly tests",
"instruction": "Run the tests.",
"request": "every night at 02:00",
"rule": {
"every": {
"minutes": 1440
}
},
"description": "Every 1 day",
"timezone": "UTC",
"enabled": true,
"next_run": "2026-01-03T02:00:00+00:00",
"next_runs": [],
"target": "chat",
"mode": "fresh",
"notify": "matters",
"catch_up": "one",
"grace_minutes": null,
"model": null,
"effort": null,
"agents": null,
"tools": [
"files",
"recall",
"search",
"fetch",
"memory",
"skills",
"plan"
],
"origin": "user",
"chat_id": "chat2",
"fired_count": 3,
"failure_streak": 0,
"watch": {
"kind": "command",
"command": "git log -1 --format=%H",
"timeout_s": 30,
"target": "git log -1 --format=%H",
"changed_at": "2026-01-02T03:04:05+00:00"
},
"runs_on": {
"device": "tok1",
"device_name": "bag-end",
"cwd": "/home/frodo/waybread",
"mode": "edit",
"approval_wait_minutes": 30
},
"last_error": null,
"last_run": {
"id": "run5",
"schedule_id": "sched3",
"status": "waiting",
"timing": "on_time",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": null,
"finished_at": null,
"waiting_for": "device",
"retry_at": "2026-01-02T03:09:05+00:00",
"progress": {
"phase": "retrying",
"since": 1767323045000,
"reason": "Waiting for bag-end to be linked.",
"in": 0,
"attempt": 1
},
"attempts": 1,
"model": {
"id": null,
"name": null,
"reason": null,
"switched_from": null
},
"tokens": {
"prompt": 0,
"completion": 0
},
"cost": null,
"chat_id": null,
"message_id": null,
"report_path": null,
"preview": "",
"error": {
"class": "device_away",
"text": "Waiting for bag-end to be linked."
},
"notified": false,
"notification": null,
"watch": null
},
"running": false,
"created_at": "2026-01-02T03:04:05+00:00"
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"run": {
"id": "run1",
"schedule_id": "sched1",
"status": "ok",
"timing": "late",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": "2026-01-02T03:04:05+00:00",
"finished_at": "2026-01-02T03:04:05+00:00",
"waiting_for": null,
"retry_at": null,
"progress": null,
"attempts": 1,
"model": {
"id": "gpt-oss",
"name": "GPT-OSS",
"reason": "switched",
"switched_from": "Qwen"
},
"tokens": {
"prompt": 1200,
"completion": 340
},
"cost": null,
"chat_id": "chat1",
"message_id": "msg2",
"report_path": null,
"preview": "All the waybread is wrapped.",
"error": null,
"notified": true,
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"watch": null
},
"text": "All the waybread is *wrapped*."
}
+26
View File
@@ -0,0 +1,26 @@
{
"scope": {
"families": [
{
"gate": "notes",
"label": "Notes",
"on": true
},
{
"gate": "web",
"label": "Web search",
"on": false
}
],
"skills": [
{
"name": "baking",
"description": "Bake waybread.",
"on": true
}
],
"allow": [
"bash:ls *"
]
}
}
+69
View File
@@ -0,0 +1,69 @@
{
"q": "lembas",
"in": "all",
"offered": [
"chats",
"files",
"memories"
],
"results": [
{
"kind": "chats",
"more": true,
"hits": [
{
"kind": "chat",
"title": "Waybread",
"snippet": "…how long does lembas keep & travel?",
"marks": [
[
15,
21
]
],
"archived": false,
"id": "chat1",
"agent": false
}
]
},
{
"kind": "files",
"more": false,
"hits": [
{
"kind": "file",
"title": "bread.md",
"snippet": "/home/frodo/bread.md — Lembas recipe",
"marks": [
[
23,
29
]
],
"archived": false,
"path": "/home/frodo/bread.md"
}
]
},
{
"kind": "memories",
"more": false,
"hits": [
{
"kind": "memory",
"id": "mem1",
"title": "",
"snippet": "Prefers lembas warm.",
"marks": [
[
8,
14
]
],
"archived": false
}
]
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "s2",
"findings": [
{
"n": 1,
"text": "Login is POST /session.",
"time": 1767323045000
}
]
}
@@ -0,0 +1,24 @@
{
"id": "s2",
"state": "working",
"turns": [
{
"role": "user",
"text": "Write the routes.",
"machine": true
},
{
"role": "assistant",
"text": "Reading the API first.",
"tools": [
{
"name": "read",
"title": "src/api.py",
"isError": false
}
],
"error": "",
"running": true
}
]
}
+112
View File
@@ -0,0 +1,112 @@
{
"tree": {
"min": 2,
"max": 4,
"size": 3,
"open_slots": 0,
"agents": [
{
"id": "lead",
"name": "lead",
"position": "the main agent",
"parent": null,
"state": "working",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": true,
"progress": {
"phase": "waiting_for_subagents",
"since": 1767323045000,
"running": 2
},
"findings": 0,
"messages": 1
},
{
"id": "s1",
"name": "area",
"position": "owns the API",
"parent": "lead",
"state": "working",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": true,
"task": "Direct it.",
"progress": null,
"findings": 0,
"messages": 1
},
{
"id": "s2",
"name": "routes",
"position": "writes the routes",
"parent": "area",
"state": "working",
"model": "local/qwen",
"mode": "edit",
"request": [
"execute"
],
"manager": false,
"task": "Write the routes.",
"context": {
"used": 9100,
"window": 131072
},
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "read",
"what": "src/api.py"
},
"findings": 1,
"messages": 1
}
],
"ended": [
{
"id": "s3",
"name": "scout",
"position": "maps the code",
"parent": "lead",
"state": "done",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": false,
"task": "Map it.",
"progress": null,
"findings": 0,
"messages": 1,
"ended": 1767323045000
}
]
},
"room": [
{
"n": 1,
"from": "routes",
"to": [
"area"
],
"text": "Routes are in.",
"report": false,
"kind": "message",
"time": 1767323045000
},
{
"n": 2,
"from": "scout",
"to": [
"lead"
],
"text": "Mapped: see map.md.",
"report": true,
"kind": "report",
"time": 1767323045000
}
],
"live": true
}
+144
View File
@@ -0,0 +1,144 @@
// A stand-in for Tauri's bridge, for the page under a headless browser (tests/ui/run.mjs). Runs
// before the page's own scripts: `window.__TAURI__` answers the commands the page invokes from a
// table the test hands in, and records every call, so a test can say what the page asked for.
//
// The table (`config`):
// status what `get_status` answers (the app, the agent, the server)
// routes { "GET /me": value, "POST /chats/c1/interactions/k1": value, ... } -- the server's
// /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.
// 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
// (`control` frames as the server's JSON, `output` as text, sent base64 as Rust does).
// More with `__mock.shell(kind, data)`; `{ refuse: {status, detail} }` makes
// `terminal_open` fail.
//
// Nothing here talks to a network: the page's policy allows it none, and this keeps it so.
;(function () {
const config = window.__mockConfig || {}
const listeners = new Map() // event name -> [callback]
const streams = new Map() // stream id -> path
const terminals = new Map() // terminal id -> { target, open }
let nextTerminal = 0
let nextStream = 0
const calls = []
function dispatch(name, payload) {
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
}
function route(method, path) {
const routes = config.routes || {}
const bare = path.split("?")[0]
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
if (Object.prototype.hasOwnProperty.call(routes, key)) {
const value = routes[key]
if (value && value.__error) return Promise.reject(value.__error)
return Promise.resolve(JSON.parse(JSON.stringify(value)))
}
}
return Promise.reject({ status: 404, detail: `Not in the test's table: ${method} ${path}` })
}
// The shell's bytes, base64 as Rust sends them (`terminal.rs`): UTF-8 first.
const b64 = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text)))
function shell(kind, data, id) {
const ids = id ? [id] : [...terminals.keys()].filter((k) => terminals.get(k).open)
for (const tid of ids) {
if (kind === "end") terminals.get(tid).open = false
dispatch("terminal", { id: tid, kind, data: kind === "output" ? b64(data) : data })
}
}
function frame(path, event, data) {
for (const [id, p] of streams) if (p === path) dispatch("stream", { id, event, data })
}
async function invoke(cmd, args) {
calls.push({ cmd, args: args === undefined ? null : JSON.parse(JSON.stringify(args)) })
switch (cmd) {
case "get_status":
return JSON.parse(JSON.stringify(config.status))
case "api":
return route(args.method, args.path)
case "api_text":
return route("GET", args.path)
case "stream_open": {
const id = ++nextStream
streams.set(id, args.path)
const frames = (config.streams || {})[args.path] || []
setTimeout(() => {
for (const [event, data] of frames) dispatch("stream", { id, event, data })
}, 0)
return id
}
case "stream_close":
streams.delete(args.id)
return null
case "terminal_open": {
const t = config.terminal || []
if (t.refuse) return Promise.reject(t.refuse)
const id = ++nextTerminal
terminals.set(id, { target: { chat: args.chat, device: args.device, directory: args.directory }, open: true })
setTimeout(() => {
for (const [kind, data] of t) shell(kind, data, id)
}, 0)
return id
}
case "terminal_input":
case "terminal_resize":
return !!terminals.get(args.id)?.open
case "terminal_close": {
const t = terminals.get(args.id)
if (t?.open) setTimeout(() => shell("end", { code: 1000, reason: "", status: null, error: null }, args.id), 0)
return null
}
case "model_image":
return ""
case "terminal_word":
return "lembas"
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))
}
}
}
const win = {
minimize: async () => {},
toggleMaximize: async () => {},
close: async () => {},
isMaximized: async () => false,
}
window.__TAURI__ = {
core: { invoke },
event: {
listen: async (name, cb) => {
if (!listeners.has(name)) listeners.set(name, [])
listeners.get(name).push(cb)
return () => listeners.set(name, (listeners.get(name) || []).filter((x) => x !== cb))
},
},
window: { getCurrentWindow: () => win },
}
window.__mock = {
calls,
frame,
emit: dispatch,
/** The calls of one command, or of `api` with one method and path prefix. */
api: (method, prefix) => calls.filter((c) => c.cmd === "api" && c.args.method === method && c.args.path.startsWith(prefix)),
streams: () => [...streams.values()],
shell,
/** The terminals opened so far: [{ id, target, open }]. */
terminals: () => [...terminals].map(([id, t]) => ({ id, ...t })),
/** What was typed into a terminal, joined. */
typed: () => calls.filter((c) => c.cmd === "terminal_input").map((c) => c.args.data).join(""),
}
})()
+1743
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,5 +1,5 @@
{
"tag": "v2.0.0-beta.7",
"commit": "e3bd54f4d210cff6adb4586199caed4f41987f5e",
"sha256": "c08adb703678408b469520ee41a0918424cb962ecc89d80f4cbdac5e5c437b92"
"tag": "v2.0.0-beta.9",
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"sha256": "46d010bb862b06345c05f726cd1e6ae8415866324bfb0a55b8642ce986a9e769"
}
+204 -4
View File
@@ -327,6 +327,8 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
.shell[data-side="false"] { --side-col: 0px; }
.shell[data-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); }
.shell[data-activity="true"] { --panel-col: var(--inspector-width); }
/* The terminal: the server's width for it, giving way down to its least before the chat does. */
.shell[data-terminal="true"] { --panel-col: minmax(var(--terminal-width-min), var(--terminal-width)); }
/* --- The sidebar --------------------------------------------------------------------------- */
@@ -429,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; } }
@@ -1138,6 +1168,18 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
box-shadow: var(--shadow-lg);
}
.dialog::backdrop { background: var(--scrim); }
.dialog--wide { width: min(var(--w-prose), calc(100vw - 2 * var(--sp-4))); }
/* Draw a picture (draw.js): the prompt, a shape and two buttons on one line, the pictures below. */
.draw .dialog__title { display: flex; gap: var(--sp-2); align-items: center; }
.draw__prompt { width: 100%; }
.draw__bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: var(--sp-2); align-items: center; }
.draw__size { justify-self: start; max-width: 100%; }
.draw__out { display: grid; gap: var(--sp-3); max-height: 60vh; overflow-y: auto; }
.draw__out:empty { display: none; }
.draw__pic { display: grid; gap: var(--sp-1); margin: 0; }
.draw__pic img { display: block; width: 100%; height: auto; border-radius: var(--radius-lg); background: var(--surface); }
.draw__pic figcaption { font-size: var(--text-xs); color: var(--ink-muted); overflow-wrap: anywhere; }
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
@@ -1175,6 +1217,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
/* A hit found inside: its title over the snippet, the words that matched marked. */
.palette__label--two { display: grid; padding: var(--sp-1) 0; white-space: normal; }
.palette__title, .palette__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__snippet { font-size: var(--text-xs); color: var(--ink-muted); }
.palette__snippet mark { color: var(--ink); background: var(--surface-active); border-radius: var(--radius-sm); }
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
@@ -1241,11 +1288,16 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.composer__files { justify-content: start; }
.composer__bar {
display: grid;
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto;
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto auto;
gap: var(--sp-2);
align-items: center;
}
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto; }
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto auto; }
/* Dictate: a round tool beside Send; while it records it says so in the accent. */
.composer__mic { width: var(--round); height: var(--round); border-radius: var(--radius-full); }
.composer .composer__mic.is-recording { color: var(--accent-ink); background: var(--accent); }
.composer__mic.is-busy, .msg__speak.is-busy { opacity: 0.6; cursor: progress; }
.msg__speak[aria-pressed="true"] { color: var(--accent); }
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
@@ -1291,6 +1343,7 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
}
.cchip:hover { color: var(--ink); background: var(--surface-hover); border-color: var(--border); }
.cchip:disabled { cursor: default; }
.cchip[aria-pressed="true"], .tool[aria-pressed="true"] { color: var(--ink); background: var(--surface-active); }
.cchip:disabled:hover { background: transparent; border-color: transparent; }
.cchip__label { overflow: hidden; text-overflow: ellipsis; }
.cchip__chev { display: grid; color: var(--ink-faint); }
@@ -1365,6 +1418,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
.files__where { min-width: 0; }
/* The server's pane: Upload and New folder before Read again. */
#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); }
#server-status { white-space: pre-line; }
.files__where select { width: 100%; height: var(--control-h-sm); }
.files__tree { min-height: 0; }
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
@@ -1398,6 +1454,12 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
/* A row with a more button beside it: the button on the row's own line, its folder below both. */
.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
.tree__item > ul { grid-column: 1 / -1; }
.tree__more { opacity: 0; }
.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; }
@media (hover: none) { .tree__more { opacity: 1; } }
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
/* An empty page: an icon, a heading, a sentence, centred. */
@@ -1488,6 +1550,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.sched + .sched__history { margin-top: var(--sp-6); }
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
/* Where it runs: the computer over its folder; a computer that cannot take the run says so in colour. */
.fact__path { display: block; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); overflow-wrap: anywhere; }
.fact.is-warning .fact__hint { color: var(--warning); }
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
.sched__desc.is-error { color: var(--warning); }
@@ -1558,12 +1623,147 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
.folders .empty { display: block; padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); text-align: left; }
/* --- The terminal panel -------------------------------------------------------------------------
The panel column, as the activity panel has it, and never beside it (opening one closes the
other; the shell lives on). The viewer goes over the chat while it is open, as with the
activity panel. Not `.terminal`: xterm gives its own element that class. The head is the
viewer's: an icon, the device's name over the folder, then the buttons on known tracks (the
command ones only while the shell marks its commands). */
.shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
.termpanel {
grid-area: panel;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
border-left: var(--border-w) solid var(--border);
background: var(--bg-sunken);
}
.termpanel__head {
grid-template-columns: 16px minmax(0, 1fr);
grid-auto-flow: column;
grid-auto-columns: var(--control-h-sm);
}
.termpanel__icon { display: grid; place-items: center; color: var(--ink-muted); }
.termpanel__where { font-family: var(--font-mono); }
/* xterm measures this box: it has a size of its own, and the terminal scrolls inside it. */
.termpanel__screen { min-height: 0; min-width: 0; overflow: hidden; padding: var(--sp-2); background: var(--code-bg); }
.termpanel__screen .xterm { height: 100%; }
/* What happened, the last command, and Send: the words take what is left and wrap; the last
command is one clipped line; the button keeps its size. */
.termpanel__foot {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-flow: column;
grid-auto-columns: auto;
gap: var(--sp-2);
align-items: center;
padding: var(--sp-2) var(--sp-3);
border-top: var(--border-w) solid var(--border);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.termpanel__message { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.termpanel__message.is-error { color: var(--danger); }
.termpanel__last {
max-width: var(--w-chip);
font-family: var(--font-mono);
color: var(--ink-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.termpanel__last:empty { display: none; }
/* --- The subagent tree (spec 3.4.0) ------------------------------------------------------------
Rows are the tasks' rows (.agent): name and id on one line, the position, then the phase (or how
it ended) clipped to one line -- its clock is in the opened row. Ended ones are dimmed. */
.subagents { display: grid; gap: var(--sp-2); min-width: 0; }
.subagents .tasks__team, .subagents .tasks__goal { margin: 0; }
.subagent__who { display: flex; gap: var(--sp-2); align-items: baseline; min-width: 0; }
.subagent__who .agent__id { flex: none; }
.subagent__phase { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--ink-muted); }
.agent.is-ended { opacity: var(--opacity-dim); }
.subagent__buttons { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; }
.findings { margin: 0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + var(--sp-5)); display: grid; gap: var(--sp-3); }
.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. */
@media (max-width: 64rem) {
.shell[data-viewer="true"], .shell[data-activity="true"] { --panel-col: 0px; }
.viewer, .activity, .shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
.shell[data-viewer="true"], .shell[data-activity="true"], .shell[data-terminal="true"] { --panel-col: 0px; }
.viewer, .activity, .termpanel, .shell[data-activity="true"] .viewer, .shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
.task__open { grid-template-columns: var(--sp-8) minmax(0, 1fr) var(--w-field-sm); }
.task__next { display: none; }
}
+1281 -8
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -53,6 +53,8 @@
<div class="side__nav">
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
<button type="button" class="navrow" id="nav-archived" data-view="archived"><span class="navrow__icon"></span><span data-i18n>Archived</span></button>
<button type="button" class="navrow" id="nav-memory" data-view="memory"><span class="navrow__icon"></span><span data-i18n>Memory</span></button>
</div>
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
<div class="side__foot">
@@ -82,6 +84,7 @@
<div class="composer__bar">
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<div class="composer__chips" id="composer-chips"></div>
<button type="button" class="tool composer__mic" id="dictate" hidden aria-pressed="false"></button>
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
</div>
@@ -107,8 +110,11 @@
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="files__head">
<div class="files__where muted" id="server-where"></div>
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<p class="status" id="server-status" hidden></p>
<div class="files__tree" id="server-tree"></div>
</section>
</div>
@@ -123,6 +129,24 @@
<div class="page__body" id="scheduled-body"></div>
</section>
<section class="page" id="page-archived" hidden aria-labelledby="archived-title">
<header class="head">
<h1 class="head__title" id="archived-title" data-i18n>Archived</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="tool" id="archived-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="page__body" id="archived-body"></div>
</section>
<section class="page" id="page-memory" hidden aria-labelledby="memory-title">
<header class="head">
<h1 class="head__title" id="memory-title" data-i18n>Memory</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="tool" id="memory-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="page__body" id="memory-body"></div>
</section>
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
@@ -135,6 +159,26 @@
</div>
</section>
<aside class="termpanel" id="terminal" hidden aria-labelledby="terminal-title">
<header class="viewer__head termpanel__head">
<span class="termpanel__icon" id="terminal-icon" aria-hidden="true"></span>
<div class="viewer__id">
<h2 class="viewer__name" id="terminal-title" data-i18n>Terminal</h2>
<p class="viewer__where termpanel__where" id="terminal-where"></p>
</div>
<button type="button" class="tool" id="terminal-prev" hidden aria-label="The previous command" title="The previous command (Ctrl+Shift+↑)" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="terminal-next" hidden aria-label="The next command" title="The next command (Ctrl+Shift+↓)" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="terminal-copy" aria-label="Copy the last command and its output" title="Copy the last command and its output" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="terminal-close" aria-label="Close terminal" title="Close terminal" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="termpanel__screen" id="terminal-screen"></div>
<footer class="termpanel__foot">
<p class="termpanel__message" id="terminal-message" aria-live="polite"></p>
<span class="termpanel__last" id="terminal-last"></span>
<button type="button" class="btn btn--sm termpanel__send" id="terminal-send" title="Put the last command and its output into the message box" data-i18n-title><span data-i18n>Send to chat</span></button>
</footer>
</aside>
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
<header class="viewer__head activity__head">
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
+130 -8
View File
@@ -13,6 +13,14 @@
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
// POST /chats/{chat}/compact summarise the earlier turns now
// GET /chats/{chat}/tree?since= the subagent tree (spec 3.4.0) and the room
// GET /chats/{chat}/subagents/{id}/transcript a subagent's conversation, read-only
// GET /chats/{chat}/subagents/{id}/findings what it kept
// POST /chats/{chat}/subagents/{id}/stop it and those under it
//
// The tree comes too as the reply stream's `tree` frame (chat.js hands it on). An agent chat's
// subagents run on its device: while the device is away the server sends the tree it kept
// (`live` false) and answers 409 for a conversation or findings, in its own words.
//
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
// while the panel is open a new `version` reads the panel again. The context is the costly part
@@ -27,6 +35,7 @@ import { get, post, follow, why } from "./bridge.js"
import { markdown } from "./markdown.js"
import { ask } from "./popup.js"
import { openServerFile } from "./explorer.js"
import { treePart, tickTree, treeState, TREE_LIVE } from "./tree.js"
const ACTIVE = ["queued", "working", "waiting"]
// How many live agents show before "Show N more", as on the server's panel.
@@ -50,7 +59,12 @@ let readAt = 0 // when `data` was read: the elapsed times count on from there
let contextAt = 0
let reading = null // the read in flight
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
let view = null // null: the panel; { kind, id, name }: one agent's conversation
let view = null // null: the panel; { kind, id, name }: one agent's conversation ({ kind: "sub", what } a subagent's)
let tree = null // the subagent tree (spec 3.4.0), null for none or an older server
let treeRoom = [] // the room as the tree's answer gives it (an agent chat's comes only there)
let treeLive = true // false: an agent chat's device is away, and this is the tree the server kept
let treeNote = "" // why the last stop did not go through
let subTimer = 0 // a live subagent's conversation, read again while it works
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
let ticker = 0
@@ -147,6 +161,10 @@ export function watchActivity(next) {
data = null
room = []
cursor = 0
tree = null
treeRoom = []
treeLive = true
treeNote = ""
readAt = contextAt = 0
view = null
compactError = ""
@@ -164,6 +182,15 @@ export function watchActivity(next) {
}
}
/** The reply stream's `tree` frame: the tree as it is now. */
export function treeChanged(chatId, next) {
if (!chat || chat.id !== chatId) return
tree = next || null
if (!panelOpen()) return
if (!view) render()
else if (view.kind === "sub" && view.what === "transcript") readAgent()
}
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
export function activityChanged() {
if (chat && panelOpen()) read(true)
@@ -241,8 +268,16 @@ async function read(withContext) {
const id = chat.id
reading = (async () => {
try {
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
const [r, tr] = await Promise.all([
get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`),
// A server before 3.4.0 has no tree: 404, and the panel is as it was.
get(`/chats/${enc(id)}/tree`).catch(() => null),
])
const a = r.activity
if (chat?.id !== id || !a) return
tree = tr?.tree || null
treeRoom = tr?.room || []
treeLive = tr ? tr.live !== false : true
if (!withContext) a.context = data?.context ?? null
else contextAt = Date.now()
if (a.team) {
@@ -363,6 +398,7 @@ function tick() {
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
el.textContent = duration(Number(el.dataset.seconds) + extra)
}
tickTree($("activity-body"))
}
function renderError(text) {
@@ -392,13 +428,15 @@ function render() {
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
const entries = data.agents || []
// The tree draws the subagents; a team's members are not drawn again among the tasks.
const entries = (data.agents || []).filter((e) => !(tree && e.kind === "member"))
const parts = [
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
tree ? section("subagents", t("Subagents"), 0, subagentsPart()) : null,
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
data.team ? section("room", t("Room"), room.length, roomPart()) : null,
data.team || treeRoom.length ? section("room", t("Room"), roomList().length, roomPart()) : null,
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
]
body.replaceChildren(...parts.filter(Boolean))
@@ -561,7 +599,7 @@ function outputsPart(outputs) {
}
function tasksPart(entries) {
const team = data.team
const team = tree ? null : data.team
const box = h("div", { class: "tasks", "aria-live": "polite" })
if (!entries.length && !team) {
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
@@ -584,7 +622,84 @@ function tasksPart(entries) {
return box
}
/** The room: as the activity read it (a chat's team), else as the tree's answer gave it. */
function roomList() {
return room.length ? room : treeRoom
}
function subagentsPart() {
const box = treePart(tree, {
room: roomList(),
device: chat?.kind === "agent",
act: {
open: (a) => openSub(a, "transcript"),
findings: (a) => openSub(a, "findings"),
stop: stopSub,
isOpen: (key) => openRows.has(key),
toggled: (key, on) => (on ? openRows.add(key) : openRows.delete(key)),
},
})
if (!treeLive) box.prepend(h("p", { class: "activity__empty", text: t("This device is not linked right now.") }))
if (treeNote) box.prepend(h("p", { class: "activity__empty is-error", text: treeNote }))
return box
}
async function stopSub(a) {
if (!chat) return
const yes = await ask({ title: t("Stop %(name)s", { name: a.name }), text: t("Stop %(name)s and the subagents under it?", { name: a.name }), ok: t("Stop"), cancel: t("Cancel"), danger: true })
if (!yes) return
treeNote = ""
try {
await post(`/chats/${enc(chat.id)}/subagents/${enc(a.id)}/stop`)
} catch (err) {
treeNote = why(err)
}
read(false)
}
function openSub(a, what) {
view = { kind: "sub", id: a.id, name: a.name || a.id, what }
$("activity").dataset.view = "agent"
$("activity-title").textContent = what === "findings" ? `${view.name} · ${t("Findings")}` : view.name
$("activity-back").hidden = false
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
readAgent()
}
/** A subagent's conversation or findings, read-only; a live one's conversation is read again while it works. */
async function readSub() {
clearTimeout(subTimer)
const { id, what } = view
let r
try {
r = await get(`/chats/${enc(chat.id)}/subagents/${enc(id)}/${what}`)
} catch (err) {
// 409: an agent chat's device is away -- the server says it is on the device.
if (view?.id === id && view.what === what) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
return
}
if (view?.kind !== "sub" || view.id !== id || view.what !== what) return
const body = $("activity-body")
if (what === "findings") {
const list = r.findings || []
body.replaceChildren(
list.length
? h("ol", { class: "findings" }, list.map((f) => h("li", { class: "findings__item", value: String(f.n) }, h("div", { class: "md" }, markdown(String(f.text || ""))))))
: h("p", { class: "activity__empty", text: t("Nothing yet.") }),
)
return
}
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
const live = TREE_LIVE.includes(r.state)
const head = h("div", { class: "convo__head" }, h("p", { class: "agent__facts" }, h("span", { class: "agent__state", text: treeState(r.state) })))
const turns = turnsOf(r.turns || [])
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
if (live) subTimer = setTimeout(() => view?.kind === "sub" && view.id === id && panelOpen() && readAgent(), 3000)
}
function roomPart() {
const room = roomList()
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
return h(
"ol",
@@ -747,6 +862,7 @@ function openAgent(a) {
}
function closeAgent() {
clearTimeout(subTimer)
view = null
render()
$("activity-close").focus()
@@ -754,6 +870,7 @@ function closeAgent() {
async function readAgent() {
if (!chat || !view) return
if (view.kind === "sub") return readSub()
const { kind, id } = view
let r
try {
@@ -779,7 +896,14 @@ async function readAgent() {
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
)
const turns = (r.transcript || []).map((turn) => {
const turns = turnsOf(r.transcript || [])
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
}
/** A conversation's turns, read-only: the person's (or the harness's) messages and the replies. */
function turnsOf(list) {
return list.map((turn) => {
if (turn.role === "user") {
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
}
@@ -806,8 +930,6 @@ async function readAgent() {
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
)
})
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
}
// --- Start ------------------------------------------------------------------------------------------
+135
View File
@@ -0,0 +1,135 @@
// Archived chats: the ones put away from the sidebar with Archive chat, newest first. Each can be
// opened (read, and written to, as any chat), taken back into the sidebar (Restore chat), or deleted.
//
// GET /chats?archived=true&limit=&before= { chats: [chat], more }
// PATCH /chats/{id} { archived: false } { chat }
// DELETE /chats/{id} 204
//
// A chat's title is the person's or a model's words, set as text.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { get, patch, del, why } from "./bridge.js"
import { ask, menu } from "./popup.js"
import { folderName } from "./list.js"
let ctx = null // { open(id), changed(), me() }
let rows = []
let more = false
let error = ""
let loading = false
/** The page, read again from the server. */
export async function showArchived() {
loading = true
error = ""
render()
try {
const r = await get("/chats?archived=true&limit=50")
rows = r.chats || []
more = !!r.more
} catch (err) {
rows = []
more = false
error = why(err)
}
loading = false
render()
}
async function loadMore(button) {
const last = rows[rows.length - 1]
if (!last) return
button.disabled = true
try {
const r = await get(`/chats?archived=true&limit=50&before=${encodeURIComponent(last.id)}`)
rows = rows.concat(r.chats || [])
more = !!r.more
} catch (err) {
error = why(err)
}
render()
}
/** Back into the sidebar. */
export async function unarchive(chat) {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: false })
rows = rows.filter((x) => x.id !== chat.id)
error = ""
render()
ctx?.changed(r?.chat || chat)
return true
} catch (err) {
error = why(err)
render()
return false
}
}
async function remove(chat) {
const yes = await ask({
title: t("Delete this chat?"),
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
ok: t("Delete"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
try {
await del(`/chats/${encodeURIComponent(chat.id)}`)
rows = rows.filter((x) => x.id !== chat.id)
error = ""
} catch (err) {
error = why(err)
}
render()
}
function row(c) {
const agent = c.kind === "agent"
const title = c.title || t("New chat")
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const sub = [agent && c.device?.directory ? folderName(c.device.directory) : "", when(c.updated_at)].filter(Boolean).join(" · ")
return h(
"li",
{ class: "arow", dataset: { id: c.id } },
h("span", { class: `arow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
h(
"button",
{ type: "button", class: "arow__open", title, onclick: () => ctx.open(c.id) },
h("span", { class: "arow__title" }, h("bdi", { text: title })),
h("span", { class: "arow__sub", text: sub }),
),
h("button", { type: "button", class: "btn btn--sm arow__unarchive", text: t("Restore chat"), onclick: () => unarchive(c) }),
mayDelete
? h(
"button",
{
type: "button",
class: "tool arow__more",
"aria-label": t("More"),
title: t("More"),
onclick: (e) => menu(e.currentTarget, [{ label: t("Delete chat"), danger: true, run: () => remove(c) }]),
},
icon(ICONS.more),
)
: h("span", { class: "arow__more" }),
)
}
function render() {
const box = clear($("archived-body"))
const parts = []
if (error) parts.push(h("p", { class: "status is-error", text: error }))
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("No archived chats.") }))
if (rows.length) {
parts.push(h("ul", { class: "arch", "aria-label": t("Archived") }, rows.map(row)))
if (more) parts.push(h("button", { type: "button", class: "btn btn--sm arch__more", text: t("More"), onclick: (e) => loadMore(e.currentTarget) }))
}
box.append(h("div", { class: "arch__page" }, parts))
}
export function initArchived(context) {
ctx = context
}
+62
View File
@@ -66,7 +66,69 @@ export async function follow(path, { onFrame, onEnd }) {
}
}
// --- The terminal panel's socket -----------------------------------------------------------------
//
// Opened by Rust (`terminal_open`), which holds the token and the socket; the page gets the frames
// as `terminal` events -- `output` (the shell's bytes, base64), `control` (the server's JSON), a last
// `end` -- and sends keys, sizes and "close" through commands. As with a followed reply, a frame
// can come before `terminal_open` has answered with its id: those wait.
const terminals = new Map()
const earlyTerminal = new Map()
listen("terminal", (e) => {
const { id, kind, data } = e.payload
const f = terminals.get(id)
if (f) deliverTerminal(f, id, kind, data)
else {
if (!earlyTerminal.has(id)) earlyTerminal.set(id, [])
earlyTerminal.get(id).push([kind, data])
}
})
function deliverTerminal(f, id, kind, data) {
if (kind === "end") {
terminals.delete(id)
f.onEnd(data || {})
} else if (kind === "output") f.onOutput(data)
else if (kind === "control") f.onControl(data)
}
/**
* Open a shell: `target` is `{ chat }` or `{ device, directory }`; `onOutput(base64)`,
* `onControl(frame)`, and `onEnd({ code, reason, status, error })` once. The handle sends keys
* (`input`), a size (`resize`), and lets go (`close`; onEnd still comes).
*/
export async function openTerminal(target, cols, rows, { onOutput, onControl, onEnd }) {
const id = await invoke("terminal_open", {
chat: target.chat ?? null,
device: target.device ?? null,
directory: target.directory ?? null,
cols,
rows,
})
const f = { onOutput, onControl, onEnd }
terminals.set(id, f)
for (const [kind, data] of earlyTerminal.get(id) || []) {
if (!terminals.has(id)) break
deliverTerminal(f, id, kind, data)
}
earlyTerminal.delete(id)
return {
id,
input: (data) => invoke("terminal_input", { id, data }),
resize: (c, r) => invoke("terminal_resize", { id, cols: c, rows: r }),
close: () => invoke("terminal_close", { id }),
}
}
/** A web link, in the system's browser. */
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])
}
+397 -33
View File
@@ -13,15 +13,20 @@ import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList, activityButton } from "./activity.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"]
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
@@ -34,6 +39,110 @@ export function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
function deviceOf(id) {
return (ctx.devices?.() || []).find((d) => d.id === id) || null
}
/** 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
* terminals, or nothing.
*/
function terminalTarget() {
if (draft) {
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 || (draftHere() ? t("This computer") : t("Another device")) }
}
const chat = open?.chat
if (chat?.kind !== "agent" || !chat.device) return null
const device = deviceOf(chat.device.token_id)
if (!device?.terminal) return null
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
}
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
async function readAgentFacts(device) {
if (!device) return null
const known = agentFacts.get(device)
if (known && Date.now() - known.at < 5000) return known
try {
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
if (!r.agent_subagents) {
agentFacts.set(device, { at: Date.now(), none: true })
return agentFacts.get(device)
}
noteSubagentsCap(r.subagents_cap)
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
agentFacts.set(device, facts)
return facts
} catch {
return null
}
}
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
function agentOffer(facts, model) {
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
const said = facts.models.get(model?.id)
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
return { cap: subagentsCap(), own: namedOnly(said) }
}
/** Text into the message box, after what is there, for the person to read before sending. */
export function intoComposer(text) {
const box = $("composer-text")
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
box.dispatchEvent(new Event("input"))
box.focus()
}
function nearBottom() {
const th = $("thread")
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
@@ -184,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"),
),
@@ -312,6 +422,24 @@ class Live {
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
this.done = false
this.handle = null
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
this.said = "" // the last `status` text, for a server that sends no progress
this.clock = 0
}
/** The status line: the progress object's line where the server sends one, else its text. */
drawStatus() {
const p = this.progress
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
this.status.dataset.phase = isProgress(p) ? p.phase : ""
const ticks = progressTicks(p)
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
else if (!ticks) this.stopClock()
}
stopClock() {
clearInterval(this.clock)
this.clock = 0
}
async start() {
@@ -327,11 +455,18 @@ class Live {
stop() {
this.done = true
this.stopClock()
this.handle?.close()
}
frame(event, data) {
if (this.done) return
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
// the object, or null for nothing running -- then the status text again.
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
this.progress = isProgress(data.progress) ? data.progress : null
if (event !== "status") this.drawStatus()
}
switch (event) {
case "steps":
renderSteps(this.steps, data.steps || [], this.chat)
@@ -341,11 +476,16 @@ class Live {
this.tailed(data)
break
case "status":
this.status.textContent = data.text || t("Writing…")
this.said = data.text || ""
this.drawStatus()
break
case "ask":
renderCards(this.chat, data.cards || [])
break
case "tree":
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
treeChanged(this.chat.id, data.tree)
break
case "jobs":
this.jobs.hidden = !(data.jobs || []).length
append(
@@ -360,6 +500,7 @@ class Live {
break
case "done":
this.done = true
this.stopClock()
this.el.replaceWith(assistantMessage(data.message, this.chat))
renderCards(this.chat, [])
if (data.chat?.title) setTitle(data.chat.title)
@@ -385,6 +526,7 @@ class Live {
end(err) {
if (this.done) return
this.done = true
this.stopClock()
if (open?.live === this) open.live = null
setRunning(false)
if (err) {
@@ -417,10 +559,32 @@ function follower(chat, messageId) {
// --- Cards ----------------------------------------------------------------------------------------
/**
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
* countdown included, and drawing it again would take away a command being corrected or an option
* already picked. New ones are added in the server's order; the ones no longer listed go.
*/
function renderCards(chat, cards) {
const box = clear($("cards"))
box.hidden = !cards.length
for (const card of cards) box.append(cardOf(chat, card))
const box = $("cards")
const ids = cards.map((c) => String(c.id))
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
cards.forEach((card, i) => {
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
if (!el) {
try {
el = cardOf(chat, card)
} catch (err) {
// Never an empty box under a status that says the reply waits: say what went wrong.
console.error("card", card, err)
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
}
el.dataset.card = String(card.id)
el.dataset.chat = chat.id
}
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
})
box.hidden = !box.children.length
}
function cardOf(chat, card) {
@@ -430,24 +594,44 @@ function cardOf(chat, card) {
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
for (const item of card.items) {
const part = h("div", { class: "ask__item" })
// A background subagent asks, not the conversation's own turn: said first (`job`).
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
if (item.subagent) {
const sub = item.subagent
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
}
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
let detailShown = false
if (item.kind === "approval") {
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
if (item.editable) {
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
// this item's text, and only when it differs -- an untouched one is not an edit.
const command = item.detail || item.title
detailShown = true
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
edit.addEventListener("input", () => {
if (edit.value !== item.title) answers.texts[item.key] = edit.value
if (edit.value !== command) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else part.append(h("pre", { class: "call__pre", text: item.title }))
} else {
// What it wants to do (a tool's own title), then its input as the device sent it.
part.append(h("p", { class: "ask__what", text: item.title }))
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
detailShown = true
}
} else if (item.kind === "plan" && item.plan) {
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
} else {
part.append(h("p", { class: "ask__title", text: item.title }))
}
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
// The model's account of what it is for, attributed to it; then the server's reason for asking.
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
if (item.options?.length) {
const picked = new Set()
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
@@ -506,12 +690,11 @@ function cardOf(chat, card) {
const actions = h("div", { class: "ask__actions" })
if (decides) {
const isPlan = card.items.some((i) => i.kind === "plan")
append(
actions,
append(actions, [
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
)
])
} else {
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
}
@@ -542,16 +725,12 @@ function renderHead(chat) {
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
),
)
if (chat.device_session_id) {
tools.append(
h(
"button",
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
icon(ICONS.terminal),
),
)
}
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
// "Continue in a terminal" stays in the chat's menu.
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
}
// An archived chat says so where it is read, with the way back into the sidebar.
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
}
@@ -610,6 +789,47 @@ const MODE_WORDS = {
plan: () => t("Plan — changes nothing"),
}
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
/** Whether a chat here may be asked for a picture: the server draws for this account. */
function picturesOffered() {
const me = ctx.me()
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
}
function pictureChip() {
const b = chip({
ico: ICONS.picture,
label: t("Draw a picture"),
title: t("The reply to the next message is a picture drawn from it."),
menuish: false,
cls: "cchip--picture",
onclick: () => {
wantPicture = !wantPicture
b.setAttribute("aria-pressed", String(wantPicture))
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
$("composer-text").focus()
},
})
b.setAttribute("aria-pressed", String(wantPicture))
return b
}
/** The message's body, asking for a picture while the switch is on. */
function takePicture(body) {
return wantPicture ? { ...body, force_tool: "image_generate" } : body
}
/** The message went: the switch goes off with it. */
function pictureSent() {
if (!wantPicture) return
wantPicture = false
$("composer-text").placeholder = t("Message")
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
}
function modeChip(value, onChange, disabled) {
return chip({
ico: ICONS.shield,
@@ -697,6 +917,51 @@ function folderChip(roots, value, onChange) {
})
}
/**
* Which computer a new agent chat works on: this one (the agent built into this app), or another
* linked device of the account, as `GET /devices` lists them. Shown only where there is another.
*/
function deviceChip(others) {
const here = draftHere()
const current = deviceOf(draftDevice())
const choose = (id) => {
if (id === draftDevice()) return
draft.device = id
const elsewhere = !ctx.thisDevices().includes(id)
draft.dir = elsewhere ? knownFolders(id)[0] || "" : ""
renderChips()
renderHello()
renderNotice()
}
const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }]
for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) })
const label = here ? t("This computer") : current?.name || current?.host || t("Another device")
const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) })
return b
}
/** The folder of a new agent chat on another device: one it has worked in, or one typed. */
function elsewhereFolderChip(device, value, onChange) {
const type = async () => {
const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") })
if (dir) onChange(dir)
}
const known = knownFolders(device)
if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type })
return chip({
ico: ICONS.folder,
label: value ? folderName(value) : t("Choose a folder…"),
title: value ? `${t("Folder")}: ${value}` : "",
cls: value ? "" : "is-warn",
onclick: (e) =>
menu(e.currentTarget, [
...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
known.length ? null : undefined,
{ label: t("Type a folder…"), run: type },
].filter((x) => x !== undefined)),
})
}
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
@@ -710,22 +975,51 @@ async function renderChips() {
const model = models.find((m) => m.id === draft.model)
const parts = []
if (draft.kind === "agent") {
const others = otherDevices()
if (others.length) parts.push(deviceChip(others))
if (draftHere()) {
const roots = s?.remote?.enabled ? s.remote.roots : []
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
} else {
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
}
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
const shell = terminalTarget()
aimTerminal(shell)
parts.push(modelChip(models, draft.model, (id) => {
draft.model = id
draft.effort = levelsOf(models.find((m) => m.id === id)).start
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : ""
const known = device ? agentFacts.get(device) : null
const offer = device ? agentOffer(known, model) : null
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
if (device && (!known || Date.now() - known.at > 5000)) {
readAgentFacts(device).then((f) => {
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)
}
box.replaceChildren(...parts.filter(Boolean))
return
}
const chat = open?.chat
aimTerminal(terminalTarget())
if (!chat?.kind) return box.replaceChildren()
const save = async (body) => {
try {
@@ -747,6 +1041,8 @@ async function renderChips() {
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
box.replaceChildren(...parts.filter(Boolean))
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
@@ -760,7 +1056,7 @@ async function renderChips() {
function renderNotice() {
const box = $("link-notice")
const s = ctx.status()
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat)
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
box.hidden = !show
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
@@ -773,6 +1069,10 @@ export function statusChanged() {
if (draft) {
renderChips()
renderHello()
} else if (open?.chat?.kind) {
// The devices were read again: whether this chat's opens a terminal may have changed.
renderHead(open.chat)
aimTerminal(terminalTarget())
}
}
@@ -781,9 +1081,14 @@ export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (!chat.archived) {
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
}
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) })
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
@@ -816,6 +1121,34 @@ export async function continueInTerminal(chat) {
})
}
/** Where a chat can go: no folder, any of the account's, or a new one. */
function moveMenu(anchor, chat) {
const here = chat.folder_id || ""
menu(anchor, [
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
null,
{
label: t("New folder…"),
run: async () => {
const id = await newFolder()
if (id) setChat(chat, { folder_id: id })
},
},
])
}
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
async function setChat(chat, body) {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
ctx.changed(r?.chat)
} catch (err) {
alertIn(why(err))
}
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
@@ -840,6 +1173,15 @@ async function archiveChat(chat) {
}
}
/** An archived chat, open or in its page's menu, back into the sidebar. */
async function unarchiveChat(chat) {
if (!(await unarchive(chat))) return
if (open?.chat?.id === chat.id) {
open.chat = { ...open.chat, archived: false }
renderHead(open.chat)
}
}
async function deleteChat(chat) {
const yes = await ask({
title: t("Delete this chat?"),
@@ -952,7 +1294,7 @@ export function closeChat() {
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written
let lastKind = "agent"
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
@@ -960,6 +1302,18 @@ export function draftKind() {
return draft?.kind || lastKind
}
/** `/models` was read again: a new chat being written is drawn with what it says now. */
export function modelsChanged() {
if (!draft) return
const models = ctx.models()
if (!models.some((m) => m.id === draft.model)) {
const start = models.find((m) => m.default) || models[0]
draft.model = start?.id || ""
draft.effort = levelsOf(start).start
}
renderChips()
}
/** The greeting of a new chat, and the choice of what it is. */
function renderHello() {
const box = $("new-chat")
@@ -1002,7 +1356,9 @@ function renderHello() {
h(
"p",
{ class: "hello__sub" },
kind === "agent"
kind === "agent" && !draftHere()
? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") })
: kind === "agent"
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
),
@@ -1018,7 +1374,7 @@ export function newChat(kind) {
const models = ctx.models()
const start = models.find((m) => m.default) || models[0]
lastKind = kind || lastKind
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
@@ -1044,15 +1400,21 @@ async function createChat(text, attachments) {
const agents = draft.agents
if (agents && (agents.min || agents.max)) body.agents = agents
if (draft.kind === "agent") {
const device = ctx.thisDevices()[0]
const device = draftDevice()
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.") }
}
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"
@@ -1089,9 +1451,11 @@ async function submit() {
try {
if (draft) await createChat(text, files)
else if (open?.chat?.id) {
const body = { text }
let body = { text }
if (files.length) body.attachments = files
if (open.chat.kind === "chat") body = takePicture(body)
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
pictureSent()
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
} else return
+8 -1
View File
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
return el
}
export function append(el, children) {
/** append(el, child, [more], …): children one by one or in arrays, nested as deep as they come. */
export function append(el, ...children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
@@ -75,6 +76,7 @@ export const ICONS = {
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
chevronUp: "M3.5 10L8 5.5 12.5 10 11.4 11.1 8 7.7 4.6 11.1z",
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
@@ -97,6 +99,11 @@ export const ICONS = {
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"],
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
}
+86
View File
@@ -0,0 +1,86 @@
// Draw a picture: the instance's own image generation (`POST /v1/images/generations`, its
// workflow and default checkpoint), from Ctrl+K. Offered only where `GET /me` says the account may
// (`services.images`) and the token holds `models`.
//
// The pictures are shown only as Rust read them: each one of the person's own files on this
// server (`/v1/files/<id>/content`, fetched by id from the app's own server, never from an
// address the answer named), handed over as a `data:` URL of an image type -- checked again here
// before it becomes an <img>.
import { h, icon, ICONS } from "./dom.js"
import { invoke, why } from "./bridge.js"
const PICTURE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/
/** Whether pictures can be drawn here, for the account `me` (`GET /me`). */
export function canDraw(me) {
const models = !Array.isArray(me?.scopes) || me.scopes.includes("models")
return !!me?.services?.images && models && me?.permissions?.["tools.image"] !== false
}
/** The dialog: a prompt, a shape, Draw, and the pictures under it. */
export function openDraw() {
const prompt = h("textarea", { class: "textarea draw__prompt", rows: "3", placeholder: t("What to draw…"), "aria-label": t("What to draw…") })
const size = h(
"select",
{ class: "draw__size", "aria-label": t("Shape") },
h("option", { value: "1024x1024", text: t("Square") }),
h("option", { value: "1344x768", text: t("Wide") }),
h("option", { value: "768x1344", text: t("Tall") }),
)
const status = h("p", { class: "status", role: "status", hidden: true })
const out = h("div", { class: "draw__out" })
const go = h("button", { type: "submit", class: "btn btn--primary", text: t("Draw") })
const form = h(
"form",
{ class: "dialog__body draw", method: "dialog" },
h("h2", { class: "dialog__title" }, icon(ICONS.picture), h("span", { text: t("Draw a picture") })),
prompt,
h("div", { class: "draw__bar" }, size, h("button", { type: "button", class: "btn", text: t("Close"), onclick: () => close() }), go),
status,
out,
)
const dlg = h("dialog", { class: "dialog dialog--wide" }, form)
let asked = 0
const close = () => {
asked++
dlg.close()
dlg.remove()
}
const said = (text, error = false) => {
status.hidden = !text
status.textContent = text
status.className = `status${error ? " is-error" : ""}`
status.setAttribute("role", error ? "alert" : "status")
}
form.addEventListener("submit", async (e) => {
e.preventDefault()
const words = prompt.value.trim()
if (!words) return prompt.focus()
const mine = ++asked
go.disabled = true
said(t("Drawing… a picture can take a minute or two."))
try {
const urls = await invoke("draw", { prompt: words, size: size.value })
if (mine !== asked) return
const pictures = (urls || []).filter((u) => PICTURE.test(u))
if (!pictures.length) throw { detail: t("The server's answer had no picture in it.") }
out.prepend(...pictures.map((src) => h("figure", { class: "draw__pic" }, h("img", { src, alt: words }), h("figcaption", { text: words }))))
said("")
} catch (err) {
if (mine === asked) said(why(err), true)
} finally {
if (mine === asked) go.disabled = false
}
})
prompt.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
close()
})
document.body.append(dlg)
dlg.showModal()
prompt.focus()
}
+126 -11
View File
@@ -3,7 +3,8 @@
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, why } from "./bridge.js"
import { invoke, get, post, del, why } from "./bridge.js"
import { menu, ask } from "./popup.js"
import { openViewer } from "./viewer.js"
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
@@ -18,12 +19,19 @@ export const SERVER_FILES = {
/** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
/**
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
*/
function tree(box, { list, open, rootPath, actions }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const levels = new Map() // path -> { ul, depth }, each directory drawn now
const opened = new Set() // the directories open now
const level = async (parent, path, depth) => {
levels.set(path, { ul: parent, depth })
let entries
try {
entries = await list(path)
@@ -42,26 +50,45 @@ function tree(box, { list, open, rootPath }) {
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
const items = actions?.(e) || []
const li = h(
"li",
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
row,
items.length
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
)
if (isDir(e)) {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
const expand = async (opening) => {
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
kids?.remove()
kids = null
return
}
opened.add(e.path)
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
}
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
if (opened.has(e.path)) expand(true)
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
const done = level(ul, rootPath, 0)
const reload = (path) => {
const at = levels.get(path) || levels.get(rootPath)
if (!at) return Promise.resolve()
clear(at.ul)
return level(at.ul, levels.has(path) ? path : rootPath, at.depth)
}
return { done, reload }
}
// --- This computer ------------------------------------------------------------------------------
@@ -99,7 +126,7 @@ function renderLocal() {
head.append(pick)
}
const root = local.root
tree(box, {
return tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
@@ -125,12 +152,96 @@ export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
// The library's writes, as the person (each answers with the directory's listing, or a refusal
// in the server's words):
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
// POST /files/mkdir { path, name }
// POST /files/rename { path, name }
// DELETE /files?path= to the trash, as on the web
let server = null // the tree drawn now: { reload(path) }
let serverRoot = "/" // the directory the server answered `/` with: the person's home
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir)
function serverSaid(text, error = false) {
const line = $("server-status")
line.hidden = !text
line.textContent = text || ""
line.className = `status${error ? " is-error" : ""}`
line.setAttribute("role", error ? "alert" : "status")
}
/** Into `dir`: files chosen in the system's dialog. */
export async function uploadInto(dir) {
serverSaid("")
try {
const r = await invoke("library_upload", { dir, title: t("Upload") })
if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true)
if (r.sent) await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function makeFolder(dir) {
const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") })
if (!name) return
try {
await post("/files/mkdir", { path: dir, name })
serverSaid("")
await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function renameEntry(e) {
const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") })
if (!name || name === e.name) return
try {
await post("/files/rename", { path: e.path, name })
serverSaid("")
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
async function deleteEntry(e) {
const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/files?path=${encodeURIComponent(e.path)}`)
serverSaid(t("Moved to the trash."))
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
/** What can be done to an entry of the server's tree: a mount (another store) is filled, never
* renamed or deleted from here. A read-only place is refused by the server, in its words. */
function serverActions(e) {
const items = []
if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) })
if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
return items
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
serverSaid("")
server = tree(box, {
rootPath: "/",
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
list: async (path) => {
const r = await get(SERVER_FILES.list(path))
if (path === "/" && r.path) serverRoot = r.path
return r.entries || []
},
open: (e) => openServerFile(e.path, e.name),
actions: serverActions,
})
}
@@ -177,6 +288,10 @@ export function initExplorer() {
$("server-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("server-upload").replaceChildren(icon(ICONS.send))
$("server-upload").addEventListener("click", () => uploadInto(serverRoot))
$("server-mkdir").replaceChildren(icon(ICONS.folder))
$("server-mkdir").addEventListener("click", () => makeFolder(serverRoot))
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}
+156 -9
View File
@@ -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
View File
@@ -0,0 +1,121 @@
// The app's language and theme, as Settings chooses them.
//
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
// `t()` as it is needed, so a new language opens the window again.
//
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
import { invoke, patch, letGoAll } from "./bridge.js"
import { terminalThemeChanged } from "./terminal.js"
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
let me = null // /me, as last read
function read(key) {
try {
return localStorage.getItem(key) || ""
} catch {
return ""
}
}
function write(key, value) {
try {
if (value) localStorage.setItem(key, value)
else localStorage.removeItem(key)
} catch {}
}
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
function known(code) {
const c = String(code || "")
.trim()
.toLowerCase()
.split(/[-_]/)[0]
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
}
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
export function accountThemes() {
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
}
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
export function localTheme() {
const name = read("lembas.theme")
return !accountThemes() && LOOKS.includes(name) ? name : ""
}
/** The account's theme, as the server says it. */
export function accountTheme() {
return me?.theme || "moria"
}
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
export function applyTheme(name) {
name = localTheme() || name
if (!LOOKS.includes(name)) name = "moria"
const root = document.documentElement
root.dataset.theme = name
root.dataset.base = name
terminalThemeChanged()
return name
}
function paint() {
invoke("set_theme", { name: applyTheme(accountTheme()) })
}
/**
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
* it is now another, the window opens again in it.
*/
export function noteMe(next) {
me = next || null
paint()
if (!me || !me.language) return
const account = known(me.language)
const before = read("lembas.account-language")
write("lembas.account-language", account)
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
if (read("lembas.account-language") !== account) return
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
}
/**
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
* server cannot be told, kept here ("" goes back to the account's).
*/
export async function chooseTheme(name) {
if (accountThemes()) {
const r = await patch("/me", { theme: name })
me = r && r.theme ? r : { ...me, theme: name }
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
paint()
}
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
export function chosenLanguage() {
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
}
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
export function accountLanguage() {
return me?.language ? known(me.language) : ""
}
/** Choose the language; the window opens again in it. */
export function chooseLanguage(choice) {
if (choice === chosenLanguage()) return
write("lembas.language", choice === "system" || known(choice) ? choice : "")
return reopen()
}
/** Open the window again: the replies followed and the shells open let go first. */
async function reopen() {
await letGoAll()
location.reload()
}
+89 -19
View File
@@ -10,20 +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 } from "./chat.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.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
@@ -62,13 +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
}
// --- Views ------------------------------------------------------------------------------------
function route() {
@@ -96,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
@@ -117,13 +116,17 @@ function stopApp() {
closeViewer()
}
async function loadModels() {
let modelsAt = 0 // when /models was last read
/** The account's models, with what a new chat on each is offered. `keep`: a failed read keeps the last list. */
async function loadModels({ keep = false } = {}) {
try {
const r = await get("/models")
models = r.models || []
noteSubagentsCap(r.subagents_cap)
modelsAt = Date.now()
} catch {
models = []
if (!keep) models = []
}
}
@@ -148,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()
@@ -156,9 +159,14 @@ 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") showPanel(false)
if (name !== "chat") {
showPanel(false)
showTerminal(false)
}
if (narrow()) sideOpen(false)
}
@@ -177,6 +185,10 @@ function startNew(kind) {
newChat(kind || draftKind())
markCurrent()
localPane()
// What a new chat is offered (its models, Subagents) is the server's word now, not at sign-in:
// an administrator may have switched subagents on or added a model while the app stayed open in
// the tray. Drawn at once from the last list, then again from the fresh one.
if (started && Date.now() - modelsAt > 5000) loadModels({ keep: true }).then(modelsChanged)
}
/** Open a chat, from the list or the palette. */
@@ -196,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)
@@ -210,9 +232,13 @@ 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 }) })
out.push(
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
@@ -253,10 +279,16 @@ 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,
devices: () => devices,
models: () => models,
me: () => me,
changed: () => refreshList(),
@@ -276,14 +308,34 @@ 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)
})
// One panel column: the activity panel and the terminal take turns in it (the shell stays open).
initTerminal({
onLayout: (on) => {
$("shell").dataset.terminal = String(on)
if (on && panelOpen()) showPanel(false)
},
composer: intoComposer,
})
initActivity({
onLayout: (on) => {
$("shell").dataset.activity = String(on)
if (on && terminalOpen()) showTerminal(false)
},
here: onThisComputer,
// The conversation as it now stands: the summary's divider, read again, if it is still open.
@@ -302,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))
@@ -345,11 +405,21 @@ async function boot() {
renderBar(status)
route()
// The agent came up or went: which devices are linked changes with it.
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(() => (refreshList(), statusChanged()))
})
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
View File
@@ -0,0 +1,159 @@
// Memory: what the account's models remember about the person, as the web's Settings → Memory
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
// group chosen (where there is more than one).
//
// GET /memories { memories: [memory], groups: [{ id, name }] }
// POST /memories { content, data_group_id? } -> { memory }
// PATCH /memories/{id} { content } -> { memory }
// DELETE /memories/{id} 204
//
// A record may be a model's words (`author` other than "user"): set as text, never markup.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { get, post, patch, del, why } from "./bridge.js"
import { ask, menu } from "./popup.js"
let rows = []
let groups = []
let error = ""
let loading = false
let editing = "" // the record being changed
let mark = "" // the record to bring into view (from a search hit)
/** The page, read again from the server; `focus` a record to bring into view. */
export async function showMemory(focus = "") {
mark = focus
loading = true
error = ""
render()
try {
const r = await get("/memories")
rows = r.memories || []
groups = r.groups || []
} catch (err) {
rows = []
error = why(err)
}
loading = false
render()
}
async function add(form) {
const text = form.querySelector("textarea")
const content = text.value.trim()
if (!content) return text.focus()
const group = form.querySelector("select")?.value
form.querySelector("button[type=submit]").disabled = true
try {
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
if (r?.memory) rows = [r.memory, ...rows]
error = ""
text.value = ""
} catch (err) {
error = why(err)
}
render()
$("memory-new")?.focus()
}
async function save(m, content) {
content = content.trim()
if (!content || content === m.content) {
editing = ""
return render()
}
try {
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
editing = ""
error = ""
} catch (err) {
error = why(err)
}
render()
}
async function remove(m) {
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/memories/${encodeURIComponent(m.id)}`)
rows = rows.filter((x) => x.id !== m.id)
error = ""
} catch (err) {
error = why(err)
}
render()
}
function row(m) {
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
if (editing === m.id) {
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
box.value = m.content
const done = () => save(m, box.value)
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.stopPropagation()
editing = ""
render()
}
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
})
queueMicrotask(() => box.focus())
return h(
"li",
{ class: "mrow is-editing", dataset: { id: m.id } },
box,
h(
"div",
{ class: "set__buttons" },
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
),
)
}
return h(
"li",
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
h(
"button",
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
icon(ICONS.more),
),
)
}
function render() {
const box = clear($("memory-body"))
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
const form = h(
"form",
{
class: "mnew",
onsubmit: (e) => {
e.preventDefault()
add(e.currentTarget)
},
},
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
h(
"div",
{ class: "mnew__foot" },
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
),
)
form.querySelector("textarea").addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
parts.push(form)
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
box.append(h("div", { class: "arch__page" }, parts))
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
}
+70 -8
View File
@@ -1,12 +1,16 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
// 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
View File
@@ -199,7 +199,8 @@ function escPop(e) {
}
/**
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
* A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's
* point; or above it, `up`), holding `content` (an element, or a function
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
* { 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()
+134
View File
@@ -0,0 +1,134 @@
// What a session is doing, as one line: the harness's `progress` object (spec 3.4.0,
// harness/progress.md in the LLeMbas CLI) drawn the way every client draws it --
// `{{text}} · {{elapsed}}`, with the shared texts (`loop.json` progress.texts) as this page's
// sentences. The server sends the object beside the old `status` text in a chat's stream; where it
// sends none (an older server), the status text is drawn as before.
//
// The texts are the spec's, word for word with their `{{field}}`s: the server's catalogues
// translate them under the same keys (scripts/gen-i18n.py takes them from there).
/** The thirteen phases: a text for each, and a variant where a field may be missing. */
const TEXTS = {
queued: "Queued",
"queued.ahead": "Queued: {{ahead}} ahead",
waiting_for_model: "Waiting for {{model}}: {{reason}}",
loading_model: "Loading {{model}}",
reading_prompt: "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
"reading_prompt.bare": "Reading the prompt",
thinking: "Thinking",
writing: "Writing: {{rate}} tok/s",
"writing.bare": "Writing",
running_tool: "Running {{tool}}: {{what}}",
"running_tool.bare": "Running {{tool}}",
waiting_for_person: "Waiting for you: {{what}}",
waiting_for_subagents: "Waiting for subagents: {{running}} running",
compacting: "Compacting the conversation",
retrying: "Retrying in {{in}} s: {{reason}}",
stopped: "Stopped",
"stopped.reason": "Stopped: {{reason}}",
failed: "Failed: {{reason}}",
}
/** The words a field stands for. */
const REASONS = {
busy: "it serves one request at a time and is answering another",
slots: "all its slots are in use",
group: "its server holds another model now",
}
const WHAT = {
approval: "allow {{tool}}?",
"approval.bare": "an approval",
question: "a question",
plan: "the plan",
}
const STOPPED = { person: "by you", steps: "step limit", budget: "budget" }
/** Every sentence above, for scripts/gen-i18n.py to find (it reads `t("…")` calls by hand). */
export const PROGRESS_SENTENCES = [...Object.values(TEXTS), ...Object.values(REASONS), ...Object.values(WHAT), ...Object.values(STOPPED)]
const NO_ELAPSED = ["stopped", "failed"]
const has = (p, k) => p[k] !== undefined && p[k] !== null && p[k] !== ""
/** A sentence in the page's language (the spec's text is the key, as in the server's
* catalogues), its `{{field}}`s filled -- after the lookup, so a field's own text is never read
* as a template. */
function say(text, fields = {}) {
return t(text).replace(/\{\{(\w+)\}\}/g, (all, name) => (Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all))
}
/** An integer as it is; a rate with one decimal, in the page's language (12.5, 12,5). */
function rate(n) {
try {
return new Intl.NumberFormat(document.documentElement.lang || "en", { useGrouping: false, minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n)
} catch {
return Number(n).toFixed(1)
}
}
const int = (n) => String(Math.trunc(Number(n) || 0))
/** Whole seconds, as a ticking clock writes them: 5s, 1m 05s, 1h 02m. */
export function elapsed(ms) {
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000))
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
}
/** Whether `p` is a progress object this page can draw (one of the thirteen phases). */
export function isProgress(p) {
return !!p && typeof p === "object" && Object.prototype.hasOwnProperty.call(TEXTS, p.phase)
}
/** The phase's text, its fields filled; "" for nothing this page knows. */
export function progressText(p, now = Date.now()) {
if (!isProgress(p)) return ""
switch (p.phase) {
case "queued":
return has(p, "ahead") ? say(TEXTS["queued.ahead"], { ahead: int(p.ahead) }) : say(TEXTS.queued)
case "waiting_for_model":
return say(TEXTS.waiting_for_model, { model: p.model || "", reason: REASONS[p.reason] ? say(REASONS[p.reason]) : p.reason || "" })
case "loading_model":
return say(TEXTS.loading_model, { model: p.model || "" })
case "reading_prompt":
if (!has(p, "done") || !(Number(p.total) > 0)) return say(TEXTS["reading_prompt.bare"])
return say(TEXTS.reading_prompt, { done: int(p.done), total: int(p.total), percent: int(Math.floor((Number(p.done) * 100) / Number(p.total))) })
case "writing":
return has(p, "rate") ? say(TEXTS.writing, { rate: rate(p.rate) }) : say(TEXTS["writing.bare"])
case "running_tool":
return has(p, "what") ? say(TEXTS.running_tool, { tool: p.tool || "", what: p.what }) : say(TEXTS["running_tool.bare"], { tool: p.tool || "" })
case "waiting_for_person": {
const what = p.what === "approval" ? (has(p, "tool") ? say(WHAT.approval, { tool: p.tool }) : say(WHAT["approval.bare"])) : WHAT[p.what] ? say(WHAT[p.what]) : p.what || ""
return say(TEXTS.waiting_for_person, { what })
}
case "waiting_for_subagents":
return say(TEXTS.waiting_for_subagents, { running: int(p.running) })
case "retrying": {
const left = Math.max(0, Math.trunc(Number(p.in) || 0) - Math.floor((now - Number(p.since || now)) / 1000))
return say(TEXTS.retrying, { in: String(left), reason: p.reason || "" })
}
case "stopped":
return has(p, "reason") ? say(TEXTS["stopped.reason"], { reason: STOPPED[p.reason] ? say(STOPPED[p.reason]) : p.reason }) : say(TEXTS.stopped)
case "failed":
return say(TEXTS.failed, { reason: p.reason || "" })
default:
return say(TEXTS[p.phase])
}
}
/**
* The line: `{{text}} · {{elapsed}}`, the text alone for stopped and failed (and where `bare`:
* a row with no room for the clock).
*/
export function progressLine(p, now = Date.now(), { bare = false } = {}) {
const text = progressText(p, now)
if (!text || bare || NO_ELAPSED.includes(p.phase) || !has(p, "since")) return text
// progress.line, `{{text}} · {{elapsed}}`: the same in every language.
return `${text} · ${elapsed(now - Number(p.since))}`
}
/** Whether the line changes by itself as time passes (it has a clock, or a countdown). */
export function progressTicks(p) {
return isProgress(p) && !NO_ELAPSED.includes(p.phase) && has(p, "since")
}
+103 -5
View File
@@ -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 })
+91
View File
@@ -0,0 +1,91 @@
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
// the server offers this chat now (web search among them) and the skills, each on or off for this
// chat only, and the commands it was told to stop asking about.
//
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
// POST /chats/{chat}/allow/clear -> { scope }
//
// Narrowing only, on the server: switching on what the model, the permissions or the instance
// left out reaches nothing. Labels, skill names and descriptions are set as text.
import { h } from "./dom.js"
import { get, post, why } from "./bridge.js"
import { popover } from "./popup.js"
/** The switches of `chat`, in a popover under `anchor`. */
export async function scopePopover(anchor, chat) {
const path = `/chats/${encodeURIComponent(chat.id)}`
let scope = null
let error = ""
let busy = false
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
// Drawn again after each answer: the switch that was used keeps the focus.
const redraw = (key) => {
pop.redraw()
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
}
const send = async (url, body) => {
const key = document.activeElement?.dataset?.key || ""
busy = true
redraw(key)
try {
scope = (await post(url, body))?.scope || scope
error = ""
} catch (err) {
error = why(err)
}
busy = false
redraw(key)
}
const toggle = (kind, name, label, on) =>
h(
"label",
{ class: "check scope__row", title: label.title || null },
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
h("span", { text: label.text }),
)
function draw() {
const body = h("div", { class: "pop__body scope" })
if (!scope) {
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
return body
}
const families = scope.families || []
const skills = scope.skills || []
const allow = scope.allow || []
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
if (families.length) {
body.append(
h("p", { class: "pop__title", text: t("Tools") }),
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
)
}
if (skills.length) {
body.append(
h("p", { class: "pop__title", text: t("Skills") }),
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
)
}
if (allow.length) {
body.append(
h("p", { class: "pop__title", text: t("Always allowed here") }),
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
)
}
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
return body
}
try {
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
} catch (err) {
error = why(err)
}
pop.redraw()
}
+123 -2
View File
@@ -1,10 +1,13 @@
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
// 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) {
+494
View File
@@ -0,0 +1,494 @@
// The terminal panel: a shell on the device an agent chat works on, beside the chat -- the web
// page's panel (the server's static/js/terminal.js), in this app. Two places it opens:
//
// - an open agent chat: the chat's own shell (`{ chat }`), the one the web page shares;
// - a new agent chat, as soon as its device and folder are chosen (`{ device, directory }`):
// a shell there before the first message, which the chat that message makes takes over.
//
// The socket is Rust's (bridge.js `openTerminal`): the page never holds the token. xterm is the
// server's vendored build (ui/vendor, scripts/fetch-vendor.sh), loaded the first time the panel
// opens. Never beside the activity panel: the shell's layout has one panel column (as the web
// page has), so opening one closes the other -- the shell stays open behind it.
//
// Three things about xterm the web panel learnt the hard way, kept here: it cannot fit while its
// box is hidden (so every fit waits until the panel is shown), the panel changes size without a
// window `resize` (so a ResizeObserver), and it reads no CSS variables (so the theme is read from
// the computed tokens, and again when the theme changes).
import { $, h, icon, ICONS } from "./dom.js"
import { openTerminal, why } from "./bridge.js"
const ANSI = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
const MAX_MARKED = 200
let ctx = { layout: () => {}, composer: () => {} }
let target = null // what the panel is for: { key, chat } | { key, device, directory }, with `name`
let term = null
let fit = null
let session = null // the open socket's handle, and which target it is for: { handle, key, draft }
let observer = null
let loading = null
// A device's shell marks its own commands (OSC 133 A/B/C/D, OSC 7 for where it is) and says so
// in its ready frame (`osc`): then a command and its output are read from the panel's own
// buffer. Without the marks, "send" falls back to the screen as it is, and says so.
let osc = false
let pending = null
let finished = []
let visiting = -1
const panel = () => $("terminal")
function say(text, isError) {
const el = $("terminal-message")
el.textContent = text || ""
el.classList.toggle("is-error", !!isError)
}
/** Whether the panel is shown. */
export function terminalOpen() {
return !panel().hidden
}
/** Whether there is a shell to open for what the chat page shows now. */
export function terminalAvailable() {
return !!target
}
// --- xterm, loaded once --------------------------------------------------------------------------
function load() {
if (loading) return loading
loading = new Promise((resolve, reject) => {
if (typeof window.Terminal === "function" && window.FitAddon) return resolve()
document.head.append(h("link", { rel: "stylesheet", href: "vendor/xterm.css" }))
const script = (src) =>
new Promise((ok, fail) => {
const s = h("script", { src })
s.addEventListener("load", ok)
s.addEventListener("error", () => fail(new Error(src)))
document.head.append(s)
})
script("vendor/xterm.js")
.then(() => script("vendor/xterm-addon-fit.js"))
.then(resolve, reject)
})
loading.catch(() => {
loading = null
})
return loading
}
function readTheme() {
const style = getComputedStyle(document.documentElement)
const token = (name, fallback) => (style.getPropertyValue(name) || "").trim() || fallback
const theme = {
background: token("--code-bg", "black"),
foreground: token("--ink", "white"),
cursor: token("--accent", "white"),
cursorAccent: token("--code-bg", "black"),
selectionBackground: token("--accent-soft", "gray"),
}
for (const name of ANSI) {
const value = token(`--ansi-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, "")
if (value) theme[name] = value
}
return theme
}
/** The theme changed: xterm's colours are read again. */
export function terminalThemeChanged() {
if (term) term.options.theme = readTheme()
}
function build() {
if (term) return
const style = getComputedStyle(document.documentElement)
term = new window.Terminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
scrollback: 5000,
theme: readTheme(),
})
fit = new window.FitAddon.FitAddon()
term.loadAddon(fit)
term.open($("terminal-screen"))
term.parser.registerOscHandler(133, onCommandMark)
term.parser.registerOscHandler(7, onWhere)
term.onData((data) => session?.handle?.input(data))
// Ctrl+C is the shell's interrupt; copy and paste are the shifted pair, as in every terminal.
term.attachCustomKeyEventHandler((e) => {
if (!e.ctrlKey || !e.shiftKey || e.type !== "keydown") return true
if (osc && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
visit(e.key === "ArrowUp" ? -1 : 1)
return false
}
const key = e.key.toLowerCase()
if (key === "c") {
const text = term.getSelection()
if (text) navigator.clipboard.writeText(text).catch(() => {})
return false
}
if (key === "v") {
navigator.clipboard
.readText()
.then((text) => session?.handle?.input(text))
.catch(() => {})
return false
}
return true
})
observer = new ResizeObserver(() => refit())
observer.observe($("terminal-screen"))
}
function refit() {
if (!term || !fit || !terminalOpen() || !$("terminal-screen").offsetWidth) return
try {
fit.fit()
} catch {
return
}
session?.handle?.resize(term.cols, term.rows)
}
// --- The socket ------------------------------------------------------------------------------------
function decode(b64) {
const raw = atob(b64)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
return bytes
}
async function connect() {
if (!target || !term || session) return
const t0 = target
// The session exists before the socket answers, so frames that come first are this one's.
const mine = { handle: null, key: t0.key, draft: !t0.chat }
session = mine
say(t("Connecting…"))
let handle
try {
handle = await openTerminal(t0.chat ? { chat: t0.chat } : { device: t0.device, directory: t0.directory }, term.cols || 80, term.rows || 24, {
onOutput: (b64) => {
if (session === mine) term.write(decode(b64))
},
onControl: (frame) => {
if (session === mine) control(frame)
},
onEnd: (end) => {
if (session !== mine) return
session = null
ended(end)
},
})
} catch (err) {
if (session === mine) session = null
say(why(err), true)
return
}
mine.handle = handle
// Let go of meanwhile (another chat, the panel's target changed): closed at once.
if (session !== mine) handle.close()
}
function control(frame) {
if (frame.t === "ready") {
say(frame.shared ? t("Connected. This shell is also open in another tab, and they share a size.") : t("Connected."))
if (frame.dir) $("terminal-where").textContent = frame.dir
osc = !!frame.osc
showMarkControls()
showLast("")
refit()
if (terminalOpen()) term.focus()
} else if (frame.t === "behind") {
// This window stopped reading and was let go so the others kept up. The scrollback is the
// state: connecting again costs nothing.
say(frame.message || t("Reconnecting…"))
drop()
term.reset()
forgetMarks()
connect()
} else if (frame.t === "closed" || frame.t === "error") {
// No reconnect of its own: a new shell has lost the folder, the environment and the
// half-typed command, and quietly putting one in its place is worse than saying it went.
say(frame.message || t("This terminal closed."), frame.t === "error")
drop()
}
}
function ended(end) {
if (end.code === 4401 || end.status === 401) say(t("Sign in again: the server no longer accepts this app's sign-in."), true)
else if (end.status === 404) say(t("The terminal needs a newer LLeMbas server. Ask its administrator to update it."), true)
else if (end.error) say(end.error, true)
else say(t("Disconnected. Close and reopen to reconnect."))
}
/** Let go of the socket (the shell on the device lives on, as the server keeps it). */
function drop() {
const s = session
session = null
s?.handle?.close()
}
// --- What the panel is for -----------------------------------------------------------------------
/**
* Tell the panel what the chat page shows: `{ chat, name, directory }` for an open agent chat,
* `{ device, directory, name }` for a new one with its device and folder chosen, or null (no shell
* to open). A new chat's shell becomes its chat's when the first message has made the chat: the
* server took that shell over, so the socket stays as it is.
*/
export function aimTerminal(next) {
const key = next ? (next.chat ? `chat:${next.chat}` : `device:${next.device}:${next.directory}`) : ""
const before = target
target = next ? { ...next, key } : null
if (target) {
$("terminal-title").textContent = target.name || t("Terminal")
$("terminal-where").textContent = target.directory || ""
$("terminal-where").title = target.directory || ""
}
if (before?.key === key) return
// The draft's shell is the new chat's now: the same socket, said for the chat from here on.
const adopted = session?.draft && before && !before.chat && target?.chat && before.directory === target.directory && before.device === target.device
if (adopted) {
session.key = key
session.draft = false
return
}
if (session) {
drop()
if (term) term.reset()
forgetMarks()
say("")
}
if (!target) {
if (terminalOpen()) showTerminal(false)
return
}
if (terminalOpen()) connect()
}
// --- Showing it ------------------------------------------------------------------------------------
/** Show or hide the panel. Hiding keeps the shell and its socket: reopening is instant. */
export async function showTerminal(on) {
if (on && !target) return
panel().hidden = !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 {
say(t("The terminal could not be loaded."), true)
return
}
if (!terminalOpen()) return
build()
// A frame later: the panel has only just been shown and has no width to fit to yet.
requestAnimationFrame(() => {
refit()
if (wanted && terminalOpen()) connect()
term.focus()
})
}
export function toggleTerminal() {
showTerminal(!terminalOpen())
}
function markOpen() {
for (const b of document.querySelectorAll("[data-terminal-toggle]")) b.setAttribute("aria-pressed", String(terminalOpen()))
}
/** The header's button for an open chat; a new chat has the chip under the composer instead. */
export function terminalButton() {
return h(
"button",
{
type: "button",
class: "tool",
"aria-label": t("Terminal"),
title: t("Terminal"),
"aria-controls": "terminal",
"aria-pressed": String(terminalOpen()),
"data-terminal-toggle": "",
onclick: toggleTerminal,
},
icon(ICONS.terminal),
)
}
// --- A device shell's own marks ----------------------------------------------------------------------
function onCommandMark(data) {
if (!osc || !term) return false
const [kind, code] = String(data).split(";")
if (kind === "A") pending = { prompt: term.registerMarker(0) }
else if (kind === "B") {
pending = pending || {}
pending.input = term.registerMarker(0)
pending.inputX = term.buffer.active.cursorX
} else if (kind === "C") {
pending = pending || {}
pending.output = term.registerMarker(0)
pending.command = commandText(pending)
} else if (kind === "D") {
if (pending?.output) {
pending.exit = parseInt(code, 10) || 0
pending.end = term.registerMarker(0)
commandDone(pending)
}
pending = null
}
return true
}
function onWhere(data) {
if (!osc) return false
let path = String(data).replace(/^file:\/\/[^/]*/, "")
try {
path = decodeURIComponent(path)
} catch {}
if (path) $("terminal-where").textContent = path
return true
}
function linesBetween(from, to, firstX) {
const buffer = term.buffer.active
const out = []
for (let y = from; y < to; y++) {
const line = buffer.getLine(y)
if (!line) continue
const text = line.translateToString(true, y === from ? firstX || 0 : 0)
if (line.isWrapped && out.length) out[out.length - 1] += text
else out.push(text)
}
while (out.length && !out[out.length - 1].trim()) out.pop()
return out.join("\n")
}
function commandText(mark) {
if (!mark.input || !mark.output || mark.input.line < 0) return ""
return linesBetween(mark.input.line, Math.max(mark.output.line, mark.input.line + 1), mark.inputX).trim()
}
function outputOf(mark) {
if (!mark.output || !mark.end || mark.output.line < 0 || mark.end.line < 0) return ""
return linesBetween(mark.output.line, mark.end.line, 0)
}
function commandDone(mark) {
finished.push(mark)
if (finished.length > MAX_MARKED) finished.shift()
visiting = -1
showLast((mark.command || "…") + (mark.exit ? ` · exit ${mark.exit}` : ""))
}
function blockOf(mark) {
const where = $("terminal-where").textContent
const head = `${where ? `${where} ` : ""}$ ${mark.command || ""}`
return "```\n" + head + "\n" + outputOf(mark) + "\n```\n" + (mark.exit ? `exit ${mark.exit}\n` : "")
}
function lastFinished() {
for (let i = finished.length - 1; i >= 0; i--) if (finished[i].output && finished[i].output.line >= 0) return finished[i]
return null
}
function visit(step) {
const live = finished.filter((m) => (m.prompt || m.input || m.output)?.line >= 0)
if (!live.length) return
visiting = visiting < 0 ? live.length : visiting
visiting = Math.max(0, Math.min(live.length - 1, visiting + step))
const m = live[visiting]
term.scrollToLine((m.prompt || m.input || m.output).line)
}
function forgetMarks() {
finished = []
pending = null
visiting = -1
osc = false
showMarkControls()
showLast("")
}
function showMarkControls() {
$("terminal-prev").hidden = !osc
$("terminal-next").hidden = !osc
}
function showLast(text) {
$("terminal-last").textContent = text || ""
}
// --- Handing it to the chat --------------------------------------------------------------------------
/** What "send" and "copy" take: the selection; else the last command (with marks); else the screen. */
function capture() {
if (!term) return ""
const chosen = term.getSelection()
if (chosen && chosen.trim()) return "```\n" + chosen + "\n```\n"
if (osc) {
const mark = lastFinished()
if (!mark) {
say(t("Nothing has been run in this shell yet."))
return ""
}
return blockOf(mark)
}
const buffer = term.buffer.active
const last = buffer.baseY + buffer.cursorY
const lines = []
for (let y = Math.max(0, last - 40); y <= last; y++) {
const line = buffer.getLine(y)
if (line) lines.push(line.translateToString(true))
}
const text = lines.join("\n").replace(/\n+$/, "")
if (!text.trim()) {
say(t("Nothing to send: select some output first."))
return ""
}
say(t("Copied the last of the screen, as it appeared. This shell does not mark where commands begin."))
return "```\n" + text + "\n```\n"
}
/** Into the message box, never sent: what a machine printed is for the person to read first. */
function sendToChat() {
const text = capture()
if (!text) return
ctx.composer(text)
// Where the panel covers the chat (a narrow window), it steps aside to show the message box.
if (window.matchMedia("(max-width: 64rem)").matches) showTerminal(false)
}
function copyOut() {
const text = capture()
if (!text) return
navigator.clipboard.writeText(text).then(
() => say(t("Copied.")),
() => {},
)
}
export function initTerminal({ onLayout, composer }) {
ctx = { layout: onLayout || (() => {}), composer: composer || (() => {}) }
$("terminal-icon").replaceChildren(icon(ICONS.terminal))
$("terminal-prev").replaceChildren(icon(ICONS.chevronUp))
$("terminal-next").replaceChildren(icon(ICONS.chevronDown))
$("terminal-copy").replaceChildren(icon(ICONS.copy))
$("terminal-send").prepend(icon(ICONS.send))
$("terminal-close").replaceChildren(icon(ICONS.close))
$("terminal-prev").addEventListener("click", () => visit(-1))
$("terminal-next").addEventListener("click", () => visit(1))
$("terminal-copy").addEventListener("click", copyOut)
$("terminal-send").addEventListener("click", sendToChat)
$("terminal-close").addEventListener("click", () => showTerminal(false))
}
+200
View File
@@ -0,0 +1,200 @@
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it, the
// server's own panel in this app (`chat/_activity_tasks.html` tree_row): the main agent at the
// root, every subagent under its parent in the order added, how many slots are open, then the
// ended ones folded apart, dimmed, with how each ended. A row is the tasks' grid -- chevron, who
// it is and what it is doing, its context use in the time column, a stop square -- and opened,
// its facts (state, model, mode and request options, messages, findings), its task, its phase
// line, the last room message to or from it, and the buttons to read its conversation and its
// findings. Its subagents nest under it.
//
// The data is the server's `GET /api/v2/chats/{chat}/tree` (and the stream's `tree` frame):
//
// { min, max, size, open_slots,
// agents: [{ id, name, position, parent, state, model, mode, request, manager, task,
// context: { used, window }, progress, findings, messages }],
// ended: [{ ..., ended }] }
//
// Everything in it but the numbers is a model's words: set as text, never as markup.
import { h, icon, ICONS } from "./dom.js"
import { progressLine, progressText } from "./progress.js"
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
const STATES = {
working: "working",
waiting: "waiting for a slot",
finished: "finished",
idle: "idle",
paused: "paused: context full",
done: "done",
out_of_context: "out of context",
stopped: "stopped",
}
/** A subagent that may still be stopped (the server's TREE_LIVE). */
export const TREE_LIVE = ["working", "waiting", "paused"]
export function treeState(state) {
return STATES[state] ? t(STATES[state]) : String(state || "")
}
/** floor(used × 100 / window), or null when the window is not known. */
export function contextPercent(c) {
const used = Number(c?.used)
const window = Number(c?.window)
if (!(window > 0) || !(used >= 0)) return null
return Math.min(100, Math.floor((used * 100) / window))
}
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
export function treeOrder(agents) {
const names = new Map()
for (const a of agents) names.set(a.id, a.id).set(a.name, a.id)
const parentOf = (a) => {
const p = a.parent ? names.get(a.parent) : null
return p && p !== a.id ? p : null
}
const under = new Map()
for (const a of agents) {
const p = parentOf(a)
if (!under.has(p)) under.set(p, [])
under.get(p).push(a)
}
const out = []
const seen = new Set()
const walk = (parent, depth) => {
for (const a of under.get(parent) || []) {
if (seen.has(a.id)) continue
seen.add(a.id)
out.push({ agent: a, depth })
walk(a.id, depth + 1)
}
}
walk(null, 0)
for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 })
return out
}
/** The room's messages to or from an agent (by name). */
function messagesOf(room, a) {
return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name))
}
function firstLine(text) {
return String(text || "").split("\n").find((l) => l.trim()) || ""
}
/**
* The tree as rows. `act` holds what the rows can do: `open(a)` (its conversation, read-only),
* `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded
* across redraws. `room` is the room as read so far; `device` says the subagents run on the
* computer the chat runs on (an agent chat).
*/
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
const box = h("div", { class: "subagents" })
box.append(h("p", { class: "tasks__team", text: t("%(size)s of %(max)s", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }))
if (Number(tree.open_slots) > 0) box.append(h("p", { class: "tasks__goal subagents__slots", text: t("Open slots: %(n)s", { n: tree.open_slots }) }))
if (device) box.append(h("p", { class: "tasks__goal", text: t("They run on the computer this chat runs on.") }))
box.append(h("div", { class: "tasks__list subagents__list" }, treeOrder(tree.agents || []).map(({ agent, depth }) => row(agent, depth, { room, act, now, ended: false }))))
const ended = tree.ended || []
if (ended.length) {
const key = "fold:ended"
const fold = h(
"details",
{ class: "tasks__more subagents__ended", open: !!act.isOpen?.(key) },
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: `${t("Ended")} ${ended.length}` })),
h("div", { class: "tasks__list" }, ended.map((a) => row(a, 0, { room, act, now, ended: true }))),
)
fold.addEventListener("toggle", () => act.toggled?.(key, fold.open))
box.append(fold)
}
return box
}
function row(a, depth, { room, act, now, ended }) {
const key = `sub:${a.id}`
const main = !a.parent && (a.id === "lead" || a.name === "lead")
const pct = ended ? null : contextPercent(a.context)
const said = messagesOf(room, a)
const last = said[said.length - 1]
const mode = a.mode ? (a.request?.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode) : ""
const live = !ended && TREE_LIVE.includes(a.state)
const phase = !ended && a.progress ? progressText(a.progress, now) : ""
const stop =
live && !main && act.stop
? h(
"button",
{
type: "button",
class: "agent__stop",
"aria-label": t("Stop %(name)s", { name: a.name }),
title: t("Stop %(name)s", { name: a.name }),
onclick: (e) => {
e.preventDefault()
e.stopPropagation()
act.stop(a)
},
},
h("span", { class: "agent__square", "aria-hidden": "true" }),
)
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
const el = h(
"details",
{ class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: !!act.isOpen?.(key), dataset: { id: a.id } },
h(
"summary",
{ class: "agent__row" },
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
h(
"span",
{ class: "agent__what" },
h(
"span",
{ class: "subagent__who" },
h("bdi", { class: "agent__name", text: a.name || a.id }),
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
),
main ? h("span", { class: "agent__role", text: t("the main agent") }) : a.position ? h("span", { class: "agent__role", text: a.position }) : null,
// The phase alone where a row has no room for its clock (progress.md §3); ended: how.
h("span", { class: "subagent__phase", dataset: { phase: phase ? a.progress.phase : "" }, text: phase || treeState(a.state) }),
),
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
stop,
),
h(
"div",
{ class: "agent__body" },
h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: treeState(a.state) }),
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
mode ? h("span", { text: mode }) : null,
h("span", { text: t("messages: %(n)s", { n: a.messages ?? said.length }) }),
a.findings ? h("span", { text: t("findings: %(n)s", { n: a.findings }) }) : null,
),
a.task && !main ? h("p", { class: "agent__brief", text: a.task }) : null,
phase ? h("p", { class: "agent__text subagent__line", dataset: { progress: JSON.stringify(a.progress) }, text: progressLine(a.progress, now) }) : null,
last ? h("p", { class: "agent__text", text: `${last.from} → ${(last.to || []).join(", ")}: ${firstLine(last.text)}` }) : null,
main
? null
: h(
"p",
{ class: "subagent__buttons" },
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => act.open(a) }) : null,
act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null,
),
),
)
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth))))
el.addEventListener("toggle", () => act.toggled?.(key, el.open))
return el
}
/** The tree's phase lines move on by themselves between reads (their clock). */
export function tickTree(root, now = Date.now()) {
for (const el of root.querySelectorAll(".subagent__line[data-progress]")) {
try {
el.textContent = progressLine(JSON.parse(el.dataset.progress), now)
} catch {}
}
}
+180
View File
@@ -0,0 +1,180 @@
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
// `models` scope (src-tauri/src/server.rs):
//
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
//
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, why } from "./bridge.js"
let me = null
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
/** Whether a message can be dictated here. */
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
/** Whether a reply can be read aloud here. */
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
function say(text) {
const box = $("thread-note")
if (!box) return
box.textContent = text
box.hidden = !text
}
// --- Dictate --------------------------------------------------------------------------------------
let recorder = null // the recording in progress
let busy = false // a recording being turned into words
function drawMic() {
const b = $("dictate")
if (!b) return
b.hidden = !canDictate()
const on = !!recorder
b.classList.toggle("is-recording", on)
b.classList.toggle("is-busy", busy)
b.disabled = busy
b.setAttribute("aria-pressed", String(on))
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
b.setAttribute("aria-label", label)
b.title = label
}
async function start() {
say("")
let stream
try {
await invoke("voice_arm")
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
let rec
try {
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
} catch (err) {
stream.getTracks().forEach((x) => x.stop())
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const chunks = []
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
rec.addEventListener("stop", async () => {
stream.getTracks().forEach((x) => x.stop())
recorder = null
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
if (!blob.size) return drawMic()
busy = true
drawMic()
try {
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
intoComposer(text)
} catch (err) {
say(why(err))
}
busy = false
drawMic()
})
recorder = rec
rec.start()
drawMic()
}
/** The words heard, where the cursor is in the message box, a space either side where needed. */
function intoComposer(text) {
text = String(text || "").trim()
if (!text) return
const box = $("composer-text")
const at = box.selectionStart ?? box.value.length
const end = box.selectionEnd ?? at
const before = box.value.slice(0, at)
const after = box.value.slice(end)
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
box.value = before + words + after
box.selectionStart = box.selectionEnd = before.length + words.length
box.dispatchEvent(new Event("input"))
box.focus()
}
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
export function stopDictating() {
if (recorder?.state === "recording") recorder.stop()
}
// --- Read aloud -----------------------------------------------------------------------------------
let playing = null // { id, audio, button }
/** A reply's words without the markdown around them, and without its code. */
export function plain(markdown) {
return String(markdown || "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/[*_`#>|~]/g, "")
.replace(/\s+/g, " ")
.trim()
}
function stopReading() {
if (!playing) return
playing.audio?.pause()
playing.button.setAttribute("aria-pressed", "false")
playing.button.classList.remove("is-busy")
playing = null
}
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
export function readAloudButton(id, text) {
if (!canSpeak()) return null
const label = t("Read this aloud")
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
b.addEventListener("click", async () => {
if (playing?.id === id) return stopReading()
stopReading()
const words = plain(text())
if (!words) return
const mine = { id, audio: null, button: b }
playing = mine
b.setAttribute("aria-pressed", "true")
b.classList.add("is-busy")
try {
const url = await invoke("speak", { text: words })
if (playing !== mine) return
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
mine.audio = new Audio(url)
b.classList.remove("is-busy")
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
await mine.audio.play()
} catch (err) {
if (playing === mine) stopReading()
say(why(err?.message ? { detail: err.message } : err))
}
})
return b
}
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
export function voiceFor(account) {
me = account
drawMic()
}
export function initVoice() {
const b = $("dictate")
b.replaceChildren(icon(ICONS.mic))
b.addEventListener("click", () => {
if (busy) return
if (recorder) recorder.stop()
else start()
})
drawMic()
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* LLeMbas v2.0.0-beta.7 (e3bd54f4d210) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
/* LLeMbas v2.0.0-beta.9 (3b7da5fc7ac7) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
The font-face rules are left out; the app bundles no font. */
/*
Design tokens.
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
//# sourceMappingURL=xterm.js.map
+5 -2
View File
@@ -1,6 +1,6 @@
{
"tag": "v2.0.0-beta.7",
"commit": "e3bd54f4d210cff6adb4586199caed4f41987f5e",
"tag": "v2.0.0-beta.9",
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"files": {
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
@@ -19,6 +19,9 @@
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
"xterm.js": {"server": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495", "copy": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495"},
"xterm.css": {"server": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6", "copy": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6"},
"xterm-addon-fit.js": {"server": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089", "copy": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089"},
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
}
}