Compare commits
72
Commits
v0.3.0
...
v1.0.0-beta.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189f706c65
|
||
|
|
2cdd4e5355
|
||
|
|
df27ca82fa
|
||
|
|
3101552858
|
||
|
|
9b07527c3a
|
||
|
|
2b87fa4c29
|
||
|
|
67ce6076c5
|
||
|
|
d691a4dcfa
|
||
|
|
be70e1a628
|
||
|
|
04736db057
|
||
|
|
151fc5fcca
|
||
|
|
d4a1f13cf1
|
||
|
|
effcc2c2fd
|
||
|
|
97c2301bab
|
||
|
|
94e903ef70
|
||
|
|
cf13d49ed6
|
||
|
|
4b5b22c7d4
|
||
|
|
89c769c806
|
||
|
|
a91394f71d
|
||
|
|
3baae016e1
|
||
|
|
3ef0bbaad3
|
||
|
|
ac0d4ce623
|
||
|
|
ec32bca644
|
||
|
|
5a87eec28d
|
||
|
|
10c82a3c7b
|
||
|
|
3510582c8f
|
||
|
|
ede91887f7
|
||
|
|
6bad293b29
|
||
|
|
4058b4b018
|
||
|
|
8590792b50
|
||
|
|
5ce5a62dff
|
||
|
|
9ddde6c870
|
||
|
|
a13e437823
|
||
|
|
89806982a9
|
||
|
|
6a095cb102
|
||
|
|
ad36932c9f
|
||
|
|
af14f4a229
|
||
|
|
5b00822551
|
||
|
|
1749d2369e
|
||
|
|
a12ed7ac36
|
||
|
|
b8a4f1bc57
|
||
|
|
7c0c86ada1
|
||
|
|
de914f4b16
|
||
|
|
b6d69575f7
|
||
|
|
d04bc4b6bd
|
||
|
|
844684a152
|
||
|
|
917c1e2286
|
||
|
|
46b83c3809
|
||
|
|
2a51b4f6d0
|
||
|
|
7ee394b549
|
||
|
|
2958be0a23
|
||
|
|
3b0def78ec
|
||
|
|
4c2b0d045b
|
||
|
|
5c41da8b4d
|
||
|
|
ab0c1434c4
|
||
|
|
b82d4c688a
|
||
|
|
7f54d18375
|
||
|
|
4b4e63f622
|
||
|
|
96a2abe7e0
|
||
|
|
af48856fcd
|
||
|
|
b849e1a4b8
|
||
|
|
f58f28a2a8
|
||
|
|
8f04494ba8
|
||
|
|
8062a8ac77
|
||
|
|
4a982ae089
|
||
|
|
d9e2c4a01d
|
||
|
|
e3da95247d
|
||
|
|
ae62cbf7cd
|
||
|
|
9ca03493b2
|
||
|
|
881a222f5f
|
||
|
|
5028dd7ca1
|
||
|
|
1e2fa39193
|
No files matched your search
@@ -1,9 +1,12 @@
|
||||
# Checks on every push to main and every pull request: formatting, lints, tests, and the page's
|
||||
# JavaScript. The app is built for real only by release.yml, from a tag.
|
||||
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
||||
# 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:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -23,6 +26,8 @@ jobs:
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
cache-workspaces: src-tauri
|
||||
- name: Nothing private in a public repository
|
||||
run: scripts/hygiene.sh
|
||||
- name: The bundled LLeMbas CLI, verified
|
||||
run: scripts/fetch-cli.sh
|
||||
- name: Formatting
|
||||
@@ -34,5 +39,58 @@ jobs:
|
||||
- name: Tests
|
||||
working-directory: src-tauri
|
||||
run: cargo test --locked
|
||||
- name: The first-run page's JavaScript
|
||||
run: node --check ui/app.js
|
||||
- name: The page's scripts parse, and none sets HTML
|
||||
run: scripts/check-ui.sh
|
||||
- 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
|
||||
# one) and an empty home, so it starts at its first-run page. Built with the custom protocol on,
|
||||
# as a release is, so the page is the bundled ui/ and not a development server.
|
||||
start:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: System libraries, and an X server and a Wayland compositor without a screen
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
|
||||
xvfb weston dbus libgl1-mesa-dri
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: src-tauri
|
||||
- name: The bundled LLeMbas CLI, verified
|
||||
run: scripts/fetch-cli.sh
|
||||
- name: Build (debug, with the bundled page)
|
||||
working-directory: src-tauri
|
||||
run: cargo build --locked --features tauri/custom-protocol
|
||||
- name: Starts on X11
|
||||
run: scripts/ci-start.sh x11
|
||||
- name: Starts on Wayland
|
||||
run: scripts/ci-start.sh wayland
|
||||
@@ -1,6 +1,6 @@
|
||||
# A release, from a signed tag vX.Y.Z on main: the Linux packages (.deb and AppImage) built with
|
||||
# the LLeMbas CLI inside, SHA256SUMS over them, and the GitHub Release with the tag message as its
|
||||
# notes. LLeMbas Desktop is developed on Gitea, which mirrors its commits and tags here; the builds
|
||||
# A release, from a signed tag vX.Y.Z (stable, main) or vX.Y.Z-beta.N (a beta, marked as a
|
||||
# pre-release): the Linux packages (.deb and AppImage) built with the LLeMbas CLI inside,
|
||||
# SHA256SUMS over them, and the GitHub Release with the tag message as its notes. LLeMbas Desktop is developed on Gitea, which mirrors its commits and tags here; the builds
|
||||
# happen here because they need a full Rust and WebKitGTK toolchain.
|
||||
name: release
|
||||
on:
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
tag:
|
||||
description: "The tag, e.g. v0.1.0"
|
||||
required: true
|
||||
replace:
|
||||
description: "Replace a release that already exists for this tag (people who installed it get no update)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -48,6 +52,26 @@ jobs:
|
||||
run: cargo install tauri-cli --version "^2" --locked
|
||||
- name: Build
|
||||
run: cargo tauri build --bundles deb,appimage
|
||||
# Debian sorts 1.0.0-beta.1 *above* 1.0.0 (what follows the dash is a package revision), so a
|
||||
# beta's package would make the release after it look like a downgrade, and apt would keep the
|
||||
# beta. A beta's package version says ~beta, which sorts below.
|
||||
- name: A beta's .deb sorts below its release
|
||||
run: |
|
||||
deb="$(ls src-tauri/target/release/bundle/deb/*.deb)"
|
||||
have="$(dpkg-deb -f "$deb" Version)"
|
||||
want="${VERSION/-/\~}"
|
||||
if [ "$have" != "$want" ]; then
|
||||
echo "repacking $deb: Version $have -> $want"
|
||||
work="$(mktemp -d)"
|
||||
dpkg-deb -R "$deb" "$work/pkg"
|
||||
sed -i "s/^Version: .*/Version: $want/" "$work/pkg/DEBIAN/control"
|
||||
dpkg-deb --root-owner-group -b "$work/pkg" "$deb"
|
||||
fi
|
||||
base="${VERSION%%-*}"
|
||||
if [ "$VERSION" != "$base" ]; then
|
||||
dpkg --compare-versions "$(dpkg-deb -f "$deb" Version)" lt "$base" \
|
||||
|| { echo "the beta's package does not sort below $base" >&2; exit 1; }
|
||||
fi
|
||||
- name: Collect the files and SHA256SUMS
|
||||
run: |
|
||||
mkdir dist
|
||||
@@ -62,5 +86,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' > notes.md
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
# A version is released once. Installing the same version again over a broken one is a
|
||||
# no-op for apt ("already the newest version") and the updater offers nothing, so a fix
|
||||
# is a new version -- never the old one re-released. Replacing is a deliberate act: a
|
||||
# run by hand with `replace`.
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
if [ "${{ inputs.replace || false }}" != "true" ]; then
|
||||
echo "A release for $TAG exists already. Release the fix as a new version." >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes
|
||||
fi
|
||||
pre=()
|
||||
case "$VERSION" in *-*) pre=(--prerelease) ;; esac
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag "${pre[@]}" \
|
||||
--title "LLeMbas Desktop $VERSION" --notes-file notes.md dist/*
|
||||
+465
-1
@@ -4,7 +4,471 @@ 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
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
|
||||
setting and the panel's compaction need.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subagents**, by that name everywhere. The chip under the message box says **Subagents** with the
|
||||
least and the most agents a chat runs ("Subagents 1–4"), and opens one slider with two handles,
|
||||
**Min** and **Max**, from 1 to the server's cap -- Min never passes Max, and both move with the
|
||||
arrow keys as well. The numbers count the chat's own agent: **1 is the agent alone**, with no
|
||||
subagents; Max 4 is the agent and up to 3 subagents. The chip shows where the
|
||||
server offers subagents for the chat (for a new chat, for its model), and the most is the
|
||||
server's cap. Where the chat's model serves one request at a time, it says that subagents need
|
||||
another model, on another server, named in the chat. Subagents work on the chat's own model
|
||||
unless you name another one in the chat -- there is no setting for a subagent's model. A
|
||||
scheduled task's field is **Subagents** too. Needs LLeMbas 2.0.0-beta.7 for the server's word on
|
||||
all of this; with an older one the app guesses as before.
|
||||
- **The activity panel, reshaped.** A button in the chat's header opens it at any time, and the
|
||||
line above the message box shows only while something runs ("3 running · 1 queued · 2
|
||||
finished"). An agent chat's **Environment** (its folder, and where it is) comes first. **Context**
|
||||
says how far it is to the automatic compaction ("80.6k until auto-compact") beside a **Compact**
|
||||
button that summarises the earlier turns now (greyed while there is nothing to summarise). **Background tasks** lists subagents, a team's
|
||||
members and background commands under **Running**, each with how long it has run and a stop
|
||||
square, unfolding to what it is, its latest step, what it is writing and its conversation; the
|
||||
tasks under one fold beneath it ("Tasks: 2"), and the finished ones fold away under **Finished**.
|
||||
The team's **Room** and the **Plan** have sections of their own.
|
||||
|
||||
## [1.0.0-beta.4] — 2026-11-09
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.6. Scheduled tasks need LLeMbas
|
||||
2.0.0-beta.6 or later.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An agent that could not link now gets its link.** Signing in asked the server for the agent's
|
||||
token only when the built-in LLeMbas CLI had no login for that server on file -- so a computer
|
||||
with an old login the server no longer takes got the app's own token alone, and the agent stayed
|
||||
refused. The sign-in now asks for the agent's token unless the agent's link works right now, and
|
||||
the CLI's new login replaces the old one. **Link this computer** (and **Link again**) is now its
|
||||
own approval for the agent's token, handed to the CLI the same way; before, it ran the CLI's own
|
||||
login, which for a server it was logged in to only refreshed the login it had.
|
||||
- **"Refused" means the server refused**, and says the server's reason. A service that stopped for
|
||||
want of a login says *not linked*; any other reason, *stopped* with the reason.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A new look, the shape of an AI desktop app.** A slim sidebar: **New chat**, a search line,
|
||||
**Scheduled**, **Files**, then your chats of both kinds -- agent chats and chats on the server --
|
||||
under *Today* and *Earlier*, an agent chat with its folder's name; **Settings** with your account
|
||||
at the foot. The sidebar folds away (its button in the title bar, or Ctrl+B) and is a drawer on a
|
||||
narrow window.
|
||||
- **A new chat is a chat page.** A greeting, the choice of **Agent** (on this computer) or **Chat**
|
||||
(on the server), and the message box ready -- no form. What the chat starts with sits under the
|
||||
text as small chips, each opening a menu: the folder and the mode for an agent chat, the model
|
||||
with its picture, how hard it thinks (as the model offers it), and the team's size where a team
|
||||
is offered. An open chat has the same chips, and changing one changes the chat.
|
||||
- **The conversation** in a centred column: your messages in bubbles on the right, with attached
|
||||
pictures as thumbnails and other files as tiles; replies full width; thinking and each tool call
|
||||
a compact row that unfolds. **Send** is a round button that **Stop** replaces while a reply is
|
||||
written.
|
||||
- **The activity panel** opens from a chip above the message box, on every open chat; while
|
||||
agents work, the chip says what they are doing.
|
||||
- **The agent's status** is a chip in the title bar that opens what the agent says and the one
|
||||
thing to do about it. When this computer is not linked -- or the server refused its link -- a
|
||||
new agent chat says so right above the message box, with **Link this computer** (or **Link
|
||||
again**) there, not only in Settings; while the approval waits, the code to compare is shown
|
||||
with Cancel.
|
||||
- **Files** is a page of its own, with this computer's folders and your files on the server as
|
||||
two tabs. **Settings** is grouped -- Account, Agent, Project folders, Notifications, About --
|
||||
with the groups listed beside them.
|
||||
|
||||
### Added
|
||||
|
||||
- **Scheduled tasks** (with a server that has them, LLeMbas 2.0.0-beta.6 or later): the list
|
||||
with each task's next run, how its last run went and a switch to pause it, kept up to date as
|
||||
the server runs them; a task's page with what it does, when (and in which time zone), its
|
||||
model, effort, team and how it tells you, the next runs, and its history -- each run's status,
|
||||
time, whether it was late, caught up or run by hand, the model it ran on (and that it switched
|
||||
from a busy one), and its result to unfold in place, with its chat to open; earlier runs on
|
||||
request. **Run now**, **Edit**, pause and resume, and delete. A new task is said in your own
|
||||
words: the server reads them into a rule, a title and an instruction and shows the next five
|
||||
runs before you save; the title and instruction can be changed, and where the words give no
|
||||
time the days and the time can be set by hand. Each task's settings: when to tell you
|
||||
(*when it matters*, *always*, *never*), a new chat for each run or one that grows, its model
|
||||
(or the first free one of the scheduling models), its effort and its team. **Models** on the
|
||||
list chooses which models scheduled tasks may run on, each shown free or busy.
|
||||
- **A scheduled task's result comes as a system notification** while the app runs, by the
|
||||
task's own setting: a result, never a run with nothing to report unless the task says
|
||||
*always*, nothing for *never*; a failure the first time, and on the third in a row the
|
||||
question whether to pause it. The app follows the server's event stream for this rather than
|
||||
asking every few seconds.
|
||||
|
||||
## [1.0.0-beta.3] — 2026-11-09
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.5. Needs a LLeMbas 2.0 server
|
||||
(2.0.0-beta.5 or later for the activity panel, the Thinking switch and team sizes).
|
||||
|
||||
### Added
|
||||
|
||||
- **Attachments.** A message can carry files: the attach button beside the composer (the
|
||||
system's file dialog), files dropped anywhere on the window, or an image pasted into the
|
||||
composer. Each is sent to the server at once and shown above the composer with its size, or
|
||||
with the server's reason when it refused it; one taken off before sending is deleted on the
|
||||
server. The app sends only files you chose, dropped or pasted -- the page never names a path.
|
||||
Agent chats get them too.
|
||||
- **Edit a message, regenerate a reply, copy either.** Under your messages, *Edit message*
|
||||
rewrites one in place and starts the reply again from there (everything after it is dropped,
|
||||
as the app says before you save; on the agent's device too); under the last reply,
|
||||
*Regenerate reply*. *Copy* puts a message, or a reply's text as Markdown, on the clipboard.
|
||||
- **The chat's own menu** (⋯ in its header, or a right click on it in the sidebar): rename it,
|
||||
open it in the web interface in your browser, archive it, delete it (asked first).
|
||||
- **Change a chat's model** from its header, and how hard it thinks as the model offers it: a
|
||||
model with one level shows nothing to choose; one that can switch thinking off and has one
|
||||
effort shows a **Think before answering** switch; more levels keep the selector. A new chat
|
||||
starts at the model's own start level, and the control follows the model you pick.
|
||||
- **Agents, fewest and most**, beside it where the chat can have a team (a chat on the server,
|
||||
on a model that serves at least two requests at once, with subagents on): how many agents the
|
||||
chat's team may have, its own agent included. Saved at once in the header; on a new chat,
|
||||
left empty, your default stands. A pair the server will not take is said in its words, and the
|
||||
fields go back.
|
||||
- **Model pictures**: a model the server has a picture for shows it in the pickers and under
|
||||
each reply; the others show their initial on a colour of their own -- the same colour the web
|
||||
interface gives them.
|
||||
- **The activity panel** beside a chat (the header's bolt button, the strip above the message
|
||||
box, or the palette): what is in the chat's context, by category, against the model's window;
|
||||
the files the chat and its agents wrote, with lines added and removed, each opening in the
|
||||
viewer; every background agent -- a team's members as a tree, helpers and commands -- with what
|
||||
it was asked, its state, how long it has run, its model, its latest step and what it is
|
||||
writing, its whole conversation to read and a button to stop it (a member after you confirm,
|
||||
for those under it stop with it); the team's room, who wrote to whom and which messages are
|
||||
reports; and the plan. It keeps up by itself while the chat is open. The strip above the
|
||||
message box says what the agents are doing ("2 agents working · 1 queued") and opens it. On a
|
||||
narrow window the panel covers the chat; at phone width it fills the window.
|
||||
- **Continue an agent chat in a terminal**: its menu and its header show the command that opens
|
||||
the agent's session in a terminal (`lembas -s …`, in the chat's folder), to copy. On this
|
||||
computer it names the LLeMbas CLI as it starts here -- the one on your PATH, else the app's own.
|
||||
- **A command palette** (Ctrl+K): new chats, settings, the agent and updates by name, and every
|
||||
chat of both kinds by its title (accents optional). Ctrl+N starts a new chat of the tab you are
|
||||
on; Ctrl+, opens the settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The agent's status is the CLI's own word.** The title bar and Settings no longer say
|
||||
*running* because a process runs: they say what the built-in LLeMbas CLI says of its link to
|
||||
the server -- connecting, running (linked), waiting and why (the server cannot be reached, the
|
||||
link dropped, a terminal on this computer holds the link), or refused and why. The same for a
|
||||
`lembas service` you run yourself. When the CLI says trying again will not help (the server
|
||||
refused this computer's token), the app no longer restarts it every second: it waits until you
|
||||
link the computer again. When the agent stops with an error, its last words are the reason
|
||||
shown, not just an exit status.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Czech sentence in the viewer was in Slovak; "Server files" and the composer's placeholder
|
||||
were not translated.
|
||||
|
||||
## [1.0.0-beta.2] — 2026-11-07
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.2 and the look of LLeMbas 2.0.0-beta.4.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The agent keeps what its model has read** (LLeMbas CLI 2.0.0-beta.2, harness spec 3.1.0): the
|
||||
system prompt is made once per session and sent unchanged, so a local model's server reuses the
|
||||
whole conversation instead of reading it again; the date and anything that changes during a
|
||||
session come as a short note on your next message; the tools stay the same for the session;
|
||||
compaction is the next turn of the same conversation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The editor highlights code again.** One of the editor's modules imported a file the app does
|
||||
not have, so every language that parses failed to load and files opened as plain text.
|
||||
|
||||
## [1.0.0-beta.1] — 2026-11-03
|
||||
|
||||
LLeMbas Desktop 1.0 becomes an app of its own: the agent on this computer by default, your
|
||||
server's chats, two explorers and a viewer -- no longer a window onto the web interface. It needs a
|
||||
LLeMbas 2.0 server.
|
||||
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.1. Needs a LLeMbas 2.0 server (2.0.0-beta.1 or later).
|
||||
|
||||
### Changed
|
||||
|
||||
- **The app's own interface replaces the server's web pages.** The window shows the app's page
|
||||
(`ui/`, plain HTML, CSS and JavaScript modules, no build step): the chats, the transcript with
|
||||
its steps and tool calls, the reply as it is written, the questions and approvals that wait for
|
||||
an answer, and a composer. It talks to the server's `/api/v2`.
|
||||
- **Agent chats first.** The sidebar opens on agent chats, those on this computer above those on
|
||||
your other devices; a new agent chat is started on this computer, in one of the agent's
|
||||
folders, with its mode and model. Chats on the server are the second tab.
|
||||
- **One approval signs the app in and links this computer.** The app asks the server's device
|
||||
login for its own token (your chats, as the web interface reads and writes them, and dictation)
|
||||
and, in the same approval, for the agent's; the approval opens in your browser. The agent's
|
||||
token goes straight to the built-in LLeMbas CLI on its standard input (`lembas login
|
||||
--token-stdin`), which keeps it in its own configuration as `lembas login` does; the app never
|
||||
stores it. A computer linked already keeps its link, and **Link again** under Settings is still
|
||||
there. A server that cannot give both tokens from one approval gets the old two steps.
|
||||
- The app's own token is not a device: the server lists it under *Apps*, and only the agent's
|
||||
link is among the devices an agent chat can start on.
|
||||
- The look is still the server's: its design tokens, now copied from the 2.0 server, and its
|
||||
CodeMirror build for code (`scripts/fetch-vendor.sh`, pinned in `vendor.lock.json`). The
|
||||
window takes the account's theme.
|
||||
- One window with one page: the second webview, and the Linux workaround it needed, are gone. The
|
||||
window still has the app's own title bar, and its edges resize it.
|
||||
- The page's words, old and new, are in all nine of the server's languages.
|
||||
|
||||
### Added
|
||||
|
||||
- **Two explorers, kept apart**: this computer's -- the open agent chat's folder, or one of the
|
||||
agent's folders -- read only inside the agent's folders, and the server's files, through
|
||||
`/api/v2/files` with your own access. A server file that is not text, or too large, says so in
|
||||
your language instead of failing.
|
||||
- **A viewer** beside the chat: Markdown drawn, code highlighted (read-only CodeMirror), text,
|
||||
and HTML in a sealed frame on its own address, with no access to the app, no network and no way
|
||||
to navigate. A file the agent shows (`show`) opens in it: from this computer for an agent chat
|
||||
running here, otherwise as the server keeps it (`/api/v2/chats/…/show`), checked against the
|
||||
chat's access when it is opened. A file that is gone, too large or not text says so.
|
||||
- **Native notifications** when a reply or an agent chat finishes while you are elsewhere (on by
|
||||
default; Settings, Notifications).
|
||||
- **A server older than LLeMbas 2.0 is refused** with a message saying which side to update, and so
|
||||
is a server whose app interface is newer than this app knows. What counts is the link protocol
|
||||
(3) and the app interface (`app_api` 2), not the server's version number.
|
||||
- **Settings in the app**: the server and the sign-in, the agent and its folders, notifications
|
||||
and updates. There are no admin pages.
|
||||
- When the app cannot start, it says why in `desktop.log` in its log directory, not only on a
|
||||
terminal nobody sees. A missing tray no longer stops it from starting.
|
||||
- With NVIDIA's own driver, WebKitGTK's DMA-BUF renderer is turned off before the window opens
|
||||
(unless you have set `WEBKIT_DISABLE_DMABUF_RENDERER`): that renderer leaves the window blank or
|
||||
ends the app there, on Wayland most of all.
|
||||
|
||||
### Removed
|
||||
|
||||
- The server's web interface in the window, the microphone permission it needed, and the theme
|
||||
report the window read from it. The web interface stays in the browser, and as an installable
|
||||
web app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A release can no longer be quietly replaced under the same version: installing the same version
|
||||
again does nothing, so people with the broken copy never got the fix. The release workflow now
|
||||
refuses a tag that has a release already, unless it is run by hand to replace it.
|
||||
- A beta's `.deb` sorts below the release that follows it (`1.0.0~beta.1` before `1.0.0`), so apt
|
||||
does not keep a beta over its release.
|
||||
|
||||
## [0.7.2] — 2026-10-25
|
||||
|
||||
Linking this computer again leaves one device, and LLeMbas CLI 1.5.2 inside.
|
||||
|
||||
### Changed
|
||||
|
||||
- Carries LLeMbas CLI 1.5.2. Linking this computer again no longer leaves an offline copy of it on
|
||||
the server holding its agent chats: the old link is signed out, and a LLeMbas server 1.7.2 or
|
||||
later moves its chats to the new one. When the agent cannot connect, its log (`agent.log` in the
|
||||
app's log directory) now says why -- a certificate that is not trusted, for instance -- instead
|
||||
of only "could not reach the instance".
|
||||
|
||||
## [0.7.1] — 2026-10-24
|
||||
|
||||
The microphone works, and LLeMbas CLI 1.5.1 inside.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dictation said the microphone was blocked, with no way to allow it.** The server's page asks
|
||||
for the microphone, and a webview grants it only if the app answers: WebKitGTK refuses whatever
|
||||
it is not answered on, and shows no prompt of its own. Desktop now allows the microphone -- the
|
||||
microphone only, and only for the server's page in the content view; a camera or a screen still
|
||||
gets the platform's refusal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Carries LLeMbas CLI 1.5.1: an image counts by its size, so a screenshot no longer makes the
|
||||
agent compact.
|
||||
|
||||
## [0.7.0] — 2026-10-20
|
||||
|
||||
Carries LLeMbas CLI 1.5.0: harness spec 2.0.0 — background work as jobs the person can see,
|
||||
enter and stop, and subagents that run in the background and talk back.
|
||||
|
||||
### Changed
|
||||
|
||||
- Carries LLeMbas CLI 1.5.0. The agent on this computer runs subagents in the background, keeps
|
||||
every background command and subagent as a job of its session, and reports them to a LLeMbas
|
||||
server 1.6.0, which shows them in the chat with Stop and a message box.
|
||||
|
||||
## [0.6.0] — 2026-10-20
|
||||
|
||||
Carries LLeMbas CLI 1.4.0: harness spec 1.2.0, so the agent's memory, notes and skills are the
|
||||
same tools as the server's.
|
||||
|
||||
### Changed
|
||||
|
||||
- Carries LLeMbas CLI 1.4.0. The agent on this computer and a LLeMbas server 1.5.0 now send a
|
||||
model the same library tools — `memory`, `note_view`, `note_manage`, `skills_list`,
|
||||
`skill_view`, `skill_manage` — whether the agent uses its own library or the server's.
|
||||
|
||||
## [0.5.0] — 2026-10-18
|
||||
|
||||
Carries LLeMbas CLI 1.3.0: a plan in every agent chat, the instance's instructions and
|
||||
knowledge scope on the device, Edit and Regenerate on agent chats. The app's look comes from
|
||||
the server's tokens and follows the page's theme; its own pages speak nine languages.
|
||||
|
||||
### Changed
|
||||
|
||||
- The app's own pages take their look from the server's `tokens.css`, copied by
|
||||
`scripts/fetch-tokens.sh` from a server tag and pinned in `tokens.lock.json` (the script with
|
||||
no argument verifies the copy). The hand copy had drifted: `--danger`, `--control-h` and
|
||||
`--text-xl` were not the server's. The pages name no colour, size or space of their own, and
|
||||
no font is bundled: the font rules are left out of the copy, so the title bar and the
|
||||
first-run page are set in the system's monospace face.
|
||||
- The window's frame is painted in the theme's border colour read out of `tokens.css` at build
|
||||
time (`build.rs`), not a value written in Rust, and it changes with the theme.
|
||||
- The first-run page and the title bar speak the system's language when it is one of the
|
||||
server's nine (Czech, German, Spanish, French, Hungarian, Italian, Polish, Slovak; English
|
||||
otherwise). `ui/i18n.js` is generated by `scripts/gen-i18n.py`: the sentences the server also
|
||||
has are translated as the server translates them, the app's own are in the script.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The title bar followed the desktop's light or dark setting, not the account's theme, so it
|
||||
could be dark over the parchment (Shire) page. It now follows the server page: a script in the
|
||||
content view reports the page's theme to the app, which paints the frame, the title bar and
|
||||
the first-run page in it and remembers it for the next start. The server's pages still get no
|
||||
IPC; the report is a navigation attempt the app reads and refuses.
|
||||
- The close button's hover colour was a literal white; it is the theme's inverse ink.
|
||||
|
||||
## [0.4.0] — 2026-10-14
|
||||
|
||||
Carries LLeMbas CLI 1.2.0, whose agent stays linked before any folder is allowed and follows
|
||||
changes to its folders without a restart.
|
||||
|
||||
### Added
|
||||
|
||||
- **Project folders**: Settings… (in the title bar's and the tray's menu) lists the folders the
|
||||
agent may work in, with *Add folder…* (the system's folder dialog), *Remove*, the most an agent
|
||||
chat may do there, and whether the server may open a terminal. Turning the agent on with no
|
||||
folder chosen opens this page. Changes go through the bundled CLI (`lembas remote`).
|
||||
- The server is told it is shown in LLeMbas Desktop and on which computer, so it lists this
|
||||
computer's device as **Local**, first.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The title bar's menu opens under the button, not in the middle of the window.
|
||||
- The agent no longer shows a green light while it may open no folder: the title bar says
|
||||
*agent: no folders*.
|
||||
|
||||
## [0.3.0] — 2026-10-14
|
||||
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
# LLeMbas Desktop
|
||||
|
||||
Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent on this computer.
|
||||
The [LLeMbas](https://llembas.eu) agent on this computer, and the chats on your LLeMbas server, in
|
||||
an app of its own.
|
||||
|
||||
- **The whole web UI**: chats, agent chats, the library, reports, settings. Same account, models,
|
||||
permissions and credits as in the browser; the app is a front end to the server you already run.
|
||||
- **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI)
|
||||
and runs it in the background as this computer's device, so agent chats started on the server
|
||||
can work in your projects here, without a terminal.
|
||||
- **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the
|
||||
agent, link the computer, reload, change server, quit). Close the window and the agent keeps
|
||||
running in the tray, which has the same menu.
|
||||
- **The agent, built in, by default.** LLeMbas Desktop carries the
|
||||
[LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) and runs it in the background as this
|
||||
computer's device. Agent chats work in your project folders here, without a terminal -- start
|
||||
them, follow them and answer them in the app.
|
||||
- **Your server's chats** beside them: the same account, models and permissions as in the browser.
|
||||
- **A chat app's shape**: a slim sidebar with your chats of both kinds by day, a new chat that is
|
||||
a chat page, and the chat's folder, mode, model, effort and team size as chips in the message
|
||||
box.
|
||||
- **Scheduled tasks** (with a server that has them): the list, each task's history and *Run now*,
|
||||
and a new one said in your own words, with its next runs shown before you save.
|
||||
- **Files**, two trees kept apart: the agent's folders on this computer, and your files on the
|
||||
server.
|
||||
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
||||
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
|
||||
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
|
||||
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
|
||||
- **A command palette** (Ctrl+K) for commands and every chat by its title.
|
||||
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
||||
waiting and why, refused with the server's reason -- not whether a process runs; and, when this
|
||||
computer is not linked, **Link this computer** right where you start an agent chat.
|
||||
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
|
||||
- **Its own window and tray**: close the window and the agent keeps running in the tray.
|
||||
|
||||
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
|
||||
There are no admin pages: an administrator uses the server's web interface for that.
|
||||
|
||||
> **Beta (1.0).** Needs a **LLeMbas 2.0 server** or later; an older one is refused with a message
|
||||
> saying so. Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -20,10 +38,14 @@ Download the `.deb` or the AppImage from the
|
||||
`SHA256SUMS` in the same release.
|
||||
|
||||
```sh
|
||||
sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas)
|
||||
sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu
|
||||
chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
|
||||
```
|
||||
|
||||
Installing a `.deb` of the version you already have changes nothing (apt calls it "already the
|
||||
newest version"); `sudo apt reinstall ./llembas-desktop_*_amd64.deb` installs it again. If the app
|
||||
does not start, the reason is in `~/.local/share/eu.llembas.desktop/logs/desktop.log`.
|
||||
|
||||
On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
|
||||
everything, and starting the app again brings a hidden window back.
|
||||
|
||||
@@ -33,18 +55,41 @@ LLeMbas Desktop looks for a new release when it starts and every six hours. When
|
||||
**update** button appears in the title bar (and *Install LLeMbas x.y.z…* in its menu; *Check for
|
||||
updates…* otherwise). Installing asks for your password to update the `.deb` (an AppImage updates
|
||||
itself), then the app starts again. Every package is checked against the release signing key
|
||||
built into the app before anything is installed.
|
||||
built into the app before anything is installed. Betas are not offered as updates.
|
||||
|
||||
## Use
|
||||
|
||||
1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser.
|
||||
2. From the tray, **Link this computer**. The approval page opens in the window; approve it.
|
||||
3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat.
|
||||
1. Start LLeMbas and enter your server's address. **Sign in**: the approval page opens in your
|
||||
browser, where you are signed in already; approve the code shown in the app. This one approval
|
||||
lets the app read and write your chats, as the web interface does, and links this computer as
|
||||
the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
|
||||
in its own configuration. The agent starts. (A computer whose link works keeps it; on a server
|
||||
that cannot give both from one approval, **Link this computer** -- above the message box of a
|
||||
new agent chat, in the agent's panel in the title bar, or in **Settings** -- is a second one.)
|
||||
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
|
||||
until you do.
|
||||
3. **New chat**, **Agent**: pick one of those folders, a mode and a model in the chips under the
|
||||
message box, and write.
|
||||
|
||||
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
|
||||
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
||||
(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run `lembas service` yourself, the app sees
|
||||
it and does not start a second one: they are the same device.
|
||||
(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run
|
||||
`lembas service` yourself, the app sees it and does not start a second one: they are the same
|
||||
device.
|
||||
|
||||
### What can reach what
|
||||
|
||||
- The app's page is its own (`ui/`), and it is the only content with access to the app. No page
|
||||
of the server is ever loaded into the window, and the window goes nowhere else.
|
||||
- The app's sign-in token stays in the app's Rust half (`account.json` in the app's config
|
||||
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
|
||||
holds the token.
|
||||
- The explorer reads only inside the agent's folders.
|
||||
- A file goes to the server as an attachment only when you choose it in the file dialog, drop it
|
||||
on the window or paste it: the page cannot name a path for the app to send.
|
||||
- HTML is shown in a sealed frame on an address of its own: scripts in it run, but it has no
|
||||
access to the app, no network and no way to navigate anywhere.
|
||||
- Links in a reply open in your browser, and only web and mail links.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -53,14 +98,32 @@ Needs Rust (stable) and, on Linux, WebKitGTK 4.1 and an AppIndicator library:
|
||||
```sh
|
||||
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev
|
||||
scripts/fetch-cli.sh # the bundled CLI, checked against its release signature
|
||||
scripts/fetch-tokens.sh # verifies ui/tokens.css against tokens.lock.json
|
||||
scripts/fetch-vendor.sh # verifies ui/vendor/ against vendor.lock.json
|
||||
cargo install tauri-cli --version "^2" --locked
|
||||
cargo tauri dev # or: cargo tauri build
|
||||
```
|
||||
|
||||
There is no Node and no bundler: the app's one local page is plain HTML, CSS and JavaScript in
|
||||
`ui/`. Everything else on screen is the server's own web UI. The version of the CLI it carries is
|
||||
in `lembas-cli.version`.
|
||||
There is no Node and no bundler: the app's page is plain HTML, CSS and JavaScript modules in
|
||||
`ui/`. The version of the CLI it carries is in `lembas-cli.version`.
|
||||
|
||||
Three things in `ui/` are copied from the LLeMbas server and committed, each pinned:
|
||||
|
||||
- `ui/tokens.css`, the server's design tokens: `scripts/fetch-tokens.sh <server ref>` from a
|
||||
checkout of the server beside this repository (`../LLeMbas`, or `LEMBAS_SERVER`), pinned in
|
||||
`tokens.lock.json`.
|
||||
- `ui/vendor/`, the server's vendored CodeMirror build: `scripts/fetch-vendor.sh <server ref>`
|
||||
checks every module against the server's own pin before copying it, and records both hashes in
|
||||
`vendor.lock.json`. The one change: the modules import each other by file name, since the page
|
||||
has no import map.
|
||||
- `ui/i18n.js`, the page's words in the server's languages: `scripts/gen-i18n.py`, from the
|
||||
server's catalogues and the script's own table. Run it when a sentence on the page changes.
|
||||
|
||||
`scripts/check-ui.sh` parses the page's scripts and refuses any that sets HTML from a string;
|
||||
`scripts/hygiene.sh` keeps private names out of this public repository. CI runs both, and starts
|
||||
the built app under X11 and under Wayland (`scripts/ci-start.sh`).
|
||||
|
||||
## Licence
|
||||
|
||||
MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices.
|
||||
CodeMirror and Lezer (in `ui/vendor/`) are MIT.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.0
|
||||
2.0.0-beta.7
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# The page's JavaScript parses: the app's modules and the vendored ones as ES modules (a plain
|
||||
# `node --check file.js` treats a .js file as CommonJS and passes a module with a syntax error in
|
||||
# it), the generated i18n.js as the classic script it is.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
bad=0
|
||||
for f in ui/js/*.js ui/vendor/*.mjs; do
|
||||
node --input-type=module --check < "$f" || { echo "check-ui: $f does not parse" >&2; bad=1; }
|
||||
done
|
||||
node --check ui/i18n.js || { echo "check-ui: ui/i18n.js does not parse" >&2; bad=1; }
|
||||
# Nothing the page draws is set as HTML: text goes in as text (ui/js/dom.js).
|
||||
if grep -nE '\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML|document\.write' ui/js/*.js; then
|
||||
echo "check-ui: the lines above set HTML from a string" >&2
|
||||
bad=1
|
||||
fi
|
||||
[ "$bad" -eq 0 ] && echo "check-ui: the page's scripts parse, and none sets HTML"
|
||||
exit "$bad"
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the built app on a display without a screen and check it is still running 25 seconds
|
||||
# later, with nothing in its log saying it stopped. For CI (ci.yml, job `start`):
|
||||
#
|
||||
# scripts/ci-start.sh x11 under Xvfb
|
||||
# scripts/ci-start.sh wayland under a headless Weston, GTK's Wayland backend forced
|
||||
#
|
||||
# The app starts with an empty home, so it shows its first-run page and talks to no server.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
APP="${LLEMBAS_DESKTOP_BIN:-src-tauri/target/debug/llembas-desktop}"
|
||||
[ -x "$APP" ] || { echo "ci-start: no app at $APP; build it first" >&2; exit 1; }
|
||||
how="${1:?usage: ci-start.sh x11|wayland}"
|
||||
|
||||
home="$(mktemp -d)"
|
||||
runtime="$(mktemp -d)"
|
||||
chmod 700 "$runtime"
|
||||
pids=()
|
||||
cleanup() {
|
||||
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
|
||||
# The document portal, started on the session bus, may have mounted itself (FUSE) at
|
||||
# $XDG_RUNTIME_DIR/doc; rm cannot remove a mount point, and a cleanup must not fail the check.
|
||||
fusermount3 -u "$runtime/doc" 2>/dev/null || fusermount -u "$runtime/doc" 2>/dev/null || true
|
||||
rm -rf "$home" "$runtime" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_DATA_HOME="$home/.local/share" \
|
||||
XDG_STATE_HOME="$home/.local/state" XDG_CACHE_HOME="$home/.cache" XDG_RUNTIME_DIR="$runtime"
|
||||
# No GPU on a runner: Mesa's software renderer.
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
log="$XDG_DATA_HOME/eu.llembas.desktop/logs/desktop.log"
|
||||
|
||||
case "$how" in
|
||||
x11)
|
||||
run=(xvfb-run -a -s "-screen 0 1280x840x24")
|
||||
;;
|
||||
wayland)
|
||||
weston --backend=headless-backend.so --socket=wayland-ci --idle-time=0 --use-pixman \
|
||||
> "$runtime/weston.log" 2>&1 &
|
||||
pids+=("$!")
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$runtime/wayland-ci" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
[ -S "$runtime/wayland-ci" ] || { echo "ci-start: weston did not start" >&2; cat "$runtime/weston.log" >&2; exit 1; }
|
||||
export WAYLAND_DISPLAY=wayland-ci GDK_BACKEND=wayland
|
||||
unset DISPLAY
|
||||
run=()
|
||||
;;
|
||||
*)
|
||||
echo "ci-start: x11 or wayland, not $how" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
code=0
|
||||
dbus-run-session -- "${run[@]}" timeout 25 "$APP" > "$runtime/app.log" 2>&1 || code=$?
|
||||
echo "--- the app's output ($how)"
|
||||
cat "$runtime/app.log"
|
||||
if [ -f "$log" ]; then
|
||||
echo "--- desktop.log"
|
||||
cat "$log"
|
||||
fi
|
||||
if [ "$code" -ne 124 ]; then
|
||||
echo "ci-start: on $how the app ended by itself within 25 seconds (exit $code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "$log" ] && grep -q 'stopped:\|could not start\|could not open the window' "$log"; then
|
||||
echo "ci-start: on $how the app logged a failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ci-start: on $how the app was still running after 25 seconds"
|
||||
Executable
+29
@@ -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"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Put the LLeMbas server's design tokens where the app's own pages read them from:
|
||||
# ui/tokens.css (a copy of the server's static/css/tokens.css, never edited by hand)
|
||||
#
|
||||
# scripts/fetch-tokens.sh <server-ref> copy the tokens at that ref (a tag such as v1.3.0,
|
||||
# or HEAD) and write tokens.lock.json
|
||||
# scripts/fetch-tokens.sh verify: ui/tokens.css matches tokens.lock.json
|
||||
#
|
||||
# The server is a git checkout, LLeMbas beside this repository by default (LLEMBAS_SERVER names
|
||||
# another). The one change made to the copy: the @font-face rules are left out, with the comment
|
||||
# above them. They name woff2 files the server vendors and the app does not carry; the app's pages
|
||||
# fall through the same `--font-ui` stack to the system's monospace face, and without the rules
|
||||
# the webview does not ask for two files that are not there.
|
||||
#
|
||||
# tokens.lock.json records the ref, the commit it resolved to, and the SHA-256 of ui/tokens.css
|
||||
# as written, which is what verify checks. Like fetch-cli.sh and lembas-cli.version: the file in
|
||||
# the tree says which server version the look comes from, and CI refuses a copy that drifted.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||
SOURCE="src/lembas/web/static/css/tokens.css"
|
||||
OUT="ui/tokens.css"
|
||||
LOCK="tokens.lock.json"
|
||||
|
||||
sha() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
[ -f "$LOCK" ] || { echo "fetch-tokens: no $LOCK; run with a server ref first" >&2; exit 1; }
|
||||
[ -f "$OUT" ] || { echo "fetch-tokens: no $OUT; run with a server ref first" >&2; exit 1; }
|
||||
want="$(sed -n -E 's/^ *"sha256": *"([0-9a-f]{64})".*/\1/p' "$LOCK" | head -n1)"
|
||||
ref="$(sed -n -E 's/^ *"tag": *"([^"]+)".*/\1/p' "$LOCK" | head -n1)"
|
||||
have="$(sha "$OUT")"
|
||||
if [ "$want" != "$have" ]; then
|
||||
echo "fetch-tokens: $OUT does not match $LOCK (server $ref): $have, expected $want" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "fetch-tokens: $OUT matches $LOCK (server $ref)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REF="$1"
|
||||
[ -d "$SERVER/.git" ] || [ -f "$SERVER/.git" ] \
|
||||
|| { echo "fetch-tokens: $SERVER is not a git checkout of the LLeMbas server (set LEMBAS_SERVER)" >&2; exit 1; }
|
||||
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$REF^{commit}")" \
|
||||
|| { echo "fetch-tokens: $REF is not a commit in $SERVER" >&2; exit 1; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
git -C "$SERVER" show "$commit:$SOURCE" > "$work/tokens.css" \
|
||||
|| { echo "fetch-tokens: $SOURCE is not in $SERVER at $REF" >&2; exit 1; }
|
||||
|
||||
# A comment immediately before an @font-face goes with it; then every @font-face block.
|
||||
perl -0pe 's#/\*(?:(?!\*/).)*?\*/\s*(?=\@font-face)##gs; s#\@font-face\s*\{[^}]*\}\s*##g' \
|
||||
"$work/tokens.css" > "$work/body.css"
|
||||
grep -q -- '--border:' "$work/body.css" && grep -q -- '--bg:' "$work/body.css" \
|
||||
|| { echo "fetch-tokens: $SOURCE at $REF has no --border or --bg: not the tokens file" >&2; exit 1; }
|
||||
! grep -q '@font-face' "$work/body.css" \
|
||||
|| { echo "fetch-tokens: an @font-face survived the strip; fix the script" >&2; exit 1; }
|
||||
|
||||
{
|
||||
printf '/* LLeMbas %s (%s) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.\n' "$REF" "${commit:0:12}"
|
||||
printf ' The font-face rules are left out; the app bundles no font. */\n'
|
||||
cat "$work/body.css"
|
||||
} > "$work/out.css"
|
||||
|
||||
cp "$work/out.css" "$OUT"
|
||||
printf '{\n "tag": "%s",\n "commit": "%s",\n "sha256": "%s"\n}\n' "$REF" "$commit" "$(sha "$OUT")" > "$LOCK"
|
||||
echo "fetch-tokens: LLeMbas $REF (${commit:0:12}) $SOURCE -> $OUT; $LOCK written"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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
|
||||
# scripts/fetch-vendor.sh verify: ui/vendor matches vendor.lock.json
|
||||
#
|
||||
# The server is a git checkout, LLeMbas beside this repository by default (LLEMBAS_SERVER names
|
||||
# another). Each module is checked against the server's own pin (its scripts/vendor.lock.json, at
|
||||
# the same ref) before it is copied, so what the app carries is exactly what the server serves.
|
||||
#
|
||||
# The one change made to a copy: the server loads these modules through an import map
|
||||
# ("@codemirror/view" -> its file), which the app's pages do not have -- an inline import map is a
|
||||
# script the content security policy would have to allow. So the bare names in `import`/`from`
|
||||
# are rewritten to the sibling files ("./cm-view.mjs"), and nothing else is touched. esm.sh's
|
||||
# Node polyfill ("/node/process.mjs", which @lezer/lr imports to read process.env.LOG) becomes
|
||||
# "./node-process.mjs", the server's own one-line stub (static/js/node-process.mjs), copied too:
|
||||
# an absolute path is a 404 in the app, and a failed @lezer/lr takes every editor language with it.
|
||||
#
|
||||
# vendor.lock.json records the ref, the commit it resolved to, and for every file the server's
|
||||
# SHA-256 and the copy's, which is what verify checks. Like fetch-tokens.sh: the file in the tree
|
||||
# says which server version the editor comes from, and CI refuses a copy that drifted.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||
SOURCE="src/lembas/web/static/vendor"
|
||||
SERVER_LOCK="scripts/vendor.lock.json"
|
||||
OUT="ui/vendor"
|
||||
LOCK="vendor.lock.json"
|
||||
|
||||
sha() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
[ -f "$LOCK" ] || { echo "fetch-vendor: no $LOCK; run with a server ref first" >&2; exit 1; }
|
||||
ref="$(sed -n -E 's/^ *"tag": *"([^"]+)".*/\1/p' "$LOCK" | head -n1)"
|
||||
bad=0
|
||||
count=0
|
||||
# One file a line: "name": {"server": "…", "copy": "…"}
|
||||
while IFS=$'\t' read -r name want; do
|
||||
count=$((count + 1))
|
||||
if [ ! -f "$OUT/$name" ]; then
|
||||
echo "fetch-vendor: $OUT/$name is missing" >&2
|
||||
bad=1
|
||||
elif [ "$(sha "$OUT/$name")" != "$want" ]; 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|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; }
|
||||
[ "$bad" -eq 0 ] || exit 1
|
||||
echo "fetch-vendor: $count files in $OUT match $LOCK (server $ref)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REF="$1"
|
||||
[ -d "$SERVER/.git" ] || [ -f "$SERVER/.git" ] \
|
||||
|| { echo "fetch-vendor: $SERVER is not a git checkout of the LLeMbas server (set LEMBAS_SERVER)" >&2; exit 1; }
|
||||
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$REF^{commit}")" \
|
||||
|| { echo "fetch-vendor: $REF is not a commit in $SERVER" >&2; exit 1; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
git -C "$SERVER" show "$commit:$SERVER_LOCK" > "$work/server-lock.json" \
|
||||
|| { echo "fetch-vendor: $SERVER_LOCK is not in $SERVER at $REF" >&2; exit 1; }
|
||||
names="$(git -C "$SERVER" ls-tree --name-only "$commit" "$SOURCE/" | sed -n -E 's#^.*/((cm|lezer)-[a-z0-9-]+\.mjs)$#\1#p' | sort)"
|
||||
[ -n "$names" ] || { echo "fetch-vendor: no CodeMirror modules in $SOURCE at $REF" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$work/out"
|
||||
entries=()
|
||||
for name in $names; do
|
||||
git -C "$SERVER" show "$commit:$SOURCE/$name" > "$work/$name"
|
||||
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/$name")"
|
||||
[ -n "$pinned" ] || { echo "fetch-vendor: $name is not pinned in the server's $SERVER_LOCK" >&2; exit 1; }
|
||||
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
|
||||
# "@codemirror/view" -> "./cm-view.mjs", "@lezer/lr" -> "./lezer-lr.mjs", "codemirror" -> "./cm-codemirror.mjs".
|
||||
perl -pe 's#\b(from|import)(\s*)"\@codemirror/([a-z-]+)"#$1$2"./cm-$3.mjs"#g; s#\b(from|import)(\s*)"\@lezer/([a-z-]+)"#$1$2"./lezer-$3.mjs"#g; s#\b(from|import)(\s*)"codemirror"#$1$2"./cm-codemirror.mjs"#g; s#\b(from|import)(\s*)"/node/process\.mjs"#$1$2"./node-process.mjs"#g' \
|
||||
"$work/$name" > "$work/out/$name"
|
||||
if grep -E -q '(from|import)\s*"((@codemirror|@lezer)/|/)' "$work/out/$name"; then
|
||||
echo "fetch-vendor: a bare module name or an absolute path survived in $name; fix the script" >&2
|
||||
exit 1
|
||||
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" \
|
||||
|| { echo "fetch-vendor: $stub is not in $SERVER at $REF" >&2; exit 1; }
|
||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' node-process.mjs "$(sha "$work/out/node-process.mjs")" "$(sha "$work/out/node-process.mjs")")")
|
||||
# Every module a copy imports must be one of the copies.
|
||||
for f in "$work"/out/*.mjs; do
|
||||
while read -r dep; do
|
||||
[ -f "$work/out/$dep" ] || { echo "fetch-vendor: $(basename "$f") imports $dep, which is not copied" >&2; exit 1; }
|
||||
done < <(grep -o -E '(from|import)\s*"\./[a-z0-9-]+\.mjs"' "$f" | sed -E 's/.*"\.\/([^"]+)"/\1/' | sort -u)
|
||||
done
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$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))
|
||||
for i in "${!entries[@]}"; do
|
||||
if [ "$i" -lt "$last" ]; then printf '%s,\n' "${entries[$i]}"; else printf '%s\n' "${entries[$i]}"; fi
|
||||
done
|
||||
printf ' }\n}\n'
|
||||
} > "$LOCK"
|
||||
echo "fetch-vendor: LLeMbas $REF (${commit:0:12}) $SOURCE -> $OUT (${#entries[@]} files); $LOCK written"
|
||||
Executable
+2881
File diff suppressed because it is too large.
Load diff
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# The repository is public: nothing in it may name the private estate it was written on.
|
||||
# The LLeMbas section rule ("everything public is current-state and estate-free"), as a check
|
||||
# that runs in CI on every push rather than a sentence somebody has to remember.
|
||||
#
|
||||
# Patterns are the kinds of thing that leak: a private domain, an internal `.lan` name, a
|
||||
# container number, a private address range, a machine's name. This file is the one place that
|
||||
# may spell them, and is skipped.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
patterns=(
|
||||
'houmeres'
|
||||
'\b[a-z0-9-]+\.lan\b'
|
||||
'\bCT ?2[0-9]{2}\b'
|
||||
'\b10\.0\.0\.[0-9]+'
|
||||
'\b192\.168\.[0-9]+\.[0-9]+'
|
||||
'\bgamebox\b'
|
||||
'\bthor\b'
|
||||
)
|
||||
found=0
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if git ls-files -z | grep -zv '^scripts/hygiene.sh$' | xargs -0 grep -nIEi -- "$pattern" ; then
|
||||
echo "hygiene: the lines above match /$pattern/" >&2
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
[ "$found" -eq 0 ] && echo "hygiene: nothing private in the tracked files"
|
||||
exit "$found"
|
||||
Generated
+185
-6
@@ -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"
|
||||
@@ -469,6 +478,17 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -500,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"
|
||||
@@ -569,6 +595,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -603,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"
|
||||
@@ -666,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"
|
||||
@@ -755,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]]
|
||||
@@ -1296,6 +1357,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1531,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"
|
||||
@@ -2064,19 +2135,21 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "llembas-desktop"
|
||||
version = "0.3.0"
|
||||
version = "1.0.0-beta.7"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tungstenite",
|
||||
"ureq",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2094,6 +2167,20 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2220,6 +2307,20 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2842,6 +2943,23 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3422,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"
|
||||
@@ -3429,8 +3558,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3925,6 +4054,26 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"url",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.7.1"
|
||||
@@ -4097,6 +4246,17 @@ dependencies = [
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -4442,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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "llembas-desktop"
|
||||
version = "0.3.0"
|
||||
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
|
||||
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"
|
||||
repository = "https://github.com/LLeMbas/LLeMbas-Desktop"
|
||||
@@ -17,21 +17,25 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "unstable"] }
|
||||
tauri = { version = "2.12", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
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 window's GTK box: Tauri packs every webview into it with an equal share on Linux.
|
||||
gtk = "0.18"
|
||||
# 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
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
//! Besides Tauri's own build step: the two themes' page colour, which the window itself paints
|
||||
//! before the page has (so a dark theme does not start white), read out of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into
|
||||
//! `$OUT_DIR/tokens.rs`, so no colour is written in Rust. `lib.rs` includes that file.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// The themes the app knows, by the name `tokens.css` selects them with (`data-theme`), and the
|
||||
/// Rust names the constants get.
|
||||
const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")];
|
||||
/// The tokens taken from each theme, by the custom property and the constant's suffix.
|
||||
const TOKENS: [(&str, &str); 1] = [("--bg", "BG")];
|
||||
|
||||
fn main() {
|
||||
let tokens = Path::new("../ui/tokens.css");
|
||||
println!("cargo:rerun-if-changed={}", tokens.display());
|
||||
let css = fs::read_to_string(tokens)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}; run scripts/fetch-tokens.sh", tokens.display()));
|
||||
let mut out = String::from(
|
||||
"// Generated by build.rs from ui/tokens.css: the colours the window paints itself.\n",
|
||||
);
|
||||
for (theme, name) in THEMES {
|
||||
let block = theme_block(&css, theme).unwrap_or_else(|| {
|
||||
panic!("ui/tokens.css has no `:root[data-theme=\"{theme}\"]` block")
|
||||
});
|
||||
for (token, suffix) in TOKENS {
|
||||
let (r, g, b) = hex_token(block, token).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"ui/tokens.css: `{token}` of the {theme} theme is not a six-digit hex colour"
|
||||
)
|
||||
});
|
||||
out.push_str(&format!(
|
||||
"pub const {name}_{suffix}: (u8, u8, u8) = (0x{r:02X}, 0x{g:02X}, 0x{b:02X});\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs");
|
||||
fs::write(&dest, out).expect("write tokens.rs");
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
/// The declarations of the theme's block: from the selector naming it (at the start of a line,
|
||||
/// so a mention in a comment does not count) to the block's closing brace at the start of a
|
||||
/// line, which is the file's own layout, one block per theme.
|
||||
fn theme_block<'a>(css: &'a str, theme: &str) -> Option<&'a str> {
|
||||
let selector = format!("\n:root[data-theme=\"{theme}\"]");
|
||||
let at = css.find(&selector)?;
|
||||
let open = at + css[at..].find('{')?;
|
||||
let close = open + css[open..].find("\n}")?;
|
||||
Some(&css[open + 1..close])
|
||||
}
|
||||
|
||||
/// `--token: #RRGGBB;` in the block, as bytes.
|
||||
fn hex_token(block: &str, token: &str) -> Option<(u8, u8, u8)> {
|
||||
let value = block
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find_map(|l| {
|
||||
l.strip_prefix(token)
|
||||
.and_then(|r| r.trim_start().strip_prefix(':'))
|
||||
})?
|
||||
.trim()
|
||||
.trim_end_matches(';')
|
||||
.trim();
|
||||
let hex = value.strip_prefix('#')?;
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||
Some((byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).",
|
||||
"windows": ["main"],
|
||||
"description": "The app's own page (ui/), the only content with the bridge. It names the main window and no remote URL: no server page is ever loaded, and HTML the viewer shows is a sealed frame on its own scheme, which no capability names. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
|
||||
+277
-12
@@ -4,6 +4,12 @@
|
||||
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
|
||||
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
|
||||
//! progress ends cleanly) when the agent is switched off or the app quits.
|
||||
//!
|
||||
//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While
|
||||
//! it runs, the state is what the service writes to its `service.json` (connecting, linked,
|
||||
//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last
|
||||
//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every
|
||||
//! second: it waits until linking again starts it.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
@@ -20,6 +26,8 @@ use crate::cli;
|
||||
const RESTART_FOR_UPDATE: i32 = 75;
|
||||
const BACKOFF: Duration = Duration::from_secs(10);
|
||||
const STOP_GRACE: Duration = Duration::from_secs(15);
|
||||
/// How long to wait before trying again after the CLI said trying again will not help.
|
||||
const REFUSED_WAIT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||
@@ -28,25 +36,110 @@ pub enum State {
|
||||
Off,
|
||||
/// This computer is not signed in to the server yet.
|
||||
NotLinked,
|
||||
/// The process is started; the CLI has not said anything of its link yet.
|
||||
Starting,
|
||||
/// The CLI is dialling the server.
|
||||
Connecting,
|
||||
/// The CLI says it is linked: agent chats can use this computer.
|
||||
Running,
|
||||
/// Another `lembas service` on this computer holds the link (its pid).
|
||||
Elsewhere(u32),
|
||||
/// It ended with an error; trying again shortly.
|
||||
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||
/// the server cannot be reached, the link dropped).
|
||||
Waiting(String),
|
||||
/// The server refused this computer's link, in its words (a token it no longer takes, or a
|
||||
/// protocol it does not speak). The CLI will not try again by itself; linking again is the
|
||||
/// way out. Only the server's refusal: a missing login is `NotLinked`.
|
||||
Refused(String),
|
||||
/// Another `lembas service` on this computer holds the link.
|
||||
Elsewhere(Elsewhere),
|
||||
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A `lembas service` this app did not start, and what it says of its link.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Elsewhere {
|
||||
pub pid: u32,
|
||||
/// It says it is linked.
|
||||
pub linked: bool,
|
||||
/// Why not, when it is not (its own words; empty while it has said nothing).
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
State::Off => "Agent: off".into(),
|
||||
State::NotLinked => "Agent: this computer is not linked".into(),
|
||||
State::Starting => "Agent: starting…".into(),
|
||||
State::Connecting => "Agent: connecting…".into(),
|
||||
State::Running => "Agent: running".into(),
|
||||
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"),
|
||||
State::Waiting(why) => format!("Agent: waiting — {why}"),
|
||||
State::Refused(why) => format!("Agent: refused — {why}"),
|
||||
State::Elsewhere(e) if e.linked => {
|
||||
format!("Agent: running as a service (pid {})", e.pid)
|
||||
}
|
||||
State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid),
|
||||
State::Failed(why) => format!("Agent: stopped — {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a state the CLI's own status file decides, while the process runs.
|
||||
fn told_by_cli(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
State::Starting | State::Connecting | State::Running | State::Waiting(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
|
||||
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
|
||||
/// every try, `waiting` between them) instead of flickering between the two.
|
||||
pub fn from_link(link: &cli::Link, before: &State) -> State {
|
||||
let why = link.detail.clone().unwrap_or_default();
|
||||
match link.state.as_str() {
|
||||
"linked" => State::Running,
|
||||
"connecting" => match before {
|
||||
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
|
||||
_ => State::Connecting,
|
||||
},
|
||||
"waiting" => State::Waiting(why),
|
||||
"stopped" => State::Refused(why),
|
||||
_ => State::Starting,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status
|
||||
/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any
|
||||
/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which
|
||||
/// is the server's word only. None when it said nothing.
|
||||
fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option<State> {
|
||||
if let Some(link) = stopped {
|
||||
let why = link.detail.clone().unwrap_or_default();
|
||||
// The CLI's advice to its terminal user ("sign in again: lembas login") is the app's
|
||||
// Link button here.
|
||||
let why = why
|
||||
.split(" — sign in again")
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !why.is_empty() && why != "stopped" {
|
||||
return Some(State::Refused(why));
|
||||
}
|
||||
}
|
||||
let said = strip_prefix(said?);
|
||||
if said.starts_with("not logged in") {
|
||||
return Some(State::NotLinked);
|
||||
}
|
||||
Some(State::Failed(said))
|
||||
}
|
||||
|
||||
/// What a running process has said before its status file says anything: the one line the
|
||||
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||
fn from_output(line: &str) -> Option<State> {
|
||||
line.contains("holds the link")
|
||||
.then(|| State::Waiting(line.trim().to_string()))
|
||||
}
|
||||
|
||||
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
||||
@@ -124,15 +217,33 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
|
||||
if !self.set(generation, State::Elsewhere(pid)) {
|
||||
// Not ours, but this computer's agent all the same: say what it says.
|
||||
let link = cli::link_of(pid);
|
||||
let linked = link.as_ref().is_some_and(|l| l.state == "linked");
|
||||
let why = link
|
||||
.map(|l| match l.detail {
|
||||
Some(d) if !d.is_empty() => d,
|
||||
_ => l.state,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !self.set(
|
||||
generation,
|
||||
State::Elsewhere(Elsewhere {
|
||||
pid,
|
||||
linked,
|
||||
why: if linked { String::new() } else { why },
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
self.pause(generation, Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
if !self.set(generation, State::Starting) {
|
||||
return;
|
||||
}
|
||||
// Where this run's output starts in the log: its last line is its reason.
|
||||
let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0);
|
||||
let child = match self.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -146,6 +257,7 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pid = child.id();
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
@@ -155,23 +267,36 @@ impl Agent {
|
||||
}
|
||||
g.child = Some(child);
|
||||
}
|
||||
self.set(generation, State::Running);
|
||||
let code = self.wait_child(generation);
|
||||
let code = self.wait_child(generation, pid, from);
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return;
|
||||
}
|
||||
g.child = None;
|
||||
drop(g);
|
||||
let said = last_line(&self.log, from);
|
||||
match code {
|
||||
Some(RESTART_FOR_UPDATE) => continue,
|
||||
Some(0) => {
|
||||
// It ended by itself without an error: nothing to do but start it again.
|
||||
self.pause(generation, Duration::from_secs(1));
|
||||
// It ended by itself without an error: the CLI's word for "starting again
|
||||
// will not help". Waiting a long while (linking again starts it at once).
|
||||
let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
|
||||
let next = ended_cleanly(stopped.as_ref(), said.as_deref());
|
||||
match next {
|
||||
Some(state) => {
|
||||
if !self.set(generation, state) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, REFUSED_WAIT);
|
||||
}
|
||||
None => self.pause(generation, Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let why = other.map_or("ended by a signal".to_string(), |c| {
|
||||
let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
|
||||
other.map_or("ended by a signal".to_string(), |c| {
|
||||
format!("exit status {c}")
|
||||
})
|
||||
});
|
||||
if !self.set(generation, State::Failed(why)) {
|
||||
return;
|
||||
@@ -217,7 +342,11 @@ impl Agent {
|
||||
}
|
||||
|
||||
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
|
||||
fn wait_child(&self, generation: u64) -> Option<i32> {
|
||||
/// Meanwhile the state shown is what the CLI says of its link: its status file once it has
|
||||
/// written one, before that the line it printed (a terminal holding the link).
|
||||
fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option<i32> {
|
||||
// Every other poll (once a second) the status is read again.
|
||||
let mut read = true;
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
@@ -230,6 +359,21 @@ impl Agent {
|
||||
Some(Err(_)) | None => return None,
|
||||
}
|
||||
}
|
||||
if read {
|
||||
let now = self.state();
|
||||
if now.told_by_cli() {
|
||||
let next = match cli::link_of(pid) {
|
||||
Some(link) => from_link(&link, &now),
|
||||
None => last_line(&self.log, from)
|
||||
.and_then(|l| from_output(&l))
|
||||
.unwrap_or(State::Starting),
|
||||
};
|
||||
if !self.set(generation, next) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
read = !read;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -283,3 +427,124 @@ fn terminate(mut child: Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
|
||||
/// the file is read.
|
||||
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = File::open(log).ok()?;
|
||||
let len = f.metadata().ok()?.len();
|
||||
let start = from.max(len.saturating_sub(4096));
|
||||
if start >= len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(start)).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(len - start).read_to_end(&mut buf).ok()?;
|
||||
String::from_utf8_lossy(&buf)
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
|
||||
fn strip_prefix(line: &str) -> String {
|
||||
line.strip_prefix("lembas service: ")
|
||||
.unwrap_or(line)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refused_is_only_the_servers_word() {
|
||||
let refused = link(
|
||||
"stopped",
|
||||
Some("not allowed to link — sign in again: lembas login"),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
ended_cleanly(Some(&refused), None),
|
||||
Some(State::Refused("not allowed to link".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
ended_cleanly(
|
||||
None,
|
||||
Some("lembas service: not logged in to any LLeMbas instance — lembas login <address>")
|
||||
),
|
||||
Some(State::NotLinked)
|
||||
);
|
||||
assert_eq!(
|
||||
ended_cleanly(None, Some("lembas service: logged in to several instances")),
|
||||
Some(State::Failed("logged in to several instances".into()))
|
||||
);
|
||||
assert_eq!(ended_cleanly(None, None), None);
|
||||
}
|
||||
|
||||
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||
cli::Link {
|
||||
state: state.into(),
|
||||
detail: detail.map(String::from),
|
||||
pid: 1,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_shown_is_the_clis_word_for_its_link() {
|
||||
let s = State::Starting;
|
||||
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
|
||||
assert_eq!(
|
||||
from_link(&link("connecting", None, 1), &s),
|
||||
State::Connecting
|
||||
);
|
||||
let waiting = from_link(
|
||||
&link("waiting", Some("could not reach the instance"), 1),
|
||||
&s,
|
||||
);
|
||||
assert_eq!(
|
||||
waiting,
|
||||
State::Waiting("could not reach the instance".into())
|
||||
);
|
||||
// The next dial keeps the reason it is waiting, rather than flicker.
|
||||
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
|
||||
assert_eq!(
|
||||
from_link(&link("stopped", Some("refused"), 2), &waiting),
|
||||
State::Refused("refused".into())
|
||||
);
|
||||
// Not running is never shown as running.
|
||||
for state in ["connecting", "waiting", "stopped", "?"] {
|
||||
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_holding_the_link_is_said() {
|
||||
assert_eq!(
|
||||
from_output("a terminal here holds the link (/remote); waiting for it to close"),
|
||||
Some(State::Waiting(
|
||||
"a terminal here holds the link (/remote); waiting for it to close".into()
|
||||
))
|
||||
);
|
||||
assert_eq!(from_output("linked"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_line_is_this_runs_own() {
|
||||
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
|
||||
std::fs::write(&p, "old run\nlinked\n").unwrap();
|
||||
let from = std::fs::metadata(&p).unwrap().len();
|
||||
assert_eq!(last_line(&p, from), None);
|
||||
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
|
||||
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
|
||||
.unwrap();
|
||||
let said = last_line(&p, from).unwrap();
|
||||
assert_eq!(said, "lembas service: not logged in");
|
||||
assert_eq!(strip_prefix(&said), "not logged in");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
|
||||
@@ -40,6 +42,37 @@ pub fn binary() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from(exe_name()))
|
||||
}
|
||||
|
||||
/// How a person starts the CLI in a terminal on this computer: `lembas` where one is on PATH
|
||||
/// (installed by hand: it shares the app's sessions, the directories are the same), else the
|
||||
/// bundled one by its full path, quoted for a POSIX shell. The bundled copy is never on PATH.
|
||||
pub fn terminal_word() -> String {
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
on_path(&path, exe_name()).map_or_else(
|
||||
|| shell_quote(&binary().to_string_lossy()),
|
||||
|_| exe_name().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn on_path(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
|
||||
std::env::split_paths(path)
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// A word for a POSIX shell: as it is when it is plainly safe, else in single quotes.
|
||||
pub fn shell_quote(word: &str) -> String {
|
||||
let plain = !word.is_empty()
|
||||
&& word.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '/' | '.' | '-' | '_' | '+' | ':' | '@' | '%' | '=')
|
||||
});
|
||||
if plain {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("'{}'", word.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
@@ -108,6 +141,51 @@ fn running_in(state: &Path) -> Option<u32> {
|
||||
(pid != 0 && alive(pid)).then_some(pid)
|
||||
}
|
||||
|
||||
/// What a running `lembas service run` says of its link to the server, as it writes it to
|
||||
/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting`
|
||||
/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again;
|
||||
/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the
|
||||
/// process that wrote it: a file left by an earlier run is not this one's word.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Link {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pid: u32,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
/// The link status the CLI's service wrote last, if it was `pid` that wrote it.
|
||||
pub fn link_of(pid: u32) -> Option<Link> {
|
||||
link_in(&state_dir()).filter(|l| l.pid == pid)
|
||||
}
|
||||
|
||||
fn link_in(state: &Path) -> Option<Link> {
|
||||
let text = std::fs::read_to_string(state.join("service.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// This computer's host name: what the CLI sent the server as `device_host` at login
|
||||
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
|
||||
#[cfg(unix)]
|
||||
pub fn hostname() -> Option<String> {
|
||||
let mut buf = [0u8; 256];
|
||||
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
|
||||
if r != 0 {
|
||||
return None;
|
||||
}
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn hostname() -> Option<String> {
|
||||
std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn alive(pid: u32) -> bool {
|
||||
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
|
||||
@@ -132,6 +210,26 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_for_the_shell_is_quoted_only_when_it_must_be() {
|
||||
assert_eq!(
|
||||
shell_quote("/usr/lib/llembas-desktop/lembas"),
|
||||
"/usr/lib/llembas-desktop/lembas"
|
||||
);
|
||||
assert_eq!(shell_quote("/home/a b/lembas"), "'/home/a b/lembas'");
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cli_on_path_is_found_by_name() {
|
||||
let d = scratch("path");
|
||||
assert_eq!(on_path(d.as_os_str(), "lembas"), None);
|
||||
fs::write(d.join("lembas"), b"").unwrap();
|
||||
assert_eq!(on_path(d.as_os_str(), "lembas"), Some(d.join("lembas")));
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_reads_the_logins_the_cli_wrote() {
|
||||
let d = scratch("linked");
|
||||
@@ -147,6 +245,33 @@ mod tests {
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_status_is_read_as_the_service_writes_it() {
|
||||
let d = scratch("link");
|
||||
assert_eq!(link_in(&d), None);
|
||||
// As `lembas service run` writes it (src/service.ts): LinkStatus and its pid.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let l = link_in(&d).unwrap();
|
||||
assert_eq!(l.state, "waiting");
|
||||
assert_eq!(l.pid, 4242);
|
||||
assert_eq!(l.attempts, 3);
|
||||
assert!(l.detail.unwrap().starts_with("could not reach"));
|
||||
// `linked` carries no detail.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link_in(&d).unwrap().detail, None);
|
||||
fs::write(d.join("service.json"), "not json").unwrap();
|
||||
assert_eq!(link_in(&d), None);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_counts_only_while_its_process_lives() {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The agent's working directory, for the explorer and the viewer: list a folder, read a file.
|
||||
//!
|
||||
//! Only inside the folders the agent may work in (`lembas remote` roots, as the CLI last said):
|
||||
//! the page names a folder and a path inside it, and both are resolved here -- symbolic links and
|
||||
//! `..` included -- before anything is opened. A path that ends up outside every root is refused,
|
||||
//! whatever the page asked. Reading only: the app never writes in a project, the agent does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// The most of a folder listed at once.
|
||||
pub const MAX_ENTRIES: usize = 5000;
|
||||
/// The most of a file read for the viewer; a bigger one is offered by name only.
|
||||
pub const MAX_READ: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
/// From the folder the page named, with `/` between parts.
|
||||
pub path: String,
|
||||
/// `dir` or `file` (a link counts as what it points to; a broken one as a file).
|
||||
pub kind: &'static str,
|
||||
pub size: u64,
|
||||
/// Seconds since 1970, when the system says.
|
||||
pub modified: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Listing {
|
||||
pub root: String,
|
||||
pub path: String,
|
||||
pub entries: Vec<Entry>,
|
||||
/// More entries than `MAX_ENTRIES`: the rest are not listed.
|
||||
pub more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Opened {
|
||||
pub root: String,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
/// The text, when the file is text and small enough.
|
||||
pub text: Option<String>,
|
||||
/// Why there is no text: `binary` or `too_big`.
|
||||
pub withheld: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// `root` (a folder the agent may use, or one inside it) and `path` inside it, resolved, and
|
||||
/// refused unless the result is inside one of `roots`. The canonical root and target.
|
||||
pub fn resolve(roots: &[String], root: &str, path: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
let base = fs::canonicalize(root).map_err(|e| format!("{root}: {e}"))?;
|
||||
let allowed = roots
|
||||
.iter()
|
||||
.filter_map(|r| fs::canonicalize(r).ok())
|
||||
.any(|r| base.starts_with(&r));
|
||||
if !allowed {
|
||||
return Err(format!(
|
||||
"{root} is not a folder the agent may work in. Add it under Settings, Project folders."
|
||||
));
|
||||
}
|
||||
let rel = Path::new(path.trim_start_matches('/'));
|
||||
if rel.components().any(|c| matches!(c, Component::Prefix(_))) {
|
||||
return Err(format!("Not a path inside {root}: {path}"));
|
||||
}
|
||||
let target = fs::canonicalize(base.join(rel)).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !target.starts_with(&base) {
|
||||
return Err(format!("{path} is outside {root}."));
|
||||
}
|
||||
Ok((base, target))
|
||||
}
|
||||
|
||||
/// `target` from `base`, with `/` between parts ("" for the base itself).
|
||||
fn relative(base: &Path, target: &Path) -> String {
|
||||
target
|
||||
.strip_prefix(base)
|
||||
.map(|p| {
|
||||
p.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn list(roots: &[String], root: &str, path: &str) -> Result<Listing, String> {
|
||||
let (base, dir) = resolve(roots, root, path)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{path} is not a folder."));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut more = false;
|
||||
for item in fs::read_dir(&dir).map_err(|e| format!("{path}: {e}"))? {
|
||||
let Ok(item) = item else { continue };
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
let name = item.file_name().to_string_lossy().into_owned();
|
||||
// A link is shown as what it points to; following it is `resolve`'s to allow or not.
|
||||
let meta = fs::metadata(item.path()).or_else(|_| item.metadata());
|
||||
let (kind, size, modified) = match meta {
|
||||
Ok(m) => (
|
||||
if m.is_dir() { "dir" } else { "file" },
|
||||
if m.is_dir() { 0 } else { m.len() },
|
||||
m.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs()),
|
||||
),
|
||||
Err(_) => ("file", 0, None),
|
||||
};
|
||||
let at = relative(&base, &dir);
|
||||
entries.push(Entry {
|
||||
path: if at.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{at}/{name}")
|
||||
},
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
(a.kind != "dir")
|
||||
.cmp(&(b.kind != "dir"))
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
Ok(Listing {
|
||||
root: base.to_string_lossy().into_owned(),
|
||||
path: relative(&base, &dir),
|
||||
entries,
|
||||
more,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text, as the CLI's `show` decides it: no NUL byte, and valid UTF-8.
|
||||
fn as_text(data: Vec<u8>) -> Option<String> {
|
||||
if data.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(data).ok()
|
||||
}
|
||||
|
||||
pub fn read(roots: &[String], root: &str, path: &str) -> Result<Opened, String> {
|
||||
let (base, file) = resolve(roots, root, path)?;
|
||||
let meta = fs::metadata(&file).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path} is not a file."));
|
||||
}
|
||||
let name = file
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let mut opened = Opened {
|
||||
root: base.to_string_lossy().into_owned(),
|
||||
path: relative(&base, &file),
|
||||
name,
|
||||
size: meta.len(),
|
||||
text: None,
|
||||
withheld: None,
|
||||
};
|
||||
if meta.len() > MAX_READ {
|
||||
opened.withheld = Some("too_big");
|
||||
return Ok(opened);
|
||||
}
|
||||
let data = fs::read(&file).map_err(|e| format!("{path}: {e}"))?;
|
||||
match as_text(data) {
|
||||
Some(t) => opened.text = Some(t),
|
||||
None => opened.withheld = Some("binary"),
|
||||
}
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("llembas-files-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(d.join("project/src")).unwrap();
|
||||
fs::create_dir_all(d.join("outside")).unwrap();
|
||||
fs::write(d.join("project/README.md"), "# Waybread\n").unwrap();
|
||||
fs::write(d.join("project/src/main.rs"), "fn main() {}\n").unwrap();
|
||||
fs::write(d.join("project/blob.bin"), [0u8, 1, 2]).unwrap();
|
||||
fs::write(d.join("outside/secret.txt"), "no").unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn roots(d: &Path) -> Vec<String> {
|
||||
vec![d.join("project").to_string_lossy().into_owned()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_lists_folders_first() {
|
||||
let d = scratch("list");
|
||||
let root = d.join("project").to_string_lossy().into_owned();
|
||||
let l = list(&roots(&d), &root, "").unwrap();
|
||||
let names: Vec<_> = l.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["src", "blob.bin", "README.md"]);
|
||||
assert_eq!(l.entries[0].kind, "dir");
|
||||
let inner = list(&roots(&d), &root, "src").unwrap();
|
||||
assert_eq!(inner.entries[0].path, "src/main.rs");
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_outside_the_roots_is_reached() {
|
||||
let d = scratch("outside");
|
||||
let root = d.join("project").to_string_lossy().into_owned();
|
||||
let other = d.join("outside").to_string_lossy().into_owned();
|
||||
assert!(list(&roots(&d), &other, "").is_err());
|
||||
assert!(read(&roots(&d), &root, "../outside/secret.txt").is_err());
|
||||
assert!(read(&roots(&d), &root, "/../outside/secret.txt").is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(d.join("outside"), d.join("project/escape")).unwrap();
|
||||
assert!(read(&roots(&d), &root, "escape/secret.txt").is_err());
|
||||
// A folder inside a root is a root of its own for the pane (an agent chat's folder).
|
||||
let sub = d.join("project/src").to_string_lossy().into_owned();
|
||||
assert!(list(&roots(&d), &sub, "").is_ok());
|
||||
}
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_read_and_the_rest_is_withheld() {
|
||||
let d = scratch("read");
|
||||
let root = d.join("project").to_string_lossy().into_owned();
|
||||
let md = read(&roots(&d), &root, "README.md").unwrap();
|
||||
assert_eq!(md.text.as_deref(), Some("# Waybread\n"));
|
||||
assert_eq!(md.path, "README.md");
|
||||
let bin = read(&roots(&d), &root, "blob.bin").unwrap();
|
||||
assert_eq!(bin.text, None);
|
||||
assert_eq!(bin.withheld, Some("binary"));
|
||||
assert!(read(&roots(&d), &root, "src").is_err());
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
}
|
||||
+1650
-279
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,77 @@
|
||||
//! Which folders the agent may work in: the CLI's `remote` settings, read and changed through
|
||||
//! `lembas remote … --json` so the app never edits the CLI's files itself. A freshly linked
|
||||
//! computer allows no folder at all (remote control is off and `roots` is empty), so the agent
|
||||
//! is connected but every agent chat on it is refused until at least one folder is added.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cli;
|
||||
|
||||
/// `lembas remote list --json`, and what every changing command answers with.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Remote {
|
||||
pub enabled: bool,
|
||||
pub roots: Vec<String>,
|
||||
pub max_mode: String,
|
||||
pub terminal: bool,
|
||||
/// The running service must start again for the change to apply.
|
||||
pub restart_needed: bool,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Agent chats can be opened here at all.
|
||||
pub fn usable(&self) -> bool {
|
||||
self.enabled && !self.roots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `lembas remote <args…> --json`: the new state, or what the CLI said went wrong.
|
||||
pub fn run(args: &[&str]) -> Result<Remote, String> {
|
||||
let out = Command::new(cli::binary())
|
||||
.arg("remote")
|
||||
.args(args)
|
||||
.arg("--json")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| format!("Could not start {}: {e}", cli::binary().display()))?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(if err.is_empty() {
|
||||
format!("lembas remote {} failed ({})", args.join(" "), out.status)
|
||||
} else {
|
||||
err.trim_start_matches("lembas: ").to_string()
|
||||
});
|
||||
}
|
||||
parse(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// The last JSON object the CLI printed (it may say a word before it).
|
||||
fn parse(stdout: &str) -> Result<Remote, String> {
|
||||
stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|l| serde_json::from_str::<Remote>(l.trim()).ok())
|
||||
.ok_or_else(|| "lembas remote gave no answer this version of the app understands — is the bundled LLeMbas CLI too old?".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_state_and_defaults_what_is_missing() {
|
||||
let r = parse(
|
||||
"added /home/a/project\n{\"enabled\":true,\"roots\":[\"/home/a/project\"],\"max_mode\":\"edit\",\"terminal\":false}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.usable());
|
||||
assert_eq!(r.roots, vec!["/home/a/project"]);
|
||||
assert!(!r.restart_needed);
|
||||
let off = parse("{\"enabled\":false,\"roots\":[]}").unwrap();
|
||||
assert!(!off.usable());
|
||||
assert!(parse("lembas: unknown command remote").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! HTML to look at, sealed: an HTML file in the viewer, whether it is the agent's (`show`), the
|
||||
//! server's tree's or this computer's, is a page a model may have written. It is drawn in a frame
|
||||
//! from an address of its own (`llembas-sealed://`), never as part of the app's page:
|
||||
//!
|
||||
//! - **No bridge.** The capability names only the app's own page; Tauri gives the IPC script to
|
||||
//! the main frame only, and this scheme is not the app's origin. Nothing in the frame can call
|
||||
//! the app.
|
||||
//! - **No network, no app.** It is served under the server's sealed policy (its
|
||||
//! `security/sealed.py`): `sandbox allow-scripts` without `allow-same-origin` makes it an opaque
|
||||
//! origin, nothing loads from anywhere (inline and `data:`/`blob:` only), no form is sent.
|
||||
//! - **No way out.** The app refuses every navigation it did not make (see `lib.rs`), so a link or
|
||||
//! a script in the frame goes nowhere.
|
||||
//!
|
||||
//! The page holds the HTML until it is closed (`forget`); at most `KEEP` are kept.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub const SCHEME: &str = "llembas-sealed";
|
||||
/// The most sealed pages held at once; the oldest goes first.
|
||||
const KEEP: usize = 16;
|
||||
|
||||
/// The policy a sealed page is served under -- the server's `sealed.policy()`, minus
|
||||
/// `frame-ancestors`, which would have to name the app's own scheme.
|
||||
pub const POLICY: &str = "sandbox allow-scripts allow-forms; default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'unsafe-inline' data: blob:; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Pages {
|
||||
next: Mutex<u64>,
|
||||
held: Mutex<VecDeque<(String, String)>>,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
/// Hold `html`; the id it is served under.
|
||||
pub fn hold(&self, html: String) -> String {
|
||||
let id = {
|
||||
let mut n = self.next.lock().unwrap();
|
||||
*n += 1;
|
||||
format!("p{}", *n)
|
||||
};
|
||||
let mut held = self.held.lock().unwrap();
|
||||
while held.len() >= KEEP {
|
||||
held.pop_front();
|
||||
}
|
||||
held.push_back((id.clone(), html));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn forget(&self, id: &str) {
|
||||
self.held.lock().unwrap().retain(|(i, _)| i != id);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<String> {
|
||||
self.held
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(i, _)| i == id)
|
||||
.map(|(_, h)| h.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a held page is served: the address form differs by platform, as Tauri's own does.
|
||||
pub fn url(id: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("http://{SCHEME}.localhost/{id}")
|
||||
} else {
|
||||
format!("{SCHEME}://localhost/{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The id in a request's path: `/p3` -> `p3`.
|
||||
pub fn id_of(path: &str) -> &str {
|
||||
path.trim_start_matches('/')
|
||||
.split(['?', '/'])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// The response for a request to the scheme.
|
||||
pub fn respond(pages: &Pages, path: &str) -> tauri::http::Response<Vec<u8>> {
|
||||
let builder = tauri::http::Response::builder()
|
||||
.header("Content-Security-Policy", POLICY)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Referrer-Policy", "no-referrer")
|
||||
.header("Cache-Control", "no-store");
|
||||
let built = match pages.get(id_of(path)) {
|
||||
Some(html) => builder
|
||||
.status(200)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(html.into_bytes()),
|
||||
None => builder
|
||||
.status(404)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Vec::new()),
|
||||
};
|
||||
built.unwrap_or_else(|_| tauri::http::Response::new(Vec::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pages_are_held_by_id_and_the_oldest_goes() {
|
||||
let p = Pages::default();
|
||||
let first = p.hold("<p>one</p>".into());
|
||||
assert_eq!(p.get(&first).as_deref(), Some("<p>one</p>"));
|
||||
for i in 0..KEEP {
|
||||
p.hold(format!("<p>{i}</p>"));
|
||||
}
|
||||
assert_eq!(p.get(&first), None);
|
||||
let last = p.hold("<p>last</p>".into());
|
||||
p.forget(&last);
|
||||
assert_eq!(p.get(&last), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sealed_page_is_served_sealed() {
|
||||
let p = Pages::default();
|
||||
let id = p.hold("<script>alert(1)</script>".into());
|
||||
let res = respond(&p, &format!("/{id}"));
|
||||
assert_eq!(res.status(), 200);
|
||||
let csp = res.headers()["Content-Security-Policy"].to_str().unwrap();
|
||||
assert!(csp.starts_with("sandbox allow-scripts"));
|
||||
assert!(!csp.contains("allow-same-origin"));
|
||||
assert!(csp.contains("connect-src 'none'"));
|
||||
assert_eq!(respond(&p, "/nope").status(), 404);
|
||||
assert_eq!(id_of("/p7?x=1"), "p7");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,35 @@
|
||||
//! What the app remembers: which server it opens, and whether the agent should run. One small
|
||||
//! JSON file in the app's config directory; nothing secret is in it (the device's key is the
|
||||
//! What the app remembers: which server it talks to, whether the agent should run, the theme and
|
||||
//! whether to notify. One small JSON file in the app's config directory; nothing secret is in it
|
||||
//! (the app's sign-in is `account.json`, readable by this user only; the device's key is the
|
||||
//! LLeMbas CLI's, in its own config).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// The server's base address, `https://host[:port]`, with no trailing slash.
|
||||
pub server: Option<String>,
|
||||
/// Run the agent while the app runs (once this computer is linked to the server).
|
||||
pub agent: bool,
|
||||
/// The account's theme the last time the server said (`moria` or `shire`): what the window
|
||||
/// is painted in until it says again. None until it has.
|
||||
pub theme: Option<String>,
|
||||
/// Say with a notification when a reply or an agent's work finishes out of sight.
|
||||
pub notify: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
server: None,
|
||||
agent: false,
|
||||
theme: None,
|
||||
notify: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(config_dir: &Path) -> PathBuf {
|
||||
@@ -81,9 +98,20 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
assert_eq!(load(&dir), Settings::default());
|
||||
assert!(Settings::default().notify);
|
||||
// A file from before 1.0 has no `notify`: it is on.
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
file(&dir),
|
||||
r#"{"server":"https://ai.example","agent":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(load(&dir).notify);
|
||||
let s = Settings {
|
||||
server: Some("https://ai.example".into()),
|
||||
agent: true,
|
||||
theme: Some("shire".into()),
|
||||
notify: false,
|
||||
};
|
||||
save(&dir, &s).unwrap();
|
||||
assert_eq!(load(&dir), s);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
|
||||
//! looking at it; and a scheduled task has a result the server decided to tell of. The app's own,
|
||||
//! so the server's Web Push can stay off.
|
||||
//!
|
||||
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
|
||||
//! being answered; a chat that was and no longer is has finished. The notification is shown
|
||||
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
|
||||
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
|
||||
//! put on screen outside the app.
|
||||
//!
|
||||
//! Scheduled tasks: the app follows the server's own event stream (`GET /api/v2/events`), whose
|
||||
//! `schedules` frame carries the whole list (`{"version", "schedules": [schedule]}`) whenever it
|
||||
//! changes -- a run starting, waiting, finishing. A schedule's last run that has newly finished is
|
||||
//! told by the server's notify decision for it: `notified` when the server already told a device,
|
||||
//! 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. 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};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const EVERY: Duration = Duration::from_secs(15);
|
||||
|
||||
/// What a notification says, in the page's language.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
pub struct Words {
|
||||
/// A chat's reply is ready.
|
||||
pub reply: String,
|
||||
/// An agent chat has finished its work.
|
||||
pub agent: String,
|
||||
/// A scheduled task has a result.
|
||||
#[serde(default = "scheduled_word")]
|
||||
pub scheduled: String,
|
||||
/// A scheduled task's run failed.
|
||||
#[serde(default = "scheduled_failed_word")]
|
||||
pub scheduled_failed: String,
|
||||
/// A scheduled task failed three times in a row: pause it?
|
||||
#[serde(default = "scheduled_pause_word")]
|
||||
pub scheduled_pause: String,
|
||||
}
|
||||
|
||||
fn scheduled_word() -> String {
|
||||
"A scheduled task has a result.".into()
|
||||
}
|
||||
|
||||
fn scheduled_failed_word() -> String {
|
||||
"A scheduled task failed.".into()
|
||||
}
|
||||
|
||||
fn scheduled_pause_word() -> String {
|
||||
"A scheduled task failed 3 times in a row. Pause it?".into()
|
||||
}
|
||||
|
||||
impl Default for Words {
|
||||
fn default() -> Self {
|
||||
Words {
|
||||
reply: "The reply is ready.".into(),
|
||||
agent: "The agent has finished.".into(),
|
||||
scheduled: scheduled_word(),
|
||||
scheduled_failed: scheduled_failed_word(),
|
||||
scheduled_pause: scheduled_pause_word(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat that has just finished.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Done {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub agent: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Watch {
|
||||
/// The chat the page shows, if any.
|
||||
pub open: Mutex<Option<String>>,
|
||||
pub words: Mutex<Words>,
|
||||
/// Which chats were being answered at the last look (None before the first look).
|
||||
running: Mutex<Option<HashMap<String, bool>>>,
|
||||
/// Each schedule's last run at the last look, and whether it had finished (None before the
|
||||
/// first look).
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Told {
|
||||
/// It has a result.
|
||||
Result,
|
||||
/// It failed.
|
||||
Failed,
|
||||
/// It failed for the third time in a row: pause it?
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// A scheduled task's newly finished run that is worth telling of.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
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.
|
||||
const FINISHED: [&str; 5] = ["ok", "silent", "failed", "skipped", "missed"];
|
||||
/// The failure in a row that asks whether to pause (`notify.PAUSE_AFTER`).
|
||||
const PAUSE_AFTER: i64 = 3;
|
||||
|
||||
/// Whether a finished run is told, and how: the server's word when it already told (`notified`),
|
||||
/// else its notify policy for the schedule as the server applies it.
|
||||
pub fn decide(policy: &str, status: &str, notified: bool, streak: i64) -> Option<Told> {
|
||||
let failed = matches!(status, "failed" | "skipped");
|
||||
let kind = if failed && streak == PAUSE_AFTER {
|
||||
Told::Pause
|
||||
} else if failed {
|
||||
Told::Failed
|
||||
} else {
|
||||
Told::Result
|
||||
};
|
||||
if notified {
|
||||
return Some(kind);
|
||||
}
|
||||
match (policy, status) {
|
||||
("never", _) => None,
|
||||
(_, "ok") | ("always", "silent") => Some(Told::Result),
|
||||
_ if failed && (streak <= 1 || streak == PAUSE_AFTER) => Some(kind),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Watch {
|
||||
/// Note the chat list as the server gave it; the chats that finished since the last look.
|
||||
/// The first look only notes: what finished before the app looked is not news.
|
||||
pub fn look(&self, list: &Value) -> Vec<Done> {
|
||||
let rows = list
|
||||
.get("chats")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut now = HashMap::new();
|
||||
let mut done = Vec::new();
|
||||
let mut running = self.running.lock().unwrap();
|
||||
for row in &rows {
|
||||
let Some(id) = row.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let busy = row.get("running").and_then(Value::as_bool).unwrap_or(false);
|
||||
let was = running
|
||||
.as_ref()
|
||||
.and_then(|r| r.get(id).copied())
|
||||
.unwrap_or(false);
|
||||
if was && !busy {
|
||||
done.push(Done {
|
||||
id: id.to_string(),
|
||||
title: row
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
agent: row.get("kind").and_then(Value::as_str) == Some("agent"),
|
||||
});
|
||||
}
|
||||
now.insert(id.to_string(), busy);
|
||||
}
|
||||
*running = Some(now);
|
||||
done
|
||||
}
|
||||
|
||||
/// Note the schedules as the event stream gave them (`{"schedules": [...]}`); the runs that
|
||||
/// finished since the last look and are told. The first look only notes, as with the chats; a
|
||||
/// run is told once, however often the list comes again.
|
||||
pub fn look_schedules(&self, list: &Value) -> Vec<Ran> {
|
||||
let rows = list
|
||||
.get("schedules")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut seen = self.runs.lock().unwrap();
|
||||
let first = seen.is_none();
|
||||
let mut now = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for row in &rows {
|
||||
let Some(id) = row.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(run) = row.get("last_run").filter(|r| r.is_object()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(run_id) = run.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let status = run.get("status").and_then(Value::as_str).unwrap_or("");
|
||||
let finished = FINISHED.contains(&status);
|
||||
let before = seen.as_ref().and_then(|s| s.get(id));
|
||||
let known = before.is_some_and(|(r, done)| r == run_id && *done);
|
||||
if !first && finished && !known {
|
||||
let told = decide(
|
||||
row.get("notify").and_then(Value::as_str).unwrap_or(""),
|
||||
status,
|
||||
run.get("notified").and_then(Value::as_bool) == Some(true),
|
||||
row.get("failure_streak")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0),
|
||||
);
|
||||
if let Some(told) = told {
|
||||
let (body, actions) = run_notification(run);
|
||||
out.push(Ran {
|
||||
title: row
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
told,
|
||||
schedule: id.to_string(),
|
||||
run: run_id.to_string(),
|
||||
body,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
}
|
||||
now.insert(id.to_string(), (run_id.to_string(), finished));
|
||||
}
|
||||
*seen = Some(now);
|
||||
out
|
||||
}
|
||||
|
||||
/// The flag that ends the event stream followed now.
|
||||
pub fn stop_flag(&self) -> Arc<AtomicBool> {
|
||||
self.stop.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Start again from nothing: another server, or signed out.
|
||||
pub fn reset(&self) {
|
||||
*self.running.lock().unwrap() = None;
|
||||
*self.runs.lock().unwrap() = None;
|
||||
let mut stop = self.stop.lock().unwrap();
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
*stop = Arc::new(AtomicBool::new(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A schedule in a test: its id, notify policy, failure streak, and last run (id, status,
|
||||
/// whether the server told of it).
|
||||
type Row<'a> = (&'a str, &'a str, i64, Option<(&'a str, &'a str, bool)>);
|
||||
|
||||
fn schedules(rows: &[Row]) -> Value {
|
||||
json!({"version": "v", "schedules": rows.iter().map(|(id, notify, streak, run)| json!({
|
||||
"id": id, "title": format!("Task {id}"), "notify": notify, "failure_streak": streak,
|
||||
"last_run": run.map(|(r, status, told)| json!({"id": r, "status": status, "notified": told})),
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
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();
|
||||
assert!(w
|
||||
.look_schedules(&schedules(&[
|
||||
("a", "matters", 0, Some(("r1", "ok", false))),
|
||||
("b", "always", 0, None)
|
||||
]))
|
||||
.is_empty());
|
||||
// Running is not news; finishing is, even under the same run id.
|
||||
assert!(w
|
||||
.look_schedules(&schedules(&[(
|
||||
"a",
|
||||
"matters",
|
||||
0,
|
||||
Some(("r2", "running", false))
|
||||
)]))
|
||||
.is_empty());
|
||||
let out = w.look_schedules(&schedules(&[
|
||||
("a", "matters", 0, Some(("r2", "ok", false))),
|
||||
("b", "always", 0, Some(("r3", "silent", false))),
|
||||
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||
("d", "never", 0, Some(("r5", "ok", false))),
|
||||
]));
|
||||
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)))]))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_said_the_first_time_and_the_third_asks_to_pause() {
|
||||
assert_eq!(decide("matters", "failed", false, 1), Some(Told::Failed));
|
||||
assert_eq!(decide("matters", "failed", false, 2), None);
|
||||
assert_eq!(decide("always", "skipped", false, 3), Some(Told::Pause));
|
||||
assert_eq!(decide("never", "failed", false, 1), None);
|
||||
assert_eq!(decide("matters", "missed", false, 0), None);
|
||||
// The server's own word wins: it told a device.
|
||||
assert_eq!(decide("matters", "failed", true, 2), Some(Told::Failed));
|
||||
assert_eq!(decide("matters", "silent", true, 0), Some(Told::Result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reset_ends_the_stream_and_starts_quiet() {
|
||||
let w = Watch::default();
|
||||
let old = w.stop_flag();
|
||||
w.look_schedules(&schedules(&[(
|
||||
"a",
|
||||
"matters",
|
||||
0,
|
||||
Some(("r1", "running", false)),
|
||||
)]));
|
||||
w.reset();
|
||||
assert!(old.load(Ordering::SeqCst));
|
||||
assert!(!w.stop_flag().load(Ordering::SeqCst));
|
||||
assert!(w
|
||||
.look_schedules(&schedules(&[(
|
||||
"a",
|
||||
"matters",
|
||||
0,
|
||||
Some(("r1", "ok", false))
|
||||
)]))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
fn list(rows: &[(&str, bool, &str)]) -> Value {
|
||||
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
|
||||
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chat_that_stops_running_has_finished_and_the_first_look_is_quiet() {
|
||||
let w = Watch::default();
|
||||
assert!(w
|
||||
.look(&list(&[("a", true, "chat"), ("b", false, "agent")]))
|
||||
.is_empty());
|
||||
assert!(w
|
||||
.look(&list(&[("a", true, "chat"), ("b", true, "agent")]))
|
||||
.is_empty());
|
||||
let done = w.look(&list(&[("a", false, "chat"), ("b", false, "agent")]));
|
||||
assert_eq!(
|
||||
done,
|
||||
vec![
|
||||
Done {
|
||||
id: "a".into(),
|
||||
title: "Chat a".into(),
|
||||
agent: false
|
||||
},
|
||||
Done {
|
||||
id: "b".into(),
|
||||
title: "Chat b".into(),
|
||||
agent: true
|
||||
},
|
||||
]
|
||||
);
|
||||
// Said once.
|
||||
assert!(w.look(&list(&[("a", false, "chat")])).is_empty());
|
||||
w.reset();
|
||||
assert!(w.look(&list(&[("a", false, "chat")])).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src ipc: http://ipc.localhost"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -20,8 +23,8 @@
|
||||
"appimage"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer",
|
||||
"longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.",
|
||||
"shortDescription": "The LLeMbas agent on this computer, and your server's chats",
|
||||
"longDescription": "LLeMbas Desktop runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal, and shows them, and the chats on your LLeMbas 2.0 server, in an app of its own: the agent's working directory and the server's files side by side, and a viewer for what the agent shows you.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://llembas.eu",
|
||||
"publisher": "LLeMbas",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
LLeMbas v2.0.0-beta.9 (3b7da5fc7ac72d5de9229919793dd299c03da6a8) tests/fixtures/api_v2
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"server": {
|
||||
"version": "2.1.0-beta.3",
|
||||
"channel": "beta"
|
||||
},
|
||||
"apps": {
|
||||
"android": {
|
||||
"version": "1.1.0-beta.2",
|
||||
"tag": "v1.1.0-beta.2",
|
||||
"channel": "beta",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Android/releases/tag/v1.1.0-beta.2"
|
||||
},
|
||||
"desktop": {
|
||||
"version": "1.0.1",
|
||||
"tag": "v1.0.1",
|
||||
"channel": "stable",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"server": {
|
||||
"version": "2.0.0",
|
||||
"channel": "stable"
|
||||
},
|
||||
"apps": {
|
||||
"android": null,
|
||||
"desktop": {
|
||||
"version": "1.0.1",
|
||||
"tag": "v1.0.1",
|
||||
"channel": "stable",
|
||||
"page": "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/v1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "chat1",
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"model": "qwen",
|
||||
"folder_id": null,
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"temporary": false,
|
||||
"unread": true,
|
||||
"running": false,
|
||||
"mode": "",
|
||||
"effort": "low",
|
||||
"device": null,
|
||||
"device_session_id": null,
|
||||
"parent_chat_id": null,
|
||||
"agents": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false,
|
||||
"cap": 32
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"chats": [
|
||||
{
|
||||
"id": "chat1",
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"model": "qwen",
|
||||
"folder_id": null,
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"temporary": false,
|
||||
"unread": true,
|
||||
"running": false,
|
||||
"mode": "",
|
||||
"effort": "low",
|
||||
"device": null,
|
||||
"device_session_id": null,
|
||||
"parent_chat_id": null,
|
||||
"agents": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"subagents": null,
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
],
|
||||
"more": false
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"version": "8bee85746004dc19",
|
||||
"schedules": [
|
||||
{
|
||||
"id": "sched1",
|
||||
"title": "Waybread check",
|
||||
"instruction": "Check the waybread is wrapped.",
|
||||
"request": "every second Monday at 7:30",
|
||||
"rule": {
|
||||
"at": {
|
||||
"nth": [
|
||||
{
|
||||
"n": 2,
|
||||
"weekday": 0
|
||||
}
|
||||
],
|
||||
"times": [
|
||||
"07:30"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "On the second Monday of each month at 07:30",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-12T07:30:00+00:00",
|
||||
"next_runs": [
|
||||
"2026-01-12T07:30:00+00:00",
|
||||
"2026-02-09T07:30:00+00:00",
|
||||
"2026-03-09T07:30:00+00:00",
|
||||
"2026-04-13T07:30:00+00:00",
|
||||
"2026-05-11T07:30:00+00:00"
|
||||
],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": "qwen",
|
||||
"effort": "high",
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat1",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": null,
|
||||
"runs_on": null,
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"text": "Running bake --slow",
|
||||
"progress": {
|
||||
"phase": "running_tool",
|
||||
"since": 1767323045000,
|
||||
"tool": "bash",
|
||||
"what": "bake --slow"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"path": "/home/frodo",
|
||||
"parent": "/home",
|
||||
"entries": [
|
||||
{
|
||||
"name": "recipes",
|
||||
"path": "/home/frodo/recipes",
|
||||
"kind": "dir",
|
||||
"size": 0,
|
||||
"mime": "",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lembas.md",
|
||||
"path": "/home/frodo/lembas.md",
|
||||
"kind": "file",
|
||||
"size": 27,
|
||||
"mime": "text/markdown",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folder": {
|
||||
"id": "fold1",
|
||||
"name": "Waybread",
|
||||
"parent_id": null,
|
||||
"kind": "",
|
||||
"position": 0,
|
||||
"collapsed": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"id": "fold1",
|
||||
"name": "Waybread",
|
||||
"parent_id": null,
|
||||
"kind": "",
|
||||
"position": 0,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": "fold2",
|
||||
"name": "Recipes",
|
||||
"parent_id": "fold1",
|
||||
"kind": "chat",
|
||||
"position": 1,
|
||||
"collapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"api": 2,
|
||||
"version": "2.0.0",
|
||||
"account": {
|
||||
"id": "user1",
|
||||
"name": "Frodo",
|
||||
"email": "frodo@shire.test"
|
||||
},
|
||||
"language": "sk",
|
||||
"theme": "shire",
|
||||
"default_model": "qwen",
|
||||
"permissions": {
|
||||
"chat.create": true,
|
||||
"chat.delete": false,
|
||||
"chat.model_select": true,
|
||||
"chat.system_prompt": true,
|
||||
"chat.params": true,
|
||||
"files.upload": true,
|
||||
"tools.agent": true,
|
||||
"audio.transcribe": true,
|
||||
"audio.listen": true
|
||||
},
|
||||
"services": {
|
||||
"stt": true,
|
||||
"tts": false,
|
||||
"search": true,
|
||||
"fetch": true,
|
||||
"images": false
|
||||
},
|
||||
"scopes": [
|
||||
"app",
|
||||
"models"
|
||||
],
|
||||
"language_setting": "sk",
|
||||
"languages": [
|
||||
{
|
||||
"id": "en",
|
||||
"name": "English"
|
||||
},
|
||||
{
|
||||
"id": "sk",
|
||||
"name": "Slovenčina"
|
||||
}
|
||||
],
|
||||
"themes": [
|
||||
{
|
||||
"id": "moria",
|
||||
"label": "Moria",
|
||||
"scheme": "dark"
|
||||
},
|
||||
{
|
||||
"id": "shire",
|
||||
"label": "Shire",
|
||||
"scheme": "light"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"id": "mem1",
|
||||
"content": "Prefers lembas warm.",
|
||||
"author": "user",
|
||||
"data_group": {
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"memory": {
|
||||
"id": "mem1",
|
||||
"content": "Prefers lembas warm.",
|
||||
"author": "user",
|
||||
"data_group": {
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schedule_id": "sched1",
|
||||
"run_id": "run1",
|
||||
"chat_id": "chat1",
|
||||
"status": "ok",
|
||||
"visibility": "private",
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"ask": "",
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"pause_label": "Pause",
|
||||
"title": "LLeMbas",
|
||||
"body": "A scheduled task has finished.",
|
||||
"url": "/scheduled/sched1?run=run1",
|
||||
"kind": "schedule"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schedule_id": "sched1",
|
||||
"run_id": "run3",
|
||||
"chat_id": "chat1",
|
||||
"status": "failed",
|
||||
"visibility": "private",
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again",
|
||||
"pause"
|
||||
],
|
||||
"ask": "pause",
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "Failed 3 times in a row. Pause it?",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Failed"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again",
|
||||
"pause"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again",
|
||||
"pause": "Pause"
|
||||
},
|
||||
"ask": "pause"
|
||||
},
|
||||
"pause_label": "Pause",
|
||||
"title": "LLeMbas",
|
||||
"body": "A scheduled task failed 3 times in a row. Pause it?",
|
||||
"url": "/scheduled/sched1?run=run3",
|
||||
"kind": "schedule_failed"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"schedule": {
|
||||
"id": "sched1",
|
||||
"title": "Waybread check",
|
||||
"instruction": "Check the waybread is wrapped.",
|
||||
"request": "every second Monday at 7:30",
|
||||
"rule": {
|
||||
"at": {
|
||||
"nth": [
|
||||
{
|
||||
"n": 2,
|
||||
"weekday": 0
|
||||
}
|
||||
],
|
||||
"times": [
|
||||
"07:30"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "On the second Monday of each month at 07:30",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-12T07:30:00+00:00",
|
||||
"next_runs": [
|
||||
"2026-01-12T07:30:00+00:00",
|
||||
"2026-02-09T07:30:00+00:00",
|
||||
"2026-03-09T07:30:00+00:00",
|
||||
"2026-04-13T07:30:00+00:00",
|
||||
"2026-05-11T07:30:00+00:00"
|
||||
],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": "qwen",
|
||||
"effort": "high",
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat1",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": null,
|
||||
"runs_on": null,
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schedule": {
|
||||
"id": "sched3",
|
||||
"title": "Nightly tests",
|
||||
"instruction": "Run the tests.",
|
||||
"request": "every night at 02:00",
|
||||
"rule": {
|
||||
"every": {
|
||||
"minutes": 1440
|
||||
}
|
||||
},
|
||||
"description": "Every 1 day",
|
||||
"timezone": "UTC",
|
||||
"enabled": true,
|
||||
"next_run": "2026-01-03T02:00:00+00:00",
|
||||
"next_runs": [],
|
||||
"target": "chat",
|
||||
"mode": "fresh",
|
||||
"notify": "matters",
|
||||
"catch_up": "one",
|
||||
"grace_minutes": null,
|
||||
"model": null,
|
||||
"effort": null,
|
||||
"agents": null,
|
||||
"tools": [
|
||||
"files",
|
||||
"recall",
|
||||
"search",
|
||||
"fetch",
|
||||
"memory",
|
||||
"skills",
|
||||
"plan"
|
||||
],
|
||||
"origin": "user",
|
||||
"chat_id": "chat2",
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": {
|
||||
"kind": "command",
|
||||
"command": "git log -1 --format=%H",
|
||||
"timeout_s": 30,
|
||||
"target": "git log -1 --format=%H",
|
||||
"changed_at": "2026-01-02T03:04:05+00:00"
|
||||
},
|
||||
"runs_on": {
|
||||
"device": "tok1",
|
||||
"device_name": "bag-end",
|
||||
"cwd": "/home/frodo/waybread",
|
||||
"mode": "edit",
|
||||
"approval_wait_minutes": 30
|
||||
},
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"id": "run5",
|
||||
"schedule_id": "sched3",
|
||||
"status": "waiting",
|
||||
"timing": "on_time",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": null,
|
||||
"finished_at": null,
|
||||
"waiting_for": "device",
|
||||
"retry_at": "2026-01-02T03:09:05+00:00",
|
||||
"progress": {
|
||||
"phase": "retrying",
|
||||
"since": 1767323045000,
|
||||
"reason": "Waiting for bag-end to be linked.",
|
||||
"in": 0,
|
||||
"attempt": 1
|
||||
},
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": null,
|
||||
"name": null,
|
||||
"reason": null,
|
||||
"switched_from": null
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 0,
|
||||
"completion": 0
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": null,
|
||||
"message_id": null,
|
||||
"report_path": null,
|
||||
"preview": "",
|
||||
"error": {
|
||||
"class": "device_away",
|
||||
"text": "Waiting for bag-end to be linked."
|
||||
},
|
||||
"notified": false,
|
||||
"notification": null,
|
||||
"watch": null
|
||||
},
|
||||
"running": false,
|
||||
"created_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"run": {
|
||||
"id": "run1",
|
||||
"schedule_id": "sched1",
|
||||
"status": "ok",
|
||||
"timing": "late",
|
||||
"missed": 0,
|
||||
"due_at": "2026-01-02T03:04:05+00:00",
|
||||
"started_at": "2026-01-02T03:04:05+00:00",
|
||||
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||
"waiting_for": null,
|
||||
"retry_at": null,
|
||||
"progress": null,
|
||||
"attempts": 1,
|
||||
"model": {
|
||||
"id": "gpt-oss",
|
||||
"name": "GPT-OSS",
|
||||
"reason": "switched",
|
||||
"switched_from": "Qwen"
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 1200,
|
||||
"completion": 340
|
||||
},
|
||||
"cost": null,
|
||||
"chat_id": "chat1",
|
||||
"message_id": "msg2",
|
||||
"report_path": null,
|
||||
"preview": "All the waybread is wrapped.",
|
||||
"error": null,
|
||||
"notified": true,
|
||||
"notification": {
|
||||
"title": "Waybread check",
|
||||
"body": "All the waybread is wrapped.",
|
||||
"public": {
|
||||
"title": "Waybread check",
|
||||
"body": "Finished"
|
||||
},
|
||||
"actions": [
|
||||
"open",
|
||||
"run_again"
|
||||
],
|
||||
"labels": {
|
||||
"open": "Open",
|
||||
"run_again": "Run again"
|
||||
},
|
||||
"ask": ""
|
||||
},
|
||||
"watch": null
|
||||
},
|
||||
"text": "All the waybread is *wrapped*."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"scope": {
|
||||
"families": [
|
||||
{
|
||||
"gate": "notes",
|
||||
"label": "Notes",
|
||||
"on": true
|
||||
},
|
||||
{
|
||||
"gate": "web",
|
||||
"label": "Web search",
|
||||
"on": false
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"name": "baking",
|
||||
"description": "Bake waybread.",
|
||||
"on": true
|
||||
}
|
||||
],
|
||||
"allow": [
|
||||
"bash:ls *"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"q": "lembas",
|
||||
"in": "all",
|
||||
"offered": [
|
||||
"chats",
|
||||
"files",
|
||||
"memories"
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"kind": "chats",
|
||||
"more": true,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"title": "Waybread",
|
||||
"snippet": "…how long does lembas keep & travel?",
|
||||
"marks": [
|
||||
[
|
||||
15,
|
||||
21
|
||||
]
|
||||
],
|
||||
"archived": false,
|
||||
"id": "chat1",
|
||||
"agent": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "files",
|
||||
"more": false,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "file",
|
||||
"title": "bread.md",
|
||||
"snippet": "/home/frodo/bread.md — Lembas recipe",
|
||||
"marks": [
|
||||
[
|
||||
23,
|
||||
29
|
||||
]
|
||||
],
|
||||
"archived": false,
|
||||
"path": "/home/frodo/bread.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "memories",
|
||||
"more": false,
|
||||
"hits": [
|
||||
{
|
||||
"kind": "memory",
|
||||
"id": "mem1",
|
||||
"title": "",
|
||||
"snippet": "Prefers lembas warm.",
|
||||
"marks": [
|
||||
[
|
||||
8,
|
||||
14
|
||||
]
|
||||
],
|
||||
"archived": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tag": "v2.0.0-beta.9",
|
||||
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
|
||||
"sha256": "46d010bb862b06345c05f726cd1e6ae8415866324bfb0a55b8642ce986a9e769"
|
||||
}
|
||||
+1799
-53
File diff suppressed because it is too large.
Load diff
@@ -1,74 +0,0 @@
|
||||
// The first-run page. It talks to the app over Tauri's IPC — four commands, see src-tauri/src/lib.rs.
|
||||
// The server's own pages never get this: they are remote content with no capability.
|
||||
"use strict"
|
||||
|
||||
const { invoke } = window.__TAURI__.core
|
||||
const { listen } = window.__TAURI__.event
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
|
||||
const AGENT = {
|
||||
off: () => ["The agent is off.", ""],
|
||||
not_linked: () => ["This computer is not linked to the server yet.", ""],
|
||||
starting: () => ["The agent is starting…", ""],
|
||||
running: () => ["The agent is running: agent chats can use this computer.", "is-ok"],
|
||||
elsewhere: (pid) => [`A LLeMbas CLI service already runs here (pid ${pid}); it is this computer's agent.`, "is-ok"],
|
||||
failed: (why) => [`The agent stopped: ${why}. Trying again shortly.`, "is-error"],
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
$("version").textContent = s.version
|
||||
if (s.server && !$("address").value) $("address").value = s.server
|
||||
$("agent-section").hidden = !s.server
|
||||
const [text, cls] = (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
||||
const status = $("agent-status")
|
||||
status.textContent = text
|
||||
status.className = `status ${cls}`
|
||||
$("link").textContent = s.linked ? "Link again" : "Link this computer"
|
||||
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
|
||||
$("toggle").disabled = !s.linked && !s.agent_wanted
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
render(await invoke("get_status"))
|
||||
}
|
||||
|
||||
$("server-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
const hint = $("server-hint")
|
||||
const button = e.target.querySelector("button")
|
||||
button.disabled = true
|
||||
hint.className = "hint"
|
||||
hint.textContent = "Looking for a LLeMbas server there…"
|
||||
try {
|
||||
await invoke("set_server", { address: $("address").value })
|
||||
// The window now shows the server.
|
||||
} catch (err) {
|
||||
hint.className = "hint is-error"
|
||||
hint.textContent = String(err)
|
||||
} finally {
|
||||
button.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
$("link").addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("link_computer")
|
||||
} catch (err) {
|
||||
$("agent-status").className = "status is-error"
|
||||
$("agent-status").textContent = String(err)
|
||||
}
|
||||
})
|
||||
|
||||
$("toggle").addEventListener("click", async () => {
|
||||
const s = await invoke("get_status")
|
||||
await invoke("set_agent", { on: !s.agent_wanted })
|
||||
})
|
||||
|
||||
$("open").addEventListener("click", async () => {
|
||||
const s = await invoke("get_status")
|
||||
if (s.server) await invoke("set_server", { address: s.server })
|
||||
})
|
||||
|
||||
listen("status", (e) => render(e.payload))
|
||||
refresh()
|
||||
+3944
File diff suppressed because it is too large.
Load diff
+183
-26
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="moria" data-base="moria">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -7,42 +7,199 @@
|
||||
<title>LLeMbas</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<script src="app.js" defer></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card" aria-labelledby="title">
|
||||
<div class="app">
|
||||
<header class="bar" data-tauri-drag-region>
|
||||
<button class="bar__btn" id="side-toggle" type="button" aria-label="Sidebar" title="Sidebar" aria-expanded="true" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
|
||||
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
||||
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
||||
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
||||
<button class="bar__update" id="update" type="button" hidden>update</button>
|
||||
<button class="bar__agent" id="agent" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<span class="dot" aria-hidden="true"></span><span class="bar__agent-text" id="agent-text">agent off</span>
|
||||
</button>
|
||||
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
|
||||
<span class="bar__sep" aria-hidden="true"></span>
|
||||
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)" data-i18n-aria-label data-i18n-title></button>
|
||||
</header>
|
||||
|
||||
<div class="setup" id="setup" hidden>
|
||||
<main class="card" aria-labelledby="setup-title">
|
||||
<header class="brand">
|
||||
<img src="logo-mark.svg" alt="" width="40" height="40">
|
||||
<div>
|
||||
<h1 id="title"><b>LL</b>e<b>M</b>bas</h1>
|
||||
<p class="muted">Desktop <span id="version"></span></p>
|
||||
<h1 id="setup-title"><b>LL</b>e<b>M</b>bas</h1>
|
||||
<p class="muted">Desktop <span id="setup-version"></span></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="server-h">
|
||||
<h2 id="server-h">[ SERVER ]</h2>
|
||||
<form id="server-form" class="field" novalidate>
|
||||
<label for="address">Address of your LLeMbas server</label>
|
||||
<div class="row">
|
||||
<input id="address" name="address" type="text" inputmode="url" autocomplete="url"
|
||||
placeholder="https://ai.example" spellcheck="false" required>
|
||||
<button type="submit" class="btn btn--primary">Connect</button>
|
||||
<div class="setup__body" id="setup-body"></div>
|
||||
</main>
|
||||
</div>
|
||||
<p class="hint" id="server-hint" aria-live="polite">Sign in there as you would in a browser.</p>
|
||||
|
||||
<div class="shell" id="shell" hidden data-side="true" data-viewer="false" data-view="chat">
|
||||
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label>
|
||||
<div class="side__top">
|
||||
<button type="button" class="side__new" id="new-chat-btn"><span class="side__new-label" data-i18n>New chat</span><kbd class="kbd">Ctrl N</kbd></button>
|
||||
<label class="search">
|
||||
<span class="search__icon" id="side-search-icon"></span>
|
||||
<input type="search" id="side-search" placeholder="Search chats" aria-label="Search chats" autocomplete="off" spellcheck="false" data-i18n-placeholder data-i18n-aria-label>
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
<button type="button" class="navrow navrow--account" id="settings-btn" data-view="settings"></button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
||||
|
||||
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="chat-title"></h1>
|
||||
<div class="head__place" id="chat-place"></div>
|
||||
<div class="head__tools" id="chat-tools"></div>
|
||||
</header>
|
||||
<div class="thread" id="thread">
|
||||
<div class="hello" id="new-chat" hidden></div>
|
||||
<div class="messages" id="messages"></div>
|
||||
</div>
|
||||
<div class="dock" id="dock">
|
||||
<p class="thread__note is-error" id="thread-note" hidden></p>
|
||||
<div class="cards" id="cards" hidden></div>
|
||||
<div class="notice" id="link-notice" hidden></div>
|
||||
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
||||
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
||||
<ul class="composer__files" id="composer-files" hidden></ul>
|
||||
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
||||
<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>
|
||||
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section aria-labelledby="agent-h" id="agent-section" hidden>
|
||||
<h2 id="agent-h">[ AGENT ]</h2>
|
||||
<p class="muted">The agent lets agent chats on your server work in this computer's projects.
|
||||
It is the LLeMbas CLI, built in, running in the background while the app runs.</p>
|
||||
<p class="status" id="agent-status" aria-live="polite"></p>
|
||||
<div class="row row--buttons">
|
||||
<button type="button" class="btn" id="link">Link this computer</button>
|
||||
<button type="button" class="btn" id="toggle">Start the agent</button>
|
||||
<button type="button" class="btn btn--primary" id="open">Open the server</button>
|
||||
<section class="page" id="page-files" hidden aria-labelledby="files-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="files-title" data-i18n>Files</h1>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
|
||||
</header>
|
||||
<div class="page__body files">
|
||||
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where" id="local-where"></div>
|
||||
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
<div class="files__tree" id="local-tree"></div>
|
||||
</section>
|
||||
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where muted" id="server-where"></div>
|
||||
<button type="button" class="tool" id="server-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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="page" id="page-scheduled" hidden aria-labelledby="scheduled-title">
|
||||
<header class="head">
|
||||
<h1 class="head__title" id="scheduled-title" data-i18n>Scheduled</h1>
|
||||
<div class="head__place" id="scheduled-place"></div>
|
||||
<div class="head__tools" id="scheduled-tools"></div>
|
||||
</header>
|
||||
<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>
|
||||
<div class="head__place"></div>
|
||||
<div class="head__tools"><button type="button" class="tool" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
|
||||
</header>
|
||||
<div class="settings__layout">
|
||||
<nav class="settings__nav" id="settings-nav" aria-label="Settings" data-i18n-aria-label></nav>
|
||||
<div class="settings__body" id="settings-body"></div>
|
||||
</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>
|
||||
<h2 class="viewer__name" id="activity-title"></h2>
|
||||
<button type="button" class="tool" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
||||
</header>
|
||||
<div class="activity__body" id="activity-body"></div>
|
||||
</aside>
|
||||
|
||||
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
|
||||
<header class="viewer__head">
|
||||
<div class="viewer__id">
|
||||
<h2 class="viewer__name" id="viewer-name"></h2>
|
||||
<p class="viewer__where" id="viewer-where"></p>
|
||||
</div>
|
||||
<div class="viewer__modes" id="viewer-modes"></div>
|
||||
<button type="button" class="tool" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
|
||||
</header>
|
||||
<div class="viewer__body" id="viewer-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,963 @@
|
||||
// The chat's activity panel, beside it, opened from the header's button at any time: where an
|
||||
// agent chat works; what is in its context, by category, against the model's window, how far it
|
||||
// is to the automatic compaction and a button to compact now; the files the chat and its
|
||||
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
|
||||
// that leads them, background commands -- with how long it has run and a stop square, unfolding
|
||||
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
|
||||
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
|
||||
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
|
||||
// from /api/v2:
|
||||
//
|
||||
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
||||
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||
// 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
|
||||
// of a read; it is asked for when the panel opens, after a reply and at most every so often.
|
||||
//
|
||||
// Every name, role, brief, step, room message and conversation here is a model's words: set as
|
||||
// text, and a room message or a reply through the page's own markdown (markdown.js), which
|
||||
// builds elements and never parses HTML.
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||
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.
|
||||
const SHOWN = 6
|
||||
// The context is read again on a change at most this often (ms); always on opening and after a reply.
|
||||
const CONTEXT_EVERY = 15000
|
||||
|
||||
let layout = () => {} // (on) -> the shell makes room for the panel
|
||||
let onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
|
||||
let onCompacted = () => {} // (chat id) -> the conversation is read again
|
||||
let chat = null // the open chat: { id, kind, … }
|
||||
let stream = null // the events stream's handle
|
||||
let retry = 0 // the reconnect timer
|
||||
let failures = 0
|
||||
let summary = null
|
||||
let version = ""
|
||||
let data = null // the last `activity`
|
||||
let room = [] // the room so far: each read asks only for what came after `cursor`
|
||||
let cursor = 0
|
||||
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 ({ 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
|
||||
|
||||
// --- Words ----------------------------------------------------------------------------------------
|
||||
|
||||
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
|
||||
export function duration(seconds) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
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`
|
||||
}
|
||||
|
||||
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
|
||||
export function compact(value) {
|
||||
const n = Math.trunc(Number(value) || 0)
|
||||
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 100000) return `${trim(n / 1000, 1)}k`
|
||||
if (n < 1000000) return `${Math.round(n / 1000)}k`
|
||||
return `${trim(n / 1000000, 2)}M`
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
|
||||
* team's member, a command -- counts the same, as the panel lists them together.
|
||||
*/
|
||||
export function summaryText(counts) {
|
||||
if (!counts) return ""
|
||||
const parts = []
|
||||
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
|
||||
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
||||
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
function stateWord(state) {
|
||||
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
|
||||
}
|
||||
|
||||
function contextWord(key) {
|
||||
return (
|
||||
{
|
||||
messages: t("Messages"),
|
||||
system: t("System prompt"),
|
||||
memory: t("Memory and personal"),
|
||||
instructions: t("Instruction files"),
|
||||
tools: t("Tools"),
|
||||
mcp_tools: t("MCP tools"),
|
||||
since: t("Since the last reply"),
|
||||
buffer: t("Compaction buffer"),
|
||||
free: t("Free space"),
|
||||
}[key] || String(key)
|
||||
)
|
||||
}
|
||||
|
||||
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
|
||||
function percentSentence(text, fields) {
|
||||
return t(text, fields).replace(/%%/g, "%")
|
||||
}
|
||||
|
||||
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
|
||||
export function planList(plan) {
|
||||
return h(
|
||||
"ol",
|
||||
{ class: "plan" },
|
||||
(plan.phases || []).map((p) =>
|
||||
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Following a chat -----------------------------------------------------------------------------
|
||||
|
||||
const enc = encodeURIComponent
|
||||
|
||||
export function panelOpen() {
|
||||
return !$("activity").hidden
|
||||
}
|
||||
|
||||
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
|
||||
export function watchActivity(next) {
|
||||
const id = next?.id || null
|
||||
if (id && chat?.id === id) {
|
||||
chat = next
|
||||
return
|
||||
}
|
||||
stop()
|
||||
chat = id ? next : null
|
||||
summary = null
|
||||
version = ""
|
||||
data = null
|
||||
room = []
|
||||
cursor = 0
|
||||
tree = null
|
||||
treeRoom = []
|
||||
treeLive = true
|
||||
treeNote = ""
|
||||
readAt = contextAt = 0
|
||||
view = null
|
||||
compactError = ""
|
||||
openRows.clear()
|
||||
renderStrip()
|
||||
markOpen()
|
||||
if (!chat) {
|
||||
if (panelOpen()) showPanel(false)
|
||||
return
|
||||
}
|
||||
listenTo(chat.id)
|
||||
if (panelOpen()) {
|
||||
render()
|
||||
read(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimeout(retry)
|
||||
retry = 0
|
||||
failures = 0
|
||||
stream?.close()
|
||||
stream = null
|
||||
}
|
||||
|
||||
async function listenTo(id) {
|
||||
// Held while the stream opens, so nothing opens a second one meanwhile.
|
||||
const pending = { close() {} }
|
||||
stream = pending
|
||||
let ended = false
|
||||
let handleId = null
|
||||
try {
|
||||
const handle = await follow(`/chats/${enc(id)}/activity/events`, {
|
||||
onFrame: (event, frame) => {
|
||||
if (chat?.id !== id || event !== "activity" || !frame || typeof frame !== "object") return
|
||||
failures = 0
|
||||
heard(frame)
|
||||
},
|
||||
// The server closes the stream after a while (`close`): it is opened again at once. A broken
|
||||
// one is opened again later, more slowly each time -- polling, in effect, while it fails.
|
||||
onEnd: (err) => {
|
||||
ended = true
|
||||
if (chat?.id !== id) return
|
||||
if (stream === pending || stream?.id === handleId) stream = null
|
||||
// Not while the window is hidden (in the tray): it is opened again when it shows.
|
||||
if (document.visibilityState === "hidden") return
|
||||
failures = err ? failures + 1 : 0
|
||||
retry = setTimeout(() => {
|
||||
retry = 0
|
||||
if (chat?.id === id && !stream) {
|
||||
if (err && panelOpen()) read(false)
|
||||
listenTo(id)
|
||||
}
|
||||
}, err ? Math.min(60000, 5000 * failures) : 500)
|
||||
},
|
||||
})
|
||||
handleId = handle.id
|
||||
if (chat?.id !== id || stream !== pending) handle.close()
|
||||
else if (!ended) stream = handle
|
||||
} catch {
|
||||
if (stream === pending) stream = null
|
||||
if (chat?.id !== id) return
|
||||
failures += 1
|
||||
retry = setTimeout(() => {
|
||||
retry = 0
|
||||
if (chat?.id === id && !stream) listenTo(id)
|
||||
}, Math.min(60000, 5000 * failures))
|
||||
}
|
||||
}
|
||||
|
||||
function heard(frame) {
|
||||
summary = frame.summary || summary
|
||||
renderStrip()
|
||||
if (frame.version && frame.version !== version) {
|
||||
version = frame.version
|
||||
if (panelOpen()) read(Date.now() - contextAt > CONTEXT_EVERY)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the panel's facts; one read at a time, and one more after it if asked for meanwhile. */
|
||||
async function read(withContext) {
|
||||
if (!chat) return
|
||||
if (reading) {
|
||||
readAgain = withContext ? "context" : readAgain || "plain"
|
||||
return reading
|
||||
}
|
||||
const id = chat.id
|
||||
reading = (async () => {
|
||||
try {
|
||||
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) {
|
||||
// A cursor lower than ours is a room begun again (a new team): read from the start.
|
||||
if (a.team.cursor < cursor) {
|
||||
room = []
|
||||
cursor = 0
|
||||
readAgain = readAgain || "plain"
|
||||
}
|
||||
for (const m of a.team.room || []) if (m.n > cursor) room.push(m)
|
||||
cursor = Math.max(cursor, a.team.cursor || 0)
|
||||
} else {
|
||||
room = []
|
||||
cursor = 0
|
||||
}
|
||||
data = a
|
||||
version = a.version || version
|
||||
summary = a.summary || summary
|
||||
readAt = Date.now()
|
||||
renderStrip()
|
||||
if (view) await readAgent()
|
||||
else render()
|
||||
} catch (err) {
|
||||
if (chat?.id === id && !data) renderError(why(err))
|
||||
}
|
||||
})()
|
||||
await reading
|
||||
reading = null
|
||||
if (readAgain) {
|
||||
const ctxToo = readAgain === "context"
|
||||
readAgain = ""
|
||||
read(ctxToo)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The line above the message box, and the header's button ---------------------------------------
|
||||
|
||||
/** Both ways into the panel say whether it is open. */
|
||||
function markOpen() {
|
||||
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
|
||||
}
|
||||
|
||||
function renderStrip() {
|
||||
const strip = $("activity-strip")
|
||||
// Only while something runs, as the server's own line: the header's button is the way into the
|
||||
// panel at any other time (and a chat whose tasks all finished long ago does not carry
|
||||
// "12 finished" for ever: the panel still lists them).
|
||||
const active = chat && summary?.active ? summaryText(summary) : ""
|
||||
strip.hidden = !active
|
||||
if (!active) return clear(strip)
|
||||
strip.replaceChildren(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
id: "activity-strip-btn",
|
||||
class: "strip__btn is-active",
|
||||
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
||||
"aria-controls": "activity",
|
||||
"aria-expanded": String(panelOpen()),
|
||||
onclick: togglePanel,
|
||||
},
|
||||
h("span", { class: "dot", "aria-hidden": "true" }),
|
||||
h("span", { class: "strip__text", text: active }),
|
||||
icon(ICONS.chevron),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The header's button: the panel, open or closed, for every open chat. */
|
||||
export function activityButton() {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool",
|
||||
id: "activity-open",
|
||||
"aria-label": t("Context and background tasks"),
|
||||
title: t("Context and background tasks"),
|
||||
"aria-controls": "activity",
|
||||
"aria-expanded": String(panelOpen()),
|
||||
onclick: togglePanel,
|
||||
},
|
||||
icon(ICONS.tasks),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The panel ------------------------------------------------------------------------------------------
|
||||
|
||||
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||
export function showPanel(on) {
|
||||
if (on && !chat) return
|
||||
$("activity").hidden = !on
|
||||
layout(on)
|
||||
markOpen()
|
||||
clearInterval(ticker)
|
||||
ticker = 0
|
||||
if (!on) {
|
||||
view = null
|
||||
renderStrip()
|
||||
return
|
||||
}
|
||||
ticker = setInterval(tick, 1000)
|
||||
renderStrip()
|
||||
render()
|
||||
read(true)
|
||||
$("activity-close").focus()
|
||||
}
|
||||
|
||||
export function togglePanel() {
|
||||
showPanel(!panelOpen())
|
||||
}
|
||||
|
||||
/** The elapsed times of agents still at work count on between reads. */
|
||||
function tick() {
|
||||
if (document.visibilityState !== "visible") return
|
||||
const extra = (Date.now() - readAt) / 1000
|
||||
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) {
|
||||
clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text }))
|
||||
}
|
||||
|
||||
function section(name, title, count, ...body) {
|
||||
const el = h(
|
||||
"details",
|
||||
{ class: "activity__section", open: !openRows.has(`section:${name}`) },
|
||||
h("summary", { class: "activity__heading" }, h("span", { text: title }), count ? h("span", { class: "activity__count", text: String(count) }) : null),
|
||||
...body,
|
||||
)
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.delete(`section:${name}`) : openRows.add(`section:${name}`)))
|
||||
return el
|
||||
}
|
||||
|
||||
function render() {
|
||||
$("activity").dataset.view = "panel"
|
||||
const head = $("activity-title")
|
||||
$("activity-back").hidden = true
|
||||
head.textContent = t("Activity")
|
||||
const body = $("activity-body")
|
||||
if (!data) {
|
||||
clear(body).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||
return
|
||||
}
|
||||
// Kept where the reader was: the panel is drawn again on every change.
|
||||
const scroll = body.scrollTop
|
||||
// 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 || 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))
|
||||
body.scrollTop = scroll
|
||||
}
|
||||
|
||||
/** How many tasks, nested ones included: those still at work, or those finished. */
|
||||
function count(list, active) {
|
||||
let n = 0
|
||||
for (const a of list) {
|
||||
if (ACTIVE.includes(a.state) === active) n += 1
|
||||
n += count(a.children || [], active)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Where an agent chat works: its folder, on this computer or another device. */
|
||||
function environmentPart(c) {
|
||||
const dir = c.device?.directory || ""
|
||||
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
|
||||
const here = onThisComputer(c)
|
||||
return h(
|
||||
"p",
|
||||
{ class: "env", title: dir },
|
||||
icon(ICONS.folder),
|
||||
h("bdi", { class: "env__name", text: name }),
|
||||
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
|
||||
)
|
||||
}
|
||||
|
||||
function contextPart(c) {
|
||||
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||
const known = c.percent !== null && c.percent !== undefined
|
||||
const used = h(
|
||||
"p",
|
||||
{ class: "ctx__used" },
|
||||
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
|
||||
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
|
||||
)
|
||||
const cats = c.categories || []
|
||||
const bar = c.limit
|
||||
? h(
|
||||
"div",
|
||||
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
|
||||
cats
|
||||
.filter((r) => r.key !== "free" && r.tokens)
|
||||
.map((r) => {
|
||||
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
|
||||
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
|
||||
return seg
|
||||
}),
|
||||
)
|
||||
: null
|
||||
const rows = h(
|
||||
"ul",
|
||||
{ class: "ctx__rows" },
|
||||
cats.map((r) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "ctx__row" },
|
||||
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "ctx__name", text: contextWord(r.key) }),
|
||||
h("span", { class: "ctx__num", text: compact(r.tokens) }),
|
||||
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const note = h(
|
||||
"p",
|
||||
{ class: "ctx__note" },
|
||||
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
||||
)
|
||||
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far it is to the automatic compaction, and the button that compacts now. The server says it
|
||||
* (2.0.0-beta.7: `auto_compact`, `until_compact` in tokens, `compactable` whether Compact can run
|
||||
* now); an older one gives only `threshold` -- the server compacts before a turn once the context
|
||||
* would reach that % of the window, 0 is off -- and it is worked out from that.
|
||||
*/
|
||||
function compactRow(c) {
|
||||
const limit = Number(c.limit) || 0
|
||||
const threshold = Number(c.threshold)
|
||||
const auto = "auto_compact" in c ? !!c.auto_compact : threshold > 0
|
||||
let until = c.until_compact
|
||||
if (until === undefined && auto && limit && threshold < 100) until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
|
||||
let words
|
||||
if (!auto) words = t("Automatic compaction is off.")
|
||||
else if (until === null || until === undefined) words = t("Compacts by itself once the window is known.")
|
||||
else words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
|
||||
const busy = compacting === chat?.id
|
||||
// `compactable` false: nothing to summarise yet, a reply being written, compaction turned off --
|
||||
// the button stays, greyed; pressing an older server's says why in its own words.
|
||||
const can = !("compactable" in c) || !!c.compactable
|
||||
return h(
|
||||
"div",
|
||||
{ class: "ctx__compact" },
|
||||
h("span", { class: "ctx__until", text: words }),
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn btn--sm",
|
||||
text: busy ? t("Compacting…") : t("Compact"),
|
||||
disabled: busy || !can,
|
||||
title: t("Summarise the earlier turns now and send the summary in their place."),
|
||||
onclick: compactNow,
|
||||
}),
|
||||
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
|
||||
)
|
||||
}
|
||||
|
||||
let compacting = null // the chat being compacted
|
||||
let compactError = ""
|
||||
|
||||
async function compactNow() {
|
||||
if (!chat || compacting) return
|
||||
const id = chat.id
|
||||
compacting = id
|
||||
compactError = ""
|
||||
render()
|
||||
try {
|
||||
await post(`/chats/${enc(id)}/compact`)
|
||||
compacting = null
|
||||
if (chat?.id !== id) return
|
||||
onCompacted(id)
|
||||
read(true)
|
||||
} catch (err) {
|
||||
compacting = null
|
||||
if (chat?.id !== id) return
|
||||
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
|
||||
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||
function safeKey(key) {
|
||||
return String(key).replace(/[^a-z_]/gi, "") || "other"
|
||||
}
|
||||
|
||||
function outputsPart(outputs) {
|
||||
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
|
||||
return h(
|
||||
"ul",
|
||||
{ class: "outs" },
|
||||
outputs.map((o) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "outs__row" },
|
||||
icon(ICONS.file),
|
||||
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
|
||||
h(
|
||||
"span",
|
||||
{ class: "outs__diff" },
|
||||
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function tasksPart(entries) {
|
||||
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.") }))
|
||||
return box
|
||||
}
|
||||
if (team) {
|
||||
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
|
||||
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||
}
|
||||
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
|
||||
// what finished with nothing under it still at work, folded.
|
||||
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
|
||||
const ended = entries.filter((e) => !live.includes(e))
|
||||
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
|
||||
if (live.length) {
|
||||
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
||||
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
||||
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
|
||||
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
|
||||
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",
|
||||
{ class: "room" },
|
||||
room.map((m) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: `room__entry${m.report ? " is-report" : ""}` },
|
||||
h(
|
||||
"div",
|
||||
{ class: "room__meta" },
|
||||
h("span", { class: "room__n", text: `#${m.n}` }),
|
||||
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
|
||||
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
|
||||
),
|
||||
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A folded list of tasks ("Show 3 more", "Finished 12", "Tasks: 2"), remembered across redraws. */
|
||||
function fold(name, label, list, cls = "") {
|
||||
const el = h(
|
||||
"details",
|
||||
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
|
||||
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
|
||||
h("div", { class: "tasks__list" }, list.map(agentRows)),
|
||||
)
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||
return el
|
||||
}
|
||||
|
||||
/** A task and, folded under it, the tasks it leads or started ("Tasks: 3"). */
|
||||
function agentRows(a) {
|
||||
const kids = a.children || []
|
||||
if (!kids.length) return agentRow(a)
|
||||
const nested = fold(`under:${a.kind}:${a.id}`, t("Tasks: %(n)s", { n: kids.length }), kids, "tasks__nested")
|
||||
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
|
||||
return [agentRow(a), nested]
|
||||
}
|
||||
|
||||
function agentRow(a) {
|
||||
const key = `${a.kind}:${a.id}`
|
||||
const active = ACTIVE.includes(a.state)
|
||||
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
|
||||
if (active) {
|
||||
time.dataset.active = ""
|
||||
time.dataset.seconds = String(Number(a.seconds) || 0)
|
||||
}
|
||||
const stopBtn = a.stoppable
|
||||
? 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()
|
||||
stopAgent(a)
|
||||
},
|
||||
},
|
||||
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||
)
|
||||
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||
const facts = h(
|
||||
"p",
|
||||
{ class: "agent__facts" },
|
||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||
h("span", { text: kindWord(a) }),
|
||||
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
|
||||
a.agent ? h("span", { text: a.agent }) : null,
|
||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||
)
|
||||
const step = a.step
|
||||
? h(
|
||||
"p",
|
||||
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
|
||||
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
|
||||
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
|
||||
)
|
||||
: null
|
||||
const el = h(
|
||||
"details",
|
||||
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
|
||||
h(
|
||||
"summary",
|
||||
{ class: "agent__row" },
|
||||
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
|
||||
time,
|
||||
stopBtn,
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "agent__body" },
|
||||
facts,
|
||||
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||
step,
|
||||
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
|
||||
),
|
||||
)
|
||||
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* A task's line: a team's member by its name (it is how the room and the others call it); a
|
||||
* subagent or a command by what it was given to do, its id only where there is nothing else.
|
||||
*/
|
||||
function label(a) {
|
||||
if (a.kind === "member") return a.name || a.id
|
||||
return a.role || a.brief || a.name || a.id
|
||||
}
|
||||
|
||||
/** What a task is: a team's member, a subagent, or a background command. */
|
||||
function kindWord(a) {
|
||||
if (a.kind === "member") return t("Team member")
|
||||
if (a.type === "command") return t("Background command")
|
||||
return t("Subagent")
|
||||
}
|
||||
|
||||
async function stopAgent(a) {
|
||||
if (!chat) return
|
||||
if (a.kind === "member") {
|
||||
const yes = await ask({
|
||||
title: t("Stop %(name)s", { name: a.name }),
|
||||
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
|
||||
ok: t("Stop"),
|
||||
cancel: t("Cancel"),
|
||||
danger: true,
|
||||
})
|
||||
if (!yes) return
|
||||
}
|
||||
try {
|
||||
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
|
||||
} catch (err) {
|
||||
// 409: it had finished meanwhile; the read below shows that.
|
||||
if (err?.status !== 409) renderError(why(err))
|
||||
}
|
||||
read(false)
|
||||
}
|
||||
|
||||
// --- One agent's conversation, read-only ------------------------------------------------------------
|
||||
|
||||
function openAgent(a) {
|
||||
view = { kind: a.kind, id: a.id, name: a.name || a.id }
|
||||
$("activity").dataset.view = "agent"
|
||||
$("activity-title").textContent = view.name
|
||||
$("activity-back").hidden = false
|
||||
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||
readAgent()
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
clearTimeout(subTimer)
|
||||
view = null
|
||||
render()
|
||||
$("activity-close").focus()
|
||||
}
|
||||
|
||||
async function readAgent() {
|
||||
if (!chat || !view) return
|
||||
if (view.kind === "sub") return readSub()
|
||||
const { kind, id } = view
|
||||
let r
|
||||
try {
|
||||
r = await get(`/chats/${enc(chat.id)}/agents/${enc(kind)}/${enc(id)}`)
|
||||
} catch (err) {
|
||||
if (view?.id === id) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
|
||||
return
|
||||
}
|
||||
if (view?.kind !== kind || view?.id !== id) return
|
||||
const a = r.agent || {}
|
||||
const body = $("activity-body")
|
||||
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
|
||||
const head = h(
|
||||
"div",
|
||||
{ class: "convo__head" },
|
||||
h(
|
||||
"p",
|
||||
{ class: "agent__facts" },
|
||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||
h("span", { text: duration(a.seconds) }),
|
||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||
),
|
||||
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 = 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 || "") }))
|
||||
}
|
||||
return h(
|
||||
"article",
|
||||
{ class: "msg msg--assistant convo__turn" },
|
||||
turn.text ? h("div", { class: "md" }, markdown(String(turn.text))) : null,
|
||||
turn.tools?.length
|
||||
? h(
|
||||
"ul",
|
||||
{ class: "convo__tools" },
|
||||
turn.tools.map((c) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "convo__tool" },
|
||||
h("span", { class: `dot${c.isError ? " is-error" : " is-ok"}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "agent__verb", text: String(c.name || "") }),
|
||||
c.title ? h("code", { class: "agent__title", text: String(c.title) }) : null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
turn.error ? h("p", { class: "msg__note is-error", text: String(turn.error) }) : null,
|
||||
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Start ------------------------------------------------------------------------------------------
|
||||
|
||||
export function initActivity({ onLayout, here, compacted }) {
|
||||
layout = onLayout || layout
|
||||
onThisComputer = here || onThisComputer
|
||||
onCompacted = compacted || onCompacted
|
||||
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||
$("activity-back").addEventListener("click", closeAgent)
|
||||
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
|
||||
// panel stays; one Escape closes one thing.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
|
||||
if (!$("viewer").hidden) return
|
||||
e.preventDefault()
|
||||
if (view) closeAgent()
|
||||
else showPanel(false)
|
||||
},
|
||||
true,
|
||||
)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible" || !chat) return
|
||||
if (!stream && !retry) listenTo(chat.id)
|
||||
if (panelOpen()) read(false)
|
||||
})
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// The agent on this computer as the person sees it: one sentence for its state, and the one thing
|
||||
// to do about it -- link this computer, link it again, add a folder, start it. The same words in
|
||||
// three places: the title bar's chip (its popover), above the composer of a new agent chat, and
|
||||
// Settings. The state is the CLI's word for its link (src-tauri/src/agent.rs); "refused" is the
|
||||
// server's word only, with the server's reason.
|
||||
|
||||
import { h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen, why } from "./bridge.js"
|
||||
|
||||
let linking = null // { user_code, uri } while a link waits for its approval
|
||||
let linkError = ""
|
||||
const watchers = new Set()
|
||||
|
||||
/** Linked and allowed into a folder: what an agent chat needs. */
|
||||
export function agentReady(s) {
|
||||
const a = s?.agent || {}
|
||||
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||
return linked && !!(s.remote?.enabled && s.remote.roots?.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's state for the page: `tone` (ok, busy, warn, off), the sentence, and `action`: what
|
||||
* the one button does ("link", "relink", "folder", "start" or null).
|
||||
*/
|
||||
export function agentState(s) {
|
||||
const a = s?.agent || { state: "off" }
|
||||
const why = typeof a.detail === "string" ? a.detail : ""
|
||||
const noFolders = s?.remote && !(s.remote.enabled && s.remote.roots?.length)
|
||||
switch (a.state) {
|
||||
case "not_linked":
|
||||
return { tone: "warn", action: "link", sentence: t("This computer is not linked yet."), more: t("Link it to your server, and agent chats can work in its folders.") }
|
||||
case "refused":
|
||||
return { tone: "warn", action: "relink", sentence: t("The server refused this computer's link: %(why)s", { why: why || "…" }), more: t("Link it again to give the agent a new token.") }
|
||||
case "starting":
|
||||
return { tone: "busy", action: null, sentence: t("The agent is starting…") }
|
||||
case "connecting":
|
||||
return { tone: "busy", action: null, sentence: t("The agent is connecting to the server…") }
|
||||
case "waiting":
|
||||
return { tone: "warn", action: null, sentence: t("The agent is not connected: %(why)s. It keeps trying.", { why: why || "…" }) }
|
||||
case "failed":
|
||||
return { tone: "warn", action: s.linked ? "start" : "link", sentence: t("The agent stopped: %(why)s. Trying again shortly.", { why: why || "…" }) }
|
||||
case "elsewhere": {
|
||||
const pid = a.detail?.pid
|
||||
if (!a.detail?.linked) return { tone: "warn", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }) }
|
||||
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||
return { tone: "ok", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }) }
|
||||
}
|
||||
case "running":
|
||||
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||
return { tone: "ok", action: null, sentence: t("The agent is running: agent chats can use this computer.") }
|
||||
default:
|
||||
return { tone: "off", action: s?.linked ? "start" : "link", sentence: s?.linked ? t("The agent is off.") : t("This computer is not linked yet.") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Start linking this computer: the approval opens in the browser; the page hears `linking`. */
|
||||
export function link() {
|
||||
linkError = ""
|
||||
changed()
|
||||
return invoke("link_computer").catch((err) => {
|
||||
linkError = why(err)
|
||||
changed()
|
||||
})
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
link: () => t("Link this computer"),
|
||||
relink: () => t("Link again"),
|
||||
folder: () => t("Add folder…"),
|
||||
start: () => t("Start the agent"),
|
||||
}
|
||||
|
||||
function run(action) {
|
||||
if (action === "link" || action === "relink") return link()
|
||||
if (action === "folder") return invoke("remote_add")
|
||||
if (action === "start") return invoke("set_agent", { on: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's state as a block: its sentence, a line more where there is one, and its button;
|
||||
* while a link waits for approval, the code to compare and Cancel instead. `compact` leaves the
|
||||
* second line out.
|
||||
*/
|
||||
export function agentBlock(s, { compact = false } = {}) {
|
||||
const st = agentState(s)
|
||||
const el = h("div", { class: `agentbox is-${st.tone}`, role: "status", "aria-live": "polite" })
|
||||
el.append(h("span", { class: "agentbox__dot", "aria-hidden": "true" }), h("p", { class: "agentbox__text", text: st.sentence }))
|
||||
if (!compact && st.more) el.append(h("p", { class: "agentbox__more", text: st.more }))
|
||||
if (linking) {
|
||||
el.append(
|
||||
h("p", { class: "agentbox__more", text: t("Approve this code in the page that opened in your browser:") }),
|
||||
h("p", { class: "code agentbox__code", text: linking.user_code }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "agentbox__actions" },
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Open the page again"), onclick: () => invoke("open_external", { url: linking.uri }).catch(() => {}) }),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => invoke("link_cancel") }),
|
||||
),
|
||||
)
|
||||
} else if (st.action) {
|
||||
el.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "agentbox__actions" },
|
||||
h("button", { type: "button", class: `btn btn--sm ${st.tone === "warn" || st.tone === "off" ? "btn--primary" : ""}`, onclick: () => run(st.action) }, st.action === "link" || st.action === "relink" ? icon(ICONS.link) : null, LABELS[st.action]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (linkError) el.append(h("p", { class: "agentbox__error", text: linkError }))
|
||||
return el
|
||||
}
|
||||
|
||||
/** Something that shows `agentBlock` and draws it again when linking moves on. */
|
||||
export function onAgentChange(fn) {
|
||||
watchers.add(fn)
|
||||
return () => watchers.delete(fn)
|
||||
}
|
||||
|
||||
function changed() {
|
||||
for (const fn of watchers) fn()
|
||||
}
|
||||
|
||||
export function linkingNow() {
|
||||
return linking
|
||||
}
|
||||
|
||||
export function initAgent() {
|
||||
listen("linking", (e) => {
|
||||
const p = e.payload
|
||||
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
||||
linkError = p.state === "failed" ? p.message : ""
|
||||
changed()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// The files the next message carries. Three ways in, each the person's own act: the attach
|
||||
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
|
||||
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
|
||||
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
|
||||
// with the message that is sent next; one taken off before that is deleted on the server.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, listen, del, why } from "./bridge.js"
|
||||
import { picture } from "./models.js"
|
||||
|
||||
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
|
||||
let seq = 0
|
||||
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||
|
||||
const REASONS = {
|
||||
folder: () => t("This is a folder; attach the files in it."),
|
||||
too_large: () => t("This file is too large to send."),
|
||||
}
|
||||
|
||||
/** The ids of the files sent and ready to go with the message. */
|
||||
export function readyIds() {
|
||||
return pending.filter((p) => p.state === "ready").map((p) => p.id)
|
||||
}
|
||||
|
||||
/** A file is still being sent. */
|
||||
export function sending() {
|
||||
return pending.some((p) => p.state === "sending")
|
||||
}
|
||||
|
||||
/** After a send: the files belong to the message now. */
|
||||
export function sent() {
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
/** Another chat opened, or a new one: files never sent are deleted on the server. */
|
||||
export function discard() {
|
||||
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
function remove(p) {
|
||||
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = pending.filter((x) => x !== p)
|
||||
render()
|
||||
}
|
||||
|
||||
/**
|
||||
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
|
||||
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
|
||||
* a button to take it off. The same tracks on every tile.
|
||||
*/
|
||||
function row(p) {
|
||||
const status =
|
||||
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||
const tile = h(
|
||||
"li",
|
||||
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
|
||||
h("span", { class: "tile__icon" }, icon(ICONS.file)),
|
||||
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
|
||||
h("span", { class: "tile__size", text: status }),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "file__remove",
|
||||
"aria-label": t("Remove %(dir)s", { dir: p.name }),
|
||||
title: t("Remove"),
|
||||
disabled: p.state === "sending",
|
||||
onclick: () => remove(p),
|
||||
},
|
||||
icon(ICONS.close),
|
||||
),
|
||||
)
|
||||
return tile
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = clear($("composer-files"))
|
||||
list.className = "tiles composer__files"
|
||||
list.hidden = !pending.length
|
||||
for (const p of pending) list.append(row(p))
|
||||
}
|
||||
|
||||
/** Show `names` as being sent while `call` sends them; then what became of each. */
|
||||
async function track(names, call) {
|
||||
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
|
||||
pending.push(...placeholders)
|
||||
render()
|
||||
let results
|
||||
try {
|
||||
results = await call()
|
||||
} catch (err) {
|
||||
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
|
||||
}
|
||||
pending = pending.filter((p) => !placeholders.includes(p))
|
||||
for (const r of results || []) {
|
||||
pending.push(
|
||||
r.attachment
|
||||
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
|
||||
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||
)
|
||||
}
|
||||
render()
|
||||
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
|
||||
for (const p of pending) {
|
||||
if (p.state !== "ready" || !p.image || p.thumb) continue
|
||||
picture(`/attachments/${p.id}`).then((url) => {
|
||||
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
|
||||
p.thumb = url
|
||||
render()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
|
||||
function dataUrl(file) {
|
||||
return new Promise((resolve) => {
|
||||
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
|
||||
reader.onerror = () => resolve(undefined)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** Choose files in the system's dialog. */
|
||||
async function pick() {
|
||||
const b = $("attach")
|
||||
b.disabled = true
|
||||
try {
|
||||
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
|
||||
} finally {
|
||||
b.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
|
||||
async function paste(e) {
|
||||
const files = [...(e.clipboardData?.files || [])]
|
||||
if (!files.length || !ctx.allowed()) return
|
||||
e.preventDefault()
|
||||
for (const f of files) {
|
||||
const name = f.name || "pasted"
|
||||
track([name], async () => {
|
||||
const data = new Uint8Array(await f.arrayBuffer())
|
||||
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function initAttach(context) {
|
||||
ctx = context
|
||||
$("attach").replaceChildren(icon(ICONS.attach))
|
||||
$("attach").addEventListener("click", pick)
|
||||
$("composer-text").addEventListener("paste", paste)
|
||||
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
|
||||
listen("drag", (e) => {
|
||||
$("composer").dataset.drag = String(!!e.payload && usable())
|
||||
})
|
||||
listen("drop", (e) => {
|
||||
$("composer").dataset.drag = "false"
|
||||
if (!usable()) return
|
||||
const names = Array.isArray(e.payload) ? e.payload : []
|
||||
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether this account may attach files at all: the button shows only then. */
|
||||
export function allowAttach(on) {
|
||||
$("attach").hidden = !on
|
||||
$("composer").dataset.attach = String(on)
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
|
||||
// makes no request of its own -- the content security policy allows it none but the bridge. The
|
||||
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
|
||||
|
||||
const tauri = window.__TAURI__
|
||||
export const invoke = tauri.core.invoke
|
||||
export const listen = tauri.event.listen
|
||||
export const win = tauri.window.getCurrentWindow()
|
||||
|
||||
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
|
||||
export function api(method, path, body) {
|
||||
return invoke("api", { method, path, body: body === undefined ? null : body })
|
||||
}
|
||||
export const get = (path) => api("GET", path)
|
||||
export const post = (path, body) => api("POST", path, body ?? {})
|
||||
export const patch = (path, body) => api("PATCH", path, body)
|
||||
export const del = (path) => api("DELETE", path)
|
||||
/** A `GET` on /api/v2 that answers plain text (a shown file). Rejects with {status, detail}. */
|
||||
export const getText = (path) => invoke("api_text", { path })
|
||||
|
||||
/** An error's words, whatever shape it came in. */
|
||||
export function why(err) {
|
||||
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
|
||||
return String(err)
|
||||
}
|
||||
|
||||
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
|
||||
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
|
||||
const followers = new Map()
|
||||
const early = new Map()
|
||||
listen("stream", (e) => {
|
||||
const { id, event, data } = e.payload
|
||||
const f = followers.get(id)
|
||||
if (f) deliver(f, id, event, data)
|
||||
else {
|
||||
if (!early.has(id)) early.set(id, [])
|
||||
early.get(id).push([event, data])
|
||||
}
|
||||
})
|
||||
|
||||
function deliver(f, id, event, data) {
|
||||
if (event === "end") {
|
||||
followers.delete(id)
|
||||
f.onEnd(data)
|
||||
} else f.onFrame(event, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
|
||||
* `close()` stops it (onEnd still comes).
|
||||
*/
|
||||
export async function follow(path, { onFrame, onEnd }) {
|
||||
const id = await invoke("stream_open", { path })
|
||||
const f = { onFrame, onEnd }
|
||||
followers.set(id, f)
|
||||
for (const [event, data] of early.get(id) || []) {
|
||||
if (!followers.has(id)) break
|
||||
deliver(f, id, event, data)
|
||||
}
|
||||
early.delete(id)
|
||||
return {
|
||||
id,
|
||||
close() {
|
||||
invoke("stream_close", { id })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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])
|
||||
}
|
||||
+1509
File diff suppressed because it is too large.
Load diff
+104
@@ -0,0 +1,104 @@
|
||||
// Source in the viewer: CodeMirror, read-only, from the server's own vendored build (ui/vendor,
|
||||
// copied and pinned by scripts/fetch-vendor.sh). The look is the server's editor's: colours from
|
||||
// the theme's tokens, never CodeMirror's own. Loaded the first time a file is shown.
|
||||
|
||||
let cm = null
|
||||
|
||||
async function load() {
|
||||
if (cm) return cm
|
||||
const [state, view, language, search, commands, highlight] = await Promise.all([
|
||||
import("../vendor/cm-state.mjs"),
|
||||
import("../vendor/cm-view.mjs"),
|
||||
import("../vendor/cm-language.mjs"),
|
||||
import("../vendor/cm-search.mjs"),
|
||||
import("../vendor/cm-commands.mjs"),
|
||||
import("../vendor/lezer-highlight.mjs"),
|
||||
])
|
||||
const { EditorView } = view
|
||||
const { HighlightStyle, syntaxHighlighting } = language
|
||||
const { tags } = highlight
|
||||
// As the server's static/js/editor.js has them.
|
||||
const look = EditorView.theme({
|
||||
"&": { color: "var(--ink)", backgroundColor: "var(--code-bg)", fontSize: "var(--text-sm)", height: "100%" },
|
||||
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "var(--leading-normal)" },
|
||||
".cm-content": { caretColor: "var(--accent)" },
|
||||
".cm-gutters": { backgroundColor: "var(--code-bg)", color: "var(--ink-faint)", border: "none" },
|
||||
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--surface-hover)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--selection)" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-panels": { backgroundColor: "var(--surface)", color: "var(--ink)" },
|
||||
".cm-searchMatch": { backgroundColor: "var(--accent-soft)" },
|
||||
})
|
||||
const colours = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: "var(--accent)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "var(--leaf)" },
|
||||
{ tag: [tags.comment], color: "var(--ink-faint)", fontStyle: "italic" },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--warning)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: "var(--ink)", fontWeight: "600" },
|
||||
{ tag: [tags.heading], color: "var(--accent)", fontWeight: "700" },
|
||||
{ tag: [tags.link, tags.url], color: "var(--accent)", textDecoration: "underline" },
|
||||
{ tag: [tags.tagName, tags.typeName, tags.className], color: "var(--accent-hover)" },
|
||||
{ tag: [tags.invalid], color: "var(--danger)" },
|
||||
])
|
||||
cm = {
|
||||
state,
|
||||
view,
|
||||
base: [
|
||||
view.lineNumbers(),
|
||||
view.highlightActiveLine(),
|
||||
view.highlightActiveLineGutter(),
|
||||
view.drawSelection(),
|
||||
view.keymap.of([...search.searchKeymap, ...commands.defaultKeymap]),
|
||||
search.highlightSelectionMatches(),
|
||||
state.EditorState.readOnly.of(true),
|
||||
view.EditorView.editable.of(false),
|
||||
view.EditorView.lineWrapping,
|
||||
look,
|
||||
syntaxHighlighting(colours),
|
||||
],
|
||||
}
|
||||
return cm
|
||||
}
|
||||
|
||||
/** The language support for a name as `show` gives it; nothing for one without support. */
|
||||
async function language(name) {
|
||||
try {
|
||||
switch (name) {
|
||||
case "markdown":
|
||||
return (await import("../vendor/cm-lang-markdown.mjs")).markdown()
|
||||
case "python":
|
||||
return (await import("../vendor/cm-lang-python.mjs")).python()
|
||||
case "javascript":
|
||||
case "jsx":
|
||||
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ jsx: name === "jsx" })
|
||||
case "typescript":
|
||||
case "tsx":
|
||||
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ typescript: true, jsx: name === "tsx" })
|
||||
case "html":
|
||||
return (await import("../vendor/cm-lang-html.mjs")).html()
|
||||
case "css":
|
||||
case "scss":
|
||||
return (await import("../vendor/cm-lang-css.mjs")).css()
|
||||
case "json":
|
||||
return (await import("../vendor/cm-lang-json.mjs")).json()
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Show `text` in `parent`, read-only, at `line` when given. Returns something to `destroy()`. */
|
||||
export async function codeView(parent, text, { lang = "", line = 0 } = {}) {
|
||||
const { state, view, base } = await load()
|
||||
const ed = new view.EditorView({
|
||||
parent,
|
||||
state: state.EditorState.create({ doc: text, extensions: [base, await language(lang)] }),
|
||||
})
|
||||
if (line > 0 && line <= ed.state.doc.lines) {
|
||||
const at = ed.state.doc.line(line).from
|
||||
ed.dispatch({ selection: { anchor: at }, effects: view.EditorView.scrollIntoView(at, { y: "center" }) })
|
||||
}
|
||||
return ed
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Building the page: elements made in code, text always as text. Nothing here ever parses a
|
||||
// string as HTML -- what the server sends (a reply, a file, a title) is model output or the
|
||||
// person's own words, and is set with textContent, never innerHTML.
|
||||
|
||||
export const $ = (id) => document.getElementById(id)
|
||||
|
||||
/**
|
||||
* h("button", { class: "btn", onclick: fn, "aria-label": "…" }, "text", child, [more])
|
||||
* Attributes: `class`, `text`, `hidden`, `on<event>` (a function), `dataset` (an object), any
|
||||
* other as an attribute (false/null/undefined leave it out, true sets it empty).
|
||||
*/
|
||||
export function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag)
|
||||
for (const [key, value] of Object.entries(attrs || {})) {
|
||||
if (value === null || value === undefined || value === false) continue
|
||||
if (key === "class") el.className = value
|
||||
else if (key === "text") el.textContent = value
|
||||
else if (key === "dataset") Object.assign(el.dataset, value)
|
||||
else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value)
|
||||
else if (key === "hidden") el.hidden = !!value
|
||||
else if (key === "value") el.value = value
|
||||
else if (key === "checked") el.checked = !!value
|
||||
else if (key === "disabled") el.disabled = !!value
|
||||
else el.setAttribute(key, value === true ? "" : String(value))
|
||||
}
|
||||
append(el, children)
|
||||
return el
|
||||
}
|
||||
|
||||
/** 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)))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
export function clear(el) {
|
||||
el.replaceChildren()
|
||||
return el
|
||||
}
|
||||
|
||||
/** An inline SVG icon from a path list, drawn in currentColor. */
|
||||
export function icon(d, label) {
|
||||
const ns = "http://www.w3.org/2000/svg"
|
||||
const svg = document.createElementNS(ns, "svg")
|
||||
svg.setAttribute("viewBox", "0 0 16 16")
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.classList.add("icon")
|
||||
for (const one of [].concat(d)) {
|
||||
const p = document.createElementNS(ns, "path")
|
||||
p.setAttribute("d", one)
|
||||
p.setAttribute("fill-rule", "evenodd")
|
||||
svg.append(p)
|
||||
}
|
||||
if (label) {
|
||||
svg.removeAttribute("aria-hidden")
|
||||
svg.setAttribute("role", "img")
|
||||
svg.setAttribute("aria-label", label)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
|
||||
export const ICONS = {
|
||||
plus: "M7.25 3h1.5v4.25H13v1.5H8.75V13h-1.5V8.75H3v-1.5h4.25z",
|
||||
close: "M4.5 3.4L8 6.9l3.5-3.5 1.1 1.1L9.1 8l3.5 3.5-1.1 1.1L8 9.1l-3.5 3.5-1.1-1.1L6.9 8 3.4 4.5z",
|
||||
folder: "M1.5 3h5l1.5 1.5h6.5v8.5h-13zM3 6v5.5h10V6z",
|
||||
file: "M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z",
|
||||
chevron: "M6 3.5L10.5 8 6 12.5 4.9 11.4 8.3 8 4.9 4.6z",
|
||||
menu: "M3 4h10v1.5H3zM3 7.25h10v1.5H3zM3 10.5h10V12H3z",
|
||||
side: "M2 3h12v10H2zM3.5 4.5v7H6v-7z",
|
||||
gear: "M7 1.5h2l.4 1.8 1.1.5 1.6-1 1.4 1.4-1 1.6.5 1.1 1.8.4v2l-1.8.4-.5 1.1 1 1.6-1.4 1.4-1.6-1-1.1.5L9 14.5H7l-.4-1.8-1.1-.5-1.6 1-1.4-1.4 1-1.6-.5-1.1-1.8-.4v-2l1.8-.4.5-1.1-1-1.6 1.4-1.4 1.6 1 1.1-.5zM8 5.75a2.25 2.25 0 100 4.5 2.25 2.25 0 000-4.5z",
|
||||
refresh: "M8 2.5a5.5 5.5 0 015.2 3.7H11.5v1.5H15V4.2h-1.5v1A7 7 0 001 8h1.5A5.5 5.5 0 018 2.5zM2.5 11.8v-1h1.7A5.5 5.5 0 0013.5 8H15A7 7 0 012.5 11.8z",
|
||||
min: "M4 10h8v1.5H4z",
|
||||
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",
|
||||
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||
stop: "M4.5 4.5h7v7h-7z",
|
||||
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
|
||||
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||
send: "M8 2.25l5.03 5.03-1.06 1.06-3.22-3.22V13.5h-1.5V5.12L4.03 8.34 2.97 7.28z",
|
||||
clock: ["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 4.5h1.5v3.19l2.03 2.03-1.06 1.06-2.47-2.47z"],
|
||||
chat: "M2 2.5h12v8.5H7l-3.5 3v-3H2zM3.5 4v5.5h1.5v1.25L6.45 9.5h6.05V4z",
|
||||
bulb: ["M8 1.5a4.5 4.5 0 012.75 8.06v1.94h-5.5V9.56A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-1.83 5.38l.58.45V10h2.5V8.83l.58-.45A3 3 0 008 3z", "M5.75 12.5h4.5V14h-4.5z"],
|
||||
users: ["M6 2.5a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM6 4a1 1 0 100 2 1 1 0 000-2z", "M1.5 13.5c0-2.5 2-4.25 4.5-4.25s4.5 1.75 4.5 4.25H9c0-1.6-1.3-2.75-3-2.75S3 11.9 3 13.5z", "M11 3.5a2 2 0 110 4 2 2 0 010-4z", "M11.75 9.4c1.65.35 2.75 1.85 2.75 4.1H13c0-1.4-.55-2.4-1.6-2.7z"],
|
||||
shield: "M8 1l5.5 2v4.5c0 3.3-2.3 6-5.5 7.5-3.2-1.5-5.5-4.2-5.5-7.5V3zM4 4.05V7.5c0 2.45 1.6 4.6 4 5.85 2.4-1.25 4-3.4 4-5.85V4.05L8 2.6z",
|
||||
link: ["M6.47 8.47l2-2 1.06 1.06-2 2z", "M4.6 7.4l1.06 1.06-1.4 1.4a1.5 1.5 0 002.12 2.12l1.4-1.4 1.06 1.06-1.4 1.4a3 3 0 01-4.24-4.24z", "M11.4 8.6l-1.06-1.06 1.4-1.4a1.5 1.5 0 00-2.12-2.12l-1.4 1.4L7.16 4.36l1.4-1.4a3 3 0 014.24 4.24z"],
|
||||
pause: ["M4.5 3.5H7v9H4.5z", "M9 3.5h2.5v9H9z"],
|
||||
play: "M5 2.75L13 8l-8 5.25z",
|
||||
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"],
|
||||
}
|
||||
|
||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||
export function when(stamp) {
|
||||
if (!stamp) return ""
|
||||
const then = new Date(stamp)
|
||||
if (Number.isNaN(then.getTime())) return ""
|
||||
const seconds = Math.round((then.getTime() - Date.now()) / 1000)
|
||||
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
|
||||
const steps = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.35, "week"],
|
||||
[12, "month"],
|
||||
[Infinity, "year"],
|
||||
]
|
||||
let value = seconds
|
||||
for (const [size, unit] of steps) {
|
||||
if (Math.abs(value) < size) return rtf.format(Math.round(value), unit)
|
||||
value /= size
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** A size in bytes, as a person reads it. */
|
||||
export function bytes(n) {
|
||||
if (!Number.isFinite(n)) return ""
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i += 1
|
||||
}
|
||||
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
|
||||
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
|
||||
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.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):
|
||||
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
|
||||
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
|
||||
// A path the person cannot read is the server's 404, in its words.
|
||||
export const SERVER_FILES = {
|
||||
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
|
||||
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
|
||||
}
|
||||
|
||||
/** 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; `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)
|
||||
} catch (err) {
|
||||
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
|
||||
return
|
||||
}
|
||||
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
|
||||
for (const e of entries) {
|
||||
const row = h(
|
||||
"button",
|
||||
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
|
||||
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
|
||||
icon(isDir(e) ? ICONS.folder : ICONS.file),
|
||||
h("span", { class: "tree__name", text: e.name }),
|
||||
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||
)
|
||||
row.style.setProperty("--depth", String(depth))
|
||||
const 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 ------------------------------------------------------------------------------
|
||||
|
||||
let local = { root: "", roots: [], fixed: false }
|
||||
|
||||
/**
|
||||
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
|
||||
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
|
||||
*/
|
||||
export function showLocal({ roots, root, fixed }) {
|
||||
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
|
||||
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
|
||||
if (changed) renderLocal()
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
const head = clear($("local-where"))
|
||||
const box = $("local-tree")
|
||||
if (!local.roots.length) {
|
||||
clear(box).append(
|
||||
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (local.fixed) {
|
||||
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
|
||||
} else {
|
||||
const pick = h(
|
||||
"select",
|
||||
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
|
||||
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
|
||||
)
|
||||
head.append(pick)
|
||||
}
|
||||
const root = local.root
|
||||
return tree(box, {
|
||||
rootPath: "",
|
||||
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
||||
open: async (e) => {
|
||||
const where = t("This computer")
|
||||
try {
|
||||
const f = await invoke("local_read", { root, path: e.path })
|
||||
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
|
||||
} catch (err) {
|
||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
|
||||
export async function readLocal(root, path) {
|
||||
return invoke("local_read", { root, path })
|
||||
}
|
||||
|
||||
// --- The server ---------------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
serverSaid("")
|
||||
server = tree(box, {
|
||||
rootPath: "/",
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
|
||||
export async function openServerFile(path, name) {
|
||||
const where = t("Server")
|
||||
try {
|
||||
const f = await readServer(path)
|
||||
openViewer({
|
||||
name: f.name || name,
|
||||
path: f.path || path,
|
||||
where,
|
||||
text: f.text,
|
||||
// The server's own kind and language: its table and the viewer's are the same one.
|
||||
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
|
||||
language: f.language || undefined,
|
||||
withheld: f.withheld,
|
||||
size: f.size,
|
||||
})
|
||||
} catch (err) {
|
||||
openViewer({ name, path, where, error: why(err) })
|
||||
}
|
||||
}
|
||||
|
||||
let tab = "local"
|
||||
let serverRead = false
|
||||
|
||||
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
|
||||
export function showFilesTab(which) {
|
||||
tab = which === "server" ? "server" : "local"
|
||||
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
|
||||
$("pane-local").hidden = tab !== "local"
|
||||
$("pane-server").hidden = tab !== "server"
|
||||
if (tab === "server" && !serverRead) {
|
||||
serverRead = true
|
||||
renderServer()
|
||||
}
|
||||
}
|
||||
|
||||
export function initExplorer() {
|
||||
const tabBtn = (which, ico, label) =>
|
||||
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
|
||||
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
|
||||
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("server-refresh").addEventListener("click", renderServer)
|
||||
$("server-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)
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
// 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, post, patch, del, why } from "./bridge.js"
|
||||
import { menu, ask } from "./popup.js"
|
||||
|
||||
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) {
|
||||
return String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}+/gu, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** The list again, from the server. */
|
||||
export async function refreshList() {
|
||||
try {
|
||||
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) }))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
render()
|
||||
}
|
||||
|
||||
export function markCurrent() {
|
||||
const id = ctx.current()
|
||||
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||
}
|
||||
|
||||
/** The folder's last part: what tells one project from another at a glance. */
|
||||
export function folderName(dir) {
|
||||
const parts = String(dir || "").replace(/\/+$/, "").split("/")
|
||||
return parts[parts.length - 1] || dir || ""
|
||||
}
|
||||
|
||||
function row(c) {
|
||||
const agent = c.kind === "agent"
|
||||
const elsewhere = agent && c.device && !ctx.thisDevices().includes(c.device.token_id)
|
||||
const title = c.title || t("New chat")
|
||||
const b = h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: `chatrow${c.unread ? " is-unread" : ""}`,
|
||||
dataset: { id: c.id, kind: c.kind },
|
||||
title: agent && c.device?.directory ? `${title}\n${c.device.directory}${elsewhere ? ` · ${t("Another device")}` : ""}` : title,
|
||||
},
|
||||
h("span", { class: `chatrow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||
h("span", { class: "chatrow__title", text: title }),
|
||||
agent && c.device?.directory ? h("span", { class: "chatrow__where", text: folderName(c.device.directory) }) : h("span", { class: "chatrow__where" }),
|
||||
h("span", { class: `chatrow__state${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
||||
)
|
||||
b.addEventListener("click", () => ctx.open(c.id, c.kind))
|
||||
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault()
|
||||
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, 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
|
||||
const now = new Date()
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = clear($("chat-list"))
|
||||
const words = fold(query).split(/\s+/).filter(Boolean)
|
||||
const visible = rows
|
||||
.filter((r) => !r.archived && !r.temporary)
|
||||
.filter((r) => {
|
||||
if (!words.length) return true
|
||||
const text = fold(`${r.title || t("New chat")} ${r.device?.directory ? folderName(r.device.directory) : ""}`)
|
||||
return words.every((w) => text.includes(w))
|
||||
})
|
||||
if (!visible.length) {
|
||||
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||
return
|
||||
}
|
||||
// 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 })))
|
||||
markCurrent()
|
||||
}
|
||||
|
||||
/** The chats as the list has them (the palette's and the sidebar's first look). */
|
||||
export function listed() {
|
||||
return rows
|
||||
}
|
||||
|
||||
export function initList(context) {
|
||||
ctx = context
|
||||
$("side-search-icon").replaceChildren(icon(ICONS.search))
|
||||
$("side-search").addEventListener("input", (e) => {
|
||||
query = e.target.value
|
||||
render()
|
||||
})
|
||||
$("side-search").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && e.target.value) {
|
||||
e.stopPropagation()
|
||||
e.target.value = ""
|
||||
query = ""
|
||||
render()
|
||||
} else if (e.key === "Enter") {
|
||||
// The first match opens.
|
||||
$("chat-list").querySelector(".chatrow")?.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// The app's language and theme, as Settings chooses them.
|
||||
//
|
||||
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||
// `t()` as it is needed, so a new language opens the window again.
|
||||
//
|
||||
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||
|
||||
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||
import { terminalThemeChanged } from "./terminal.js"
|
||||
|
||||
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||
let me = null // /me, as last read
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) || ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value)
|
||||
else localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||
function known(code) {
|
||||
const c = String(code || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[-_]/)[0]
|
||||
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||
}
|
||||
|
||||
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||
export function accountThemes() {
|
||||
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||
}
|
||||
|
||||
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||
export function localTheme() {
|
||||
const name = read("lembas.theme")
|
||||
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||
}
|
||||
|
||||
/** The account's theme, as the server says it. */
|
||||
export function accountTheme() {
|
||||
return me?.theme || "moria"
|
||||
}
|
||||
|
||||
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||
export function applyTheme(name) {
|
||||
name = localTheme() || name
|
||||
if (!LOOKS.includes(name)) name = "moria"
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
return name
|
||||
}
|
||||
|
||||
function paint() {
|
||||
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||
}
|
||||
|
||||
/**
|
||||
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||
* it is now another, the window opens again in it.
|
||||
*/
|
||||
export function noteMe(next) {
|
||||
me = next || null
|
||||
paint()
|
||||
if (!me || !me.language) return
|
||||
const account = known(me.language)
|
||||
const before = read("lembas.account-language")
|
||||
write("lembas.account-language", account)
|
||||
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||
if (read("lembas.account-language") !== account) return
|
||||
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||
* server cannot be told, kept here ("" goes back to the account's).
|
||||
*/
|
||||
export async function chooseTheme(name) {
|
||||
if (accountThemes()) {
|
||||
const r = await patch("/me", { theme: name })
|
||||
me = r && r.theme ? r : { ...me, theme: name }
|
||||
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||
paint()
|
||||
}
|
||||
|
||||
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||
export function chosenLanguage() {
|
||||
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||
}
|
||||
|
||||
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||
export function accountLanguage() {
|
||||
return me?.language ? known(me.language) : ""
|
||||
}
|
||||
|
||||
/** Choose the language; the window opens again in it. */
|
||||
export function chooseLanguage(choice) {
|
||||
if (choice === chosenLanguage()) return
|
||||
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||
return reopen()
|
||||
}
|
||||
|
||||
/** Open the window again: the replies followed and the shells open let go first. */
|
||||
async function reopen() {
|
||||
await letGoAll()
|
||||
location.reload()
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar (New
|
||||
// chat, search, Scheduled, Files, the chats, Settings), and in the main area one of: a chat (a new
|
||||
// one is the same page, empty), the files, the scheduled tasks, or the settings; the activity
|
||||
// panel and the viewer beside it. Everything it knows comes from Rust: `status` (the app, the
|
||||
// agent, the server) and `api` (the server's /api/v2, as the signed-in account).
|
||||
|
||||
import { $, h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen, get, why } from "./bridge.js"
|
||||
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, openServerFile } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } 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" | "archived" | "memory" | "settings"
|
||||
let me = null
|
||||
let models = []
|
||||
let devices = [] // this account's linked devices
|
||||
let started = false
|
||||
let currentChat = null
|
||||
|
||||
const SIDE_KEY = "lembas.side"
|
||||
|
||||
const remembered = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* The device ids that are this computer: a linked device whose host is this computer's name (the
|
||||
* CLI sends it at login). Remembered, so agent chats here are still known as here while the
|
||||
* agent is off and the device is not linked right now.
|
||||
*/
|
||||
function thisDevices() {
|
||||
let changed = false
|
||||
for (const d of devices) {
|
||||
if (status?.host && d.host === status.host && !remembered.includes(d.id)) {
|
||||
remembered.push(d.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
remembered.splice(0, Math.max(0, remembered.length - 8))
|
||||
try {
|
||||
localStorage.setItem("lembas.thisDevices", JSON.stringify(remembered))
|
||||
} catch {}
|
||||
}
|
||||
return remembered.slice()
|
||||
}
|
||||
|
||||
// --- Views ------------------------------------------------------------------------------------
|
||||
|
||||
function route() {
|
||||
const ready = status.checked && status.discovery && !status.refused && status.account
|
||||
const want = ready ? "app" : "setup"
|
||||
if (want !== view) {
|
||||
view = want
|
||||
$("setup").hidden = want !== "setup"
|
||||
$("shell").hidden = want !== "app"
|
||||
// The sidebar's button keeps its place in the title bar, unseen while there is no sidebar.
|
||||
$("side-toggle").style.visibility = want === "app" ? "" : "hidden"
|
||||
if (want === "app") startApp()
|
||||
else stopApp()
|
||||
}
|
||||
if (view === "setup") renderSetup(status)
|
||||
if (view === "app") {
|
||||
if (page === "settings") renderSettings(status)
|
||||
localPane()
|
||||
statusChanged()
|
||||
accountButton()
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
started = true
|
||||
try {
|
||||
me = await get("/me")
|
||||
noteMe(me)
|
||||
voiceFor(me)
|
||||
} catch (err) {
|
||||
$("thread-note").textContent = why(err)
|
||||
$("thread-note").hidden = false
|
||||
}
|
||||
await Promise.all([loadModels(), loadDevices()])
|
||||
$("new-chat-btn").disabled = me?.permissions && me.permissions["chat.create"] === false
|
||||
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||
accountButton()
|
||||
refreshList()
|
||||
if (!openChatId()) startNew()
|
||||
invoke("remote_refresh")
|
||||
}
|
||||
|
||||
function stopApp() {
|
||||
started = false
|
||||
closeChat()
|
||||
closeViewer()
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!keep) models = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices = (await get("/devices")).devices || []
|
||||
} catch {
|
||||
devices = []
|
||||
}
|
||||
}
|
||||
|
||||
/** The sidebar's foot: Settings, with the account it is signed in as. */
|
||||
function accountButton() {
|
||||
const who = status?.account?.name || me?.name || status?.account?.email || ""
|
||||
const initial = (who.trim()[0] || "?").toUpperCase()
|
||||
$("settings-btn").replaceChildren(
|
||||
h("span", { class: "navrow__avatar", "aria-hidden": "true", text: initial }),
|
||||
h("span", { class: "navrow__two" }, h("span", { class: "navrow__label", text: t("Settings") }), who ? h("span", { class: "navrow__sub", text: who }) : null),
|
||||
h("span", { class: "navrow__icon" }, icon(ICONS.gear)),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The pages ----------------------------------------------------------------------------------
|
||||
|
||||
/** 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()
|
||||
$("shell").dataset.view = 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)
|
||||
showTerminal(false)
|
||||
}
|
||||
if (narrow()) sideOpen(false)
|
||||
}
|
||||
|
||||
/** This computer's tree: the open agent chat's folder when it runs here, else the one chosen. */
|
||||
function localPane(chat) {
|
||||
const roots = status?.remote?.enabled ? status.remote.roots : []
|
||||
const c = chat || currentChat
|
||||
const here = c?.device && thisDevices().includes(c.device.token_id)
|
||||
showLocal({ roots, root: here && roots.includes(c.device.directory) ? c.device.directory : "", fixed: false })
|
||||
}
|
||||
|
||||
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
|
||||
function startNew(kind) {
|
||||
showPage("chat")
|
||||
currentChat = null
|
||||
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. */
|
||||
function showChat(id) {
|
||||
showPage("chat")
|
||||
openChat(id).then(markCurrent)
|
||||
}
|
||||
|
||||
function showSettings(on, section) {
|
||||
if (!on) {
|
||||
showPage("chat")
|
||||
return
|
||||
}
|
||||
showPage("settings")
|
||||
renderSettings(status)
|
||||
invoke("remote_refresh")
|
||||
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)
|
||||
invoke("remote_refresh")
|
||||
}
|
||||
|
||||
/** The palette's commands: what the menus and buttons do, by name. */
|
||||
function commands() {
|
||||
const out = [
|
||||
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||
{ label: t("New chat"), run: () => startNew("chat") },
|
||||
{ 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 },
|
||||
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
function narrow() {
|
||||
return window.matchMedia("(max-width: 48rem)").matches
|
||||
}
|
||||
|
||||
/** The sidebar: shown or folded away (remembered); a drawer over the page on a narrow window. */
|
||||
function sideOpen(on) {
|
||||
$("shell").dataset.side = String(on)
|
||||
$("side-toggle").setAttribute("aria-expanded", String(on))
|
||||
if (!narrow()) {
|
||||
try {
|
||||
localStorage.setItem(SIDE_KEY, on ? "open" : "closed")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Start --------------------------------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
initAgent()
|
||||
initBar({ status: () => status, openSettings: (section) => showSettings(true, section) })
|
||||
initViewer()
|
||||
initExplorer()
|
||||
initSetup(() => status)
|
||||
initSettings(() => status)
|
||||
initList({
|
||||
thisDevices,
|
||||
current: () => openChatId(),
|
||||
open: (id) => {
|
||||
if (narrow()) sideOpen(false)
|
||||
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(),
|
||||
opened: (chat) => {
|
||||
currentChat = chat
|
||||
localPane(chat)
|
||||
markCurrent()
|
||||
},
|
||||
// Archived or deleted: the list without it, and a new chat where it was open.
|
||||
gone: (chat) => {
|
||||
if (!openChatId() || openChatId() === chat.id) {
|
||||
currentChat = null
|
||||
newChat()
|
||||
localPane()
|
||||
}
|
||||
refreshList()
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
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.
|
||||
compacted: (id) => openChatId() === id && openChat(id),
|
||||
})
|
||||
|
||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||
$("new-chat-btn").addEventListener("click", () => {
|
||||
if (narrow()) sideOpen(false)
|
||||
startNew()
|
||||
})
|
||||
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
|
||||
$("nav-scheduled").addEventListener("click", () => {
|
||||
showPage("scheduled")
|
||||
showScheduled()
|
||||
})
|
||||
$("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))
|
||||
$("settings-close").addEventListener("click", () => showSettings(false))
|
||||
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||
// A wide window starts with the sidebar as it was left; a narrow one with it closed.
|
||||
let saved = "open"
|
||||
try {
|
||||
saved = localStorage.getItem(SIDE_KEY) || "open"
|
||||
} catch {}
|
||||
sideOpen(!narrow() && saved !== "closed")
|
||||
window.matchMedia("(max-width: 48rem)").addEventListener("change", (e) => {
|
||||
if (e.matches) sideOpen(false)
|
||||
})
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && narrow() && $("shell").dataset.side === "true") sideOpen(false)
|
||||
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
const key = e.key.toLowerCase()
|
||||
// Ctrl+K: the palette. Ctrl+N: a new chat. Ctrl+,: the settings. Ctrl+B: the sidebar.
|
||||
if (mod && !e.shiftKey && !e.altKey && key === "k") {
|
||||
e.preventDefault()
|
||||
openPalette()
|
||||
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
|
||||
e.preventDefault()
|
||||
startNew()
|
||||
} else if (mod && e.key === ",") {
|
||||
e.preventDefault()
|
||||
showSettings(true)
|
||||
} else if (mod && !e.shiftKey && !e.altKey && key === "b") {
|
||||
e.preventDefault()
|
||||
sideOpen($("shell").dataset.side !== "true")
|
||||
}
|
||||
})
|
||||
|
||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed."), scheduled_pause: t("A scheduled task failed 3 times in a row. Pause it?") } })
|
||||
listen("status", (e) => {
|
||||
const before = status
|
||||
status = e.payload
|
||||
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(), 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)
|
||||
route()
|
||||
// The list keeps up with replies finished elsewhere (the web interface, the phone).
|
||||
setInterval(() => {
|
||||
if (view === "app" && document.visibilityState === "visible") refreshList()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
boot()
|
||||
@@ -0,0 +1,347 @@
|
||||
// Markdown, as a model writes it, to elements -- never to an HTML string. A reply is model
|
||||
// output and untrusted: no raw HTML passes (it shows as the text it is), no image is fetched
|
||||
// (an image is its link), and a link opens in the system's browser only when it is http, https
|
||||
// or mail. What it does draw: headings, paragraphs, emphasis, strike-through, inline code, fenced
|
||||
// code, block quotes, lists (nested), tables, rules, links and line breaks. A reply still being
|
||||
// written renders the same way; an unclosed `*` is simply a star.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
import { openLink } from "./bridge.js"
|
||||
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
|
||||
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?\s*#*\s*$/
|
||||
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
|
||||
const QUOTE = /^ {0,3}> ?/
|
||||
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
|
||||
const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
|
||||
const SAFE_LINK = /^(https?:|mailto:)/i
|
||||
|
||||
/** The rendered fragment of `source`. */
|
||||
export function markdown(source) {
|
||||
const out = document.createDocumentFragment()
|
||||
blocks(String(source ?? "").replace(/\r\n?/g, "\n").split("\n"), out)
|
||||
return out
|
||||
}
|
||||
|
||||
function indentOf(line) {
|
||||
const m = /^\s*/.exec(line)
|
||||
return m ? m[0].replace(/\t/g, " ").length : 0
|
||||
}
|
||||
|
||||
function startsBlock(line, next) {
|
||||
return (
|
||||
FENCE.test(line) ||
|
||||
HEADING.test(line) ||
|
||||
RULE.test(line) ||
|
||||
QUOTE.test(line) ||
|
||||
ITEM.test(line) ||
|
||||
(line.includes("|") && next !== undefined && TABLE_RULE.test(next) && next.includes("-"))
|
||||
)
|
||||
}
|
||||
|
||||
function blocks(lines, parent) {
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line.trim()) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const fence = FENCE.exec(line)
|
||||
if (fence) {
|
||||
const mark = fence[1]
|
||||
const body = []
|
||||
i += 1
|
||||
while (i < lines.length && !new RegExp(`^ {0,3}${mark[0]}{${mark.length},}\\s*$`).test(lines[i])) {
|
||||
body.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
i += 1 // the closing fence (or the end)
|
||||
parent.append(codeBlock(body.join("\n"), fence[2]))
|
||||
continue
|
||||
}
|
||||
const heading = HEADING.exec(line)
|
||||
if (heading) {
|
||||
parent.append(h(`h${heading[1].length}`, null, inline(heading[2] || "")))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (RULE.test(line)) {
|
||||
parent.append(h("hr"))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (QUOTE.test(line)) {
|
||||
const inner = []
|
||||
while (i < lines.length && lines[i].trim() && (QUOTE.test(lines[i]) || !startsBlock(lines[i], lines[i + 1]))) {
|
||||
inner.push(lines[i].replace(QUOTE, ""))
|
||||
i += 1
|
||||
}
|
||||
const quote = h("blockquote")
|
||||
blocks(inner, quote)
|
||||
parent.append(quote)
|
||||
continue
|
||||
}
|
||||
if (ITEM.test(line)) {
|
||||
i = list(lines, i, parent)
|
||||
continue
|
||||
}
|
||||
if (line.includes("|") && TABLE_RULE.test(lines[i + 1] || "") && (lines[i + 1] || "").includes("-")) {
|
||||
i = table(lines, i, parent)
|
||||
continue
|
||||
}
|
||||
const para = [line]
|
||||
i += 1
|
||||
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
|
||||
para.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
parent.append(h("p", null, inline(para.join("\n"))))
|
||||
}
|
||||
}
|
||||
|
||||
function codeBlock(text, lang) {
|
||||
const code = h("code", { text })
|
||||
const copy = h("button", {
|
||||
type: "button",
|
||||
class: "md-copy",
|
||||
text: t("Copy"),
|
||||
onclick: () => navigator.clipboard?.writeText(text).catch(() => {}),
|
||||
})
|
||||
return h("div", { class: "md-code" }, lang ? h("span", { class: "md-lang", text: lang }) : null, copy, h("pre", null, code))
|
||||
}
|
||||
|
||||
function list(lines, start, parent) {
|
||||
const first = ITEM.exec(lines[start])
|
||||
const base = indentOf(first[1])
|
||||
const ordered = /\d/.test(first[2])
|
||||
const el = h(ordered ? "ol" : "ul")
|
||||
if (ordered) {
|
||||
const n = parseInt(first[2], 10)
|
||||
if (n !== 1) el.setAttribute("start", String(n))
|
||||
}
|
||||
let i = start
|
||||
while (i < lines.length) {
|
||||
const m = ITEM.exec(lines[i])
|
||||
if (!m || indentOf(m[1]) !== base || /\d/.test(m[2]) !== ordered) break
|
||||
const content = [m[3]]
|
||||
const width = indentOf(m[1]) + m[2].length + 1
|
||||
i += 1
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]
|
||||
if (!l.trim()) {
|
||||
// A blank line inside an item, when the item goes on indented after it.
|
||||
if (i + 1 < lines.length && indentOf(lines[i + 1]) > base && lines[i + 1].trim()) {
|
||||
content.push("")
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
const sub = ITEM.exec(l)
|
||||
if (sub && indentOf(sub[1]) <= base) break
|
||||
if (indentOf(l) <= base && startsBlock(l, lines[i + 1])) break
|
||||
content.push(l.slice(Math.min(width, indentOf(l))))
|
||||
i += 1
|
||||
}
|
||||
const li = h("li")
|
||||
const task = /^\[([ xX])\]\s+/.exec(content[0])
|
||||
if (task) {
|
||||
content[0] = content[0].slice(task[0].length)
|
||||
li.classList.add("md-task")
|
||||
li.append(h("input", { type: "checkbox", disabled: true, checked: task[1] !== " " }))
|
||||
}
|
||||
// A tight item is its text; one with blocks in it is blocks.
|
||||
if (content.length === 1 || !content.slice(1).some((c) => startsBlock(c) || !c.trim())) {
|
||||
li.append(...inline(content.join("\n")))
|
||||
} else blocks(content, li)
|
||||
el.append(li)
|
||||
while (i < lines.length && !lines[i].trim()) {
|
||||
const next = ITEM.exec(lines[i + 1] || "")
|
||||
if (next && indentOf(next[1]) === base) i += 1
|
||||
else break
|
||||
}
|
||||
}
|
||||
parent.append(el)
|
||||
return i
|
||||
}
|
||||
|
||||
function cells(line) {
|
||||
let s = line.trim()
|
||||
if (s.startsWith("|")) s = s.slice(1)
|
||||
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
|
||||
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"))
|
||||
}
|
||||
|
||||
function table(lines, start, parent) {
|
||||
const head = cells(lines[start])
|
||||
const aligns = cells(lines[start + 1]).map((c) =>
|
||||
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "",
|
||||
)
|
||||
let i = start + 2
|
||||
const rows = []
|
||||
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
|
||||
rows.push(cells(lines[i]))
|
||||
i += 1
|
||||
}
|
||||
const cell = (tag, text, n) => {
|
||||
const c = h(tag, null, inline(text ?? ""))
|
||||
if (aligns[n]) c.dataset.align = aligns[n]
|
||||
return c
|
||||
}
|
||||
const tbl = h(
|
||||
"table",
|
||||
null,
|
||||
h("thead", null, h("tr", null, head.map((c, n) => cell("th", c, n)))),
|
||||
h("tbody", null, rows.map((r) => h("tr", null, head.map((_, n) => cell("td", r[n], n))))),
|
||||
)
|
||||
// A wide table scrolls by itself; the page does not.
|
||||
parent.append(h("div", { class: "md-table" }, tbl))
|
||||
return i
|
||||
}
|
||||
|
||||
function link(label, url) {
|
||||
const safe = SAFE_LINK.test(url)
|
||||
const a = h("a", { href: safe ? url : null, title: url, class: safe ? null : "md-unsafe" }, label)
|
||||
if (safe) {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault()
|
||||
openLink(url)
|
||||
})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/** The closing `mark` for an opening at `from`, or -1. */
|
||||
function closing(text, mark, from) {
|
||||
let at = text.indexOf(mark, from)
|
||||
while (at !== -1) {
|
||||
const before = text[at - 1]
|
||||
if (at > from && before !== " " && before !== "\\") {
|
||||
if (mark !== "_" || !/\w/.test(text[at + 1] || "")) return at
|
||||
}
|
||||
at = text.indexOf(mark, at + 1)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Inline markdown to nodes. */
|
||||
export function inline(text) {
|
||||
const out = []
|
||||
let plain = ""
|
||||
const flush = () => {
|
||||
if (plain) out.push(document.createTextNode(plain))
|
||||
plain = ""
|
||||
}
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
const c = text[i]
|
||||
const rest = text.slice(i)
|
||||
if (c === "\\" && i + 1 < text.length) {
|
||||
if (text[i + 1] === "\n") {
|
||||
flush()
|
||||
out.push(h("br"))
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (/[\\`*_{}[\]()#+\-.!|~<>]/.test(text[i + 1])) {
|
||||
plain += text[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "`") {
|
||||
const run = /^`+/.exec(rest)[0]
|
||||
const end = text.indexOf(run, i + run.length)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("code", { text: text.slice(i + run.length, end).replace(/^ (.+) $/, "$1") }))
|
||||
i = end + run.length
|
||||
continue
|
||||
}
|
||||
plain += run
|
||||
i += run.length
|
||||
continue
|
||||
}
|
||||
const img = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
|
||||
if (img) {
|
||||
flush()
|
||||
out.push(link(img[1] || img[2], img[2]))
|
||||
i += img[0].length
|
||||
continue
|
||||
}
|
||||
if (c === "[") {
|
||||
const m = /^\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
|
||||
if (m) {
|
||||
flush()
|
||||
out.push(link(inline(m[1]), m[2]))
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "<") {
|
||||
const m = /^<((?:https?:\/\/|mailto:)[^\s>]+)>/i.exec(rest)
|
||||
if (m) {
|
||||
flush()
|
||||
out.push(link(m[1], m[1]))
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ((c === "h" || c === "H") && !/\w/.test(text[i - 1] || "")) {
|
||||
const m = /^https?:\/\/[^\s<]+/i.exec(rest)
|
||||
if (m) {
|
||||
let url = m[0]
|
||||
// Trailing punctuation belongs to the sentence, and an unmatched ")" to a parenthesis.
|
||||
while (/[.,;:!?'"]$/.test(url) || (url.endsWith(")") && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length)) {
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
flush()
|
||||
out.push(link(url, url))
|
||||
i += url.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
const strong = rest.startsWith("**") ? "**" : rest.startsWith("__") && !/\w/.test(text[i - 1] || "") ? "__" : ""
|
||||
if (strong && text[i + 2] && text[i + 2] !== " ") {
|
||||
const end = closing(text, strong, i + 2)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("strong", null, inline(text.slice(i + 2, end))))
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (rest.startsWith("~~") && text[i + 2] && text[i + 2] !== " ") {
|
||||
const end = closing(text, "~~", i + 2)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("del", null, inline(text.slice(i + 2, end))))
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ((c === "*" || (c === "_" && !/\w/.test(text[i - 1] || ""))) && text[i + 1] && !/\s/.test(text[i + 1]) && text[i + 1] !== c) {
|
||||
const end = closing(text, c, i + 1)
|
||||
if (end !== -1) {
|
||||
flush()
|
||||
out.push(h("em", null, inline(text.slice(i + 1, end))))
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c === "\n") {
|
||||
if (plain.endsWith(" ")) {
|
||||
plain = plain.replace(/ +$/, "")
|
||||
flush()
|
||||
out.push(h("br"))
|
||||
} else plain += " "
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
plain += c
|
||||
i += 1
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// Memory: what the account's models remember about the person, as the web's Settings → Memory
|
||||
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
|
||||
// group chosen (where there is more than one).
|
||||
//
|
||||
// GET /memories { memories: [memory], groups: [{ id, name }] }
|
||||
// POST /memories { content, data_group_id? } -> { memory }
|
||||
// PATCH /memories/{id} { content } -> { memory }
|
||||
// DELETE /memories/{id} 204
|
||||
//
|
||||
// A record may be a model's words (`author` other than "user"): set as text, never markup.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, post, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
|
||||
let rows = []
|
||||
let groups = []
|
||||
let error = ""
|
||||
let loading = false
|
||||
let editing = "" // the record being changed
|
||||
let mark = "" // the record to bring into view (from a search hit)
|
||||
|
||||
/** The page, read again from the server; `focus` a record to bring into view. */
|
||||
export async function showMemory(focus = "") {
|
||||
mark = focus
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/memories")
|
||||
rows = r.memories || []
|
||||
groups = r.groups || []
|
||||
} catch (err) {
|
||||
rows = []
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function add(form) {
|
||||
const text = form.querySelector("textarea")
|
||||
const content = text.value.trim()
|
||||
if (!content) return text.focus()
|
||||
const group = form.querySelector("select")?.value
|
||||
form.querySelector("button[type=submit]").disabled = true
|
||||
try {
|
||||
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
|
||||
if (r?.memory) rows = [r.memory, ...rows]
|
||||
error = ""
|
||||
text.value = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
$("memory-new")?.focus()
|
||||
}
|
||||
|
||||
async function save(m, content) {
|
||||
content = content.trim()
|
||||
if (!content || content === m.content) {
|
||||
editing = ""
|
||||
return render()
|
||||
}
|
||||
try {
|
||||
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
|
||||
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
|
||||
editing = ""
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/memories/${encodeURIComponent(m.id)}`)
|
||||
rows = rows.filter((x) => x.id !== m.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(m) {
|
||||
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
|
||||
if (editing === m.id) {
|
||||
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
|
||||
box.value = m.content
|
||||
const done = () => save(m, box.value)
|
||||
box.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
editing = ""
|
||||
render()
|
||||
}
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
|
||||
})
|
||||
queueMicrotask(() => box.focus())
|
||||
return h(
|
||||
"li",
|
||||
{ class: "mrow is-editing", dataset: { id: m.id } },
|
||||
box,
|
||||
h(
|
||||
"div",
|
||||
{ class: "set__buttons" },
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
|
||||
),
|
||||
)
|
||||
}
|
||||
return h(
|
||||
"li",
|
||||
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("memory-body"))
|
||||
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
|
||||
const form = h(
|
||||
"form",
|
||||
{
|
||||
class: "mnew",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault()
|
||||
add(e.currentTarget)
|
||||
},
|
||||
},
|
||||
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "mnew__foot" },
|
||||
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
|
||||
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
|
||||
),
|
||||
)
|
||||
form.querySelector("textarea").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||
})
|
||||
parts.push(form)
|
||||
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
|
||||
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
// Models as the person picks them: each with its avatar. A model with a picture (`image` in
|
||||
// /api/v2/models) shows it, read through the bridge with the app's token and handed to the page
|
||||
// as a `data:` URL; until it arrives, and for a model without one, the avatar is what the
|
||||
// server's web interface draws then -- its initial on a colour of its own, the hue worked out
|
||||
// from the model's id exactly as the server does it (`stable_hue`: the first two bytes of the
|
||||
// id's MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
|
||||
// lightness are the theme's (app.css), only the hue is the model's.
|
||||
|
||||
import { h, icon, ICONS } from "./dom.js"
|
||||
import { invoke } from "./bridge.js"
|
||||
import { menu } from "./popup.js"
|
||||
|
||||
// Each picture once per run: its path -> a promise of its `data:` URL (null when it cannot be had,
|
||||
// so a missing picture is not asked for again with every redraw).
|
||||
const pictures = new Map()
|
||||
|
||||
export function picture(path) {
|
||||
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
||||
return pictures.get(path)
|
||||
}
|
||||
|
||||
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
|
||||
function md5(text) {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
|
||||
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
|
||||
const len = bytes.length
|
||||
const words = (((len + 8) >>> 6) + 1) * 16
|
||||
const w = new Uint32Array(words)
|
||||
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
|
||||
w[len >> 2] |= 0x80 << ((len % 4) * 8)
|
||||
w[words - 2] = (len * 8) >>> 0
|
||||
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
|
||||
let a0 = 0x67452301
|
||||
let b0 = 0xefcdab89
|
||||
let c0 = 0x98badcfe
|
||||
let d0 = 0x10325476
|
||||
for (let off = 0; off < words; off += 16) {
|
||||
let [A, B, C, D] = [a0, b0, c0, d0]
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F
|
||||
let g
|
||||
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
|
||||
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
|
||||
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
|
||||
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
|
||||
F = (F + A + K[i] + w[off + g]) >>> 0
|
||||
const s = S[(i >> 4) * 4 + (i % 4)]
|
||||
A = D
|
||||
D = C
|
||||
C = B
|
||||
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
|
||||
}
|
||||
a0 = (a0 + A) >>> 0
|
||||
b0 = (b0 + B) >>> 0
|
||||
c0 = (c0 + C) >>> 0
|
||||
d0 = (d0 + D) >>> 0
|
||||
}
|
||||
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
|
||||
}
|
||||
|
||||
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
|
||||
export function stableHue(id) {
|
||||
const d = md5(String(id))
|
||||
return ((d[0] << 8) | d[1]) % 360
|
||||
}
|
||||
|
||||
/** A model's avatar: its picture, or its initial on its own colour while there is none. */
|
||||
export function avatar(model) {
|
||||
const label = String(model?.name || model?.id || "?").trim() || "?"
|
||||
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
|
||||
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
|
||||
if (typeof model?.image === "string" && model.image) {
|
||||
picture(model.image).then((url) => {
|
||||
if (!url || !url.startsWith("data:image/")) return
|
||||
el.classList.add("avatar--image")
|
||||
el.replaceChildren(h("img", { src: url, alt: "", draggable: "false" }))
|
||||
})
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
|
||||
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
|
||||
*/
|
||||
export function modelPicker({ id, models, value, onChange, label }) {
|
||||
let current = value
|
||||
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
|
||||
const draw = () => {
|
||||
const m = models.find((x) => x.id === current)
|
||||
btn.replaceChildren(
|
||||
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
|
||||
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
|
||||
icon(ICONS.chevronDown),
|
||||
)
|
||||
btn.title = m ? `${m.name} (${m.id})` : current || ""
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
menu(
|
||||
btn,
|
||||
models.map((m) => ({
|
||||
label: m.name,
|
||||
lead: avatar(m),
|
||||
checked: m.id === current,
|
||||
run: () => {
|
||||
if (m.id === current) return
|
||||
current = m.id
|
||||
draw()
|
||||
onChange?.(m.id)
|
||||
},
|
||||
})),
|
||||
)
|
||||
})
|
||||
Object.defineProperty(btn, "value", {
|
||||
get: () => current,
|
||||
set: (v) => {
|
||||
current = v
|
||||
draw()
|
||||
},
|
||||
})
|
||||
draw()
|
||||
return btn
|
||||
}
|
||||
|
||||
// --- How hard a model thinks, and how many subagents a chat may have --------------------------------
|
||||
//
|
||||
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||
// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws
|
||||
// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or
|
||||
// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`.
|
||||
|
||||
/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */
|
||||
export function levelsOf(model) {
|
||||
const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null
|
||||
const levels = given || (model?.efforts?.length ? model.efforts : ["default"])
|
||||
const control =
|
||||
["none", "switch", "slider"].includes(model?.control) ? model.control
|
||||
: levels.length < 2 ? "none"
|
||||
: levels.length === 2 && levels.includes("off") ? "switch"
|
||||
: "slider"
|
||||
const start = levels.includes(model?.effort) ? model.effort : levels[0]
|
||||
return { levels, start, control }
|
||||
}
|
||||
|
||||
/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */
|
||||
export function levelWord(level) {
|
||||
return level === "off" ? t("off") : level === "default" ? t("default") : String(level)
|
||||
}
|
||||
|
||||
/**
|
||||
* The control for a model's levels, or null where it has one level: the Thinking switch (a real
|
||||
* checkbox with the switch role, the label its tap target) or the selector. `value` is the level
|
||||
* in force; `onChange(level)` when the person changes it. The element's `value` is the level.
|
||||
*/
|
||||
export function effortControl({ model, value, onChange, id, compact }) {
|
||||
const { levels, start, control } = levelsOf(model)
|
||||
if (control === "none") return null
|
||||
let current = levels.includes(value) ? value : start
|
||||
if (control === "switch") {
|
||||
const on = levels.find((l) => l !== "off") || start
|
||||
const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" })
|
||||
const el = h(
|
||||
"label",
|
||||
{ class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") },
|
||||
box,
|
||||
h("span", { class: "toggle__label", text: t("Think before answering") }),
|
||||
)
|
||||
box.addEventListener("change", () => {
|
||||
current = box.checked ? on : "off"
|
||||
onChange?.(current)
|
||||
})
|
||||
Object.defineProperty(el, "value", {
|
||||
get: () => current,
|
||||
set: (v) => {
|
||||
current = v
|
||||
box.checked = v !== "off"
|
||||
},
|
||||
})
|
||||
return el
|
||||
}
|
||||
const el = h(
|
||||
"select",
|
||||
{ id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") },
|
||||
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })),
|
||||
)
|
||||
el.addEventListener("change", () => {
|
||||
current = el.value
|
||||
onChange?.(current)
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new chat on `model` is offered subagents: the model's `subagents.offered` in `/models`
|
||||
* (server 2.0.0-beta.7: subagents on for the model and the instance, and at least one model could
|
||||
* serve as one -- the model itself with room for two requests at once, or a usable model outside
|
||||
* its one-model-at-a-time server). A server that does not say it: a model that calls tools and
|
||||
* serves two requests at once (`parallel` null is a hosted API with no stated limit). The server
|
||||
* decides again; this only keeps the chip away where it could never apply. An open chat has its
|
||||
* own `subagents` (chat.js).
|
||||
*/
|
||||
export function teamPossible(model) {
|
||||
const said = model?.subagents
|
||||
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
|
||||
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||
}
|
||||
|
||||
/** Whether only a model the person names in the chat could serve its subagents (`named_only`). */
|
||||
export function namedOnly(said) {
|
||||
return !!(said && typeof said === "object" && said.named_only)
|
||||
}
|
||||
|
||||
// The instance's most, as `/models` said it last (`subagents_cap`); 32 where it says nothing.
|
||||
export const SUBAGENTS_CAP = 32
|
||||
let knownCap = null
|
||||
export function noteSubagentsCap(n) {
|
||||
knownCap = Number(n) >= 1 ? Number(n) : null
|
||||
}
|
||||
/** The most subagents a chat may have: the first of `sources` that says it (`cap`), else the instance's. */
|
||||
export function subagentsCap(...sources) {
|
||||
for (const s of sources) {
|
||||
const n = Number(s?.cap)
|
||||
if (Number.isFinite(n) && n >= 1) return n
|
||||
}
|
||||
return knownCap || SUBAGENTS_CAP
|
||||
}
|
||||
// The numbers count the chat's own agent: 1 is the agent alone.
|
||||
export const SUBAGENTS_HINT = () => t("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.")
|
||||
// The spec's `subagents.setting.own`: beside the setting where the chat's model cannot serve them.
|
||||
export const SUBAGENTS_OWN = () => t("This model serves one request at a time: subagents need another model, on another server, named in the chat.")
|
||||
|
||||
// A new chat's setting as the harness spec seeds it (`loop.json` agents): 1 to 4.
|
||||
export const AGENTS_DEFAULT = { min: 1, max: 4 }
|
||||
|
||||
/**
|
||||
* The agents a chat may run, its own agent counted: one range from 1 to `cap` with two handles,
|
||||
* Min and Max -- two range inputs overlaid on one track, the span between them filled. Each is a
|
||||
* real slider (arrow keys, Home/End, Page Up/Down), and Min never passes Max. `value` is
|
||||
* { min, max } (empty for a new chat: the spec's defaults are shown, and left to the server
|
||||
* unless a handle moves); `el.value` the pair. The server checks it again.
|
||||
*/
|
||||
export function agentsControl({ value, onChange, id, cap = SUBAGENTS_CAP }) {
|
||||
const top = Math.max(1, Number(cap) || SUBAGENTS_CAP)
|
||||
const clamp = (n, d) => Math.min(top, Math.max(1, Number.isFinite(Number(n)) && n !== null && n !== "" ? Number(n) : d))
|
||||
let given = value || {}
|
||||
let touched = false
|
||||
const handle = (which, label) =>
|
||||
h("input", {
|
||||
type: "range",
|
||||
class: `range2__input range2__input--${which}`,
|
||||
id: which === "min" ? id : null,
|
||||
min: "1",
|
||||
max: String(top),
|
||||
step: "1",
|
||||
"aria-label": label,
|
||||
title: label,
|
||||
})
|
||||
const low = handle("min", t("Min"))
|
||||
const high = handle("max", t("Max"))
|
||||
const said = h("output", { class: "range2__said", "aria-live": "polite", for: id || null })
|
||||
const fill = h("span", { class: "range2__fill", "aria-hidden": "true" })
|
||||
const box = h("span", { class: "range2__box" }, h("span", { class: "range2__track", "aria-hidden": "true" }, fill), low, high)
|
||||
const draw = () => {
|
||||
const lo = Number(low.value)
|
||||
const hi = Number(high.value)
|
||||
const at = (n) => (top > 1 ? ((n - 1) / (top - 1)) * 100 : 0)
|
||||
box.style.setProperty("--lo", `${at(lo)}%`)
|
||||
box.style.setProperty("--hi", `${at(hi)}%`)
|
||||
// Where the two meet, the one that can still move is on top: Min at the far end, else Max.
|
||||
low.classList.toggle("is-top", lo === hi && hi === top)
|
||||
low.setAttribute("aria-valuetext", `${t("Min")} ${lo}`)
|
||||
high.setAttribute("aria-valuetext", `${t("Max")} ${hi}`)
|
||||
said.textContent = `${t("Min")} ${lo} · ${t("Max")} ${hi}`
|
||||
}
|
||||
const set = (v) => {
|
||||
const hi = clamp(v?.max, Math.min(AGENTS_DEFAULT.max, top))
|
||||
const lo = Math.min(hi, clamp(v?.min, AGENTS_DEFAULT.min))
|
||||
high.value = String(hi)
|
||||
low.value = String(lo)
|
||||
draw()
|
||||
}
|
||||
low.addEventListener("input", () => {
|
||||
if (Number(low.value) > Number(high.value)) low.value = high.value
|
||||
touched = true
|
||||
draw()
|
||||
})
|
||||
high.addEventListener("input", () => {
|
||||
if (Number(high.value) < Number(low.value)) high.value = low.value
|
||||
touched = true
|
||||
draw()
|
||||
})
|
||||
for (const input of [low, high]) input.addEventListener("change", () => onChange?.(read()))
|
||||
const read = () => (touched || given.min || given.max ? { min: Number(low.value), max: Number(high.value) } : {})
|
||||
set(given)
|
||||
const el = h(
|
||||
"span",
|
||||
{ class: "range2", role: "group", "aria-label": t("Subagents") },
|
||||
said,
|
||||
box,
|
||||
h("span", { class: "range2__ends", "aria-hidden": "true" }, h("span", { text: "1" }), h("span", { text: String(top) })),
|
||||
)
|
||||
Object.defineProperty(el, "value", {
|
||||
get: read,
|
||||
set: (v) => {
|
||||
given = v || {}
|
||||
touched = false
|
||||
set(given)
|
||||
},
|
||||
})
|
||||
return el
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
||||
// 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(chat), openFile(path, name), openMemory(id) }
|
||||
let dlg = null
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
function fold(text) {
|
||||
return String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}+/gu, "")
|
||||
.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))
|
||||
}
|
||||
|
||||
export async function openPalette() {
|
||||
if (dlg) return
|
||||
const input = h("input", { type: "text", class: "palette__input", placeholder: t("Type a command or a chat's title"), "aria-label": t("Search"), role: "combobox", "aria-expanded": "true", "aria-controls": "palette-list", autocomplete: "off" })
|
||||
const list = h("ul", { class: "palette__list", id: "palette-list", role: "listbox" })
|
||||
dlg = h("dialog", { class: "dialog palette", "aria-label": t("Commands") }, h("div", { class: "palette__line" }, icon(ICONS.search), input), list)
|
||||
document.body.append(dlg)
|
||||
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()
|
||||
dlg = null
|
||||
}
|
||||
const choose = (item) => {
|
||||
close()
|
||||
item?.run()
|
||||
}
|
||||
const draw = () => {
|
||||
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
||||
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
||||
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 })),
|
||||
...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.") }))
|
||||
shown.forEach((item, i) => {
|
||||
list.append(
|
||||
h(
|
||||
"li",
|
||||
{
|
||||
id: `palette-${i}`,
|
||||
role: "option",
|
||||
class: "palette__item",
|
||||
"aria-selected": String(i === at),
|
||||
onclick: () => choose(item),
|
||||
onpointermove: () => {
|
||||
if (at !== i) {
|
||||
at = i
|
||||
mark()
|
||||
}
|
||||
},
|
||||
},
|
||||
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 }),
|
||||
),
|
||||
)
|
||||
})
|
||||
mark()
|
||||
}
|
||||
const mark = () => {
|
||||
list.querySelectorAll(".palette__item").forEach((el, i) => el.setAttribute("aria-selected", String(i === at)))
|
||||
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") {
|
||||
e.preventDefault()
|
||||
if (!shown.length) return
|
||||
at = (at + (e.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length
|
||||
mark()
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
choose(shown[at])
|
||||
}
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
close()
|
||||
})
|
||||
dlg.addEventListener("click", (e) => {
|
||||
if (e.target === dlg) close()
|
||||
})
|
||||
dlg.showModal()
|
||||
input.focus()
|
||||
draw()
|
||||
try {
|
||||
const r = await get("/chats?limit=100")
|
||||
chats = (r.chats || []).filter((c) => !c.archived && !c.temporary)
|
||||
if (dlg) draw()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function initPalette(context) {
|
||||
ctx = context
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
|
||||
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
|
||||
// click outside close them; the focus goes back where it came from.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
|
||||
let openMenu = null
|
||||
|
||||
/** Close the menu that is open, if any. */
|
||||
export function closeMenu() {
|
||||
if (!openMenu) return
|
||||
const { el, back } = openMenu
|
||||
openMenu = null
|
||||
el.remove()
|
||||
document.removeEventListener("pointerdown", outside, true)
|
||||
back?.focus?.()
|
||||
}
|
||||
|
||||
function outside(e) {
|
||||
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu of `items` ({ label, hint?, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||
*/
|
||||
export function menu(anchor, items) {
|
||||
closeMenu()
|
||||
closePopover()
|
||||
const plain = !items.some((i) => i?.lead)
|
||||
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
el.append(h("div", { class: "menu__sep", role: "separator" }))
|
||||
continue
|
||||
}
|
||||
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
|
||||
// labels line up whether or not an item has a lead.
|
||||
el.append(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: item.checked === undefined ? "menuitem" : "menuitemradio",
|
||||
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
|
||||
class: `menu__item${item.danger ? " is-danger" : ""}`,
|
||||
disabled: !!item.disabled,
|
||||
onclick: () => {
|
||||
closeMenu()
|
||||
item.run()
|
||||
},
|
||||
},
|
||||
h("span", { class: "menu__lead" }, item.lead || null),
|
||||
item.hint
|
||||
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
|
||||
: h("span", { class: "menu__label", text: item.label }),
|
||||
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
|
||||
const i = all.indexOf(document.activeElement)
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeMenu()
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
all[(i + 1) % all.length]?.focus()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
all[(i - 1 + all.length) % all.length]?.focus()
|
||||
}
|
||||
})
|
||||
document.body.append(el)
|
||||
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
|
||||
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
|
||||
const r = el.getBoundingClientRect()
|
||||
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
||||
el.style.width = `${Math.ceil(r.width)}px`
|
||||
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
|
||||
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
|
||||
el.style.top = above ? `${at.top - r.height}px` : `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
||||
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||
document.addEventListener("pointerdown", outside, true)
|
||||
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
|
||||
* buttons. Resolves with the line (or true) on OK, null on cancel.
|
||||
*/
|
||||
export function ask({ title, text, value, ok, cancel, danger }) {
|
||||
return new Promise((resolve) => {
|
||||
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
|
||||
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "dialog__body", method: "dialog" },
|
||||
h("h2", { class: "dialog__title", text: title }),
|
||||
text ? h("p", { class: "muted", text }) : null,
|
||||
input,
|
||||
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
|
||||
)
|
||||
const dlg = h("dialog", { class: "dialog" }, form)
|
||||
let settled = false
|
||||
const done = (v) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
resolve(v)
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault()
|
||||
if (input && !input.value.trim()) return input.focus()
|
||||
done(input ? input.value.trim() : true)
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
done(null)
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
} else okBtn.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
|
||||
* it is and selectable), and Copy beside Close. Resolves when it is closed.
|
||||
*/
|
||||
export function showLine({ title, text, code, copy, copied, close }) {
|
||||
return new Promise((resolve) => {
|
||||
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
|
||||
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
|
||||
const closeBtn = h("button", { type: "button", class: "btn", text: close })
|
||||
const dlg = h(
|
||||
"dialog",
|
||||
{ class: "dialog" },
|
||||
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
|
||||
)
|
||||
const done = () => {
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
resolve()
|
||||
}
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard
|
||||
?.writeText(code)
|
||||
.then(() => (copyBtn.textContent = copied))
|
||||
.catch(() => {
|
||||
// No clipboard: the line is selected, for Ctrl+C.
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(line)
|
||||
getSelection()?.removeAllRanges()
|
||||
getSelection()?.addRange(range)
|
||||
})
|
||||
})
|
||||
closeBtn.addEventListener("click", done)
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
done()
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
copyBtn.focus()
|
||||
})
|
||||
}
|
||||
|
||||
let openPop = null
|
||||
|
||||
/** Close the popover that is open, if any. */
|
||||
export function closePopover() {
|
||||
if (!openPop) return
|
||||
const { el, back, onClose } = openPop
|
||||
openPop = null
|
||||
el.remove()
|
||||
document.removeEventListener("pointerdown", outsidePop, true)
|
||||
document.removeEventListener("keydown", escPop, true)
|
||||
onClose?.()
|
||||
back?.focus?.()
|
||||
}
|
||||
|
||||
function outsidePop(e) {
|
||||
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
|
||||
}
|
||||
|
||||
function escPop(e) {
|
||||
if (e.key === "Escape" && openPop) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closePopover()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
|
||||
closePopover()
|
||||
closeMenu()
|
||||
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
|
||||
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
|
||||
draw()
|
||||
document.body.append(el)
|
||||
const place = () => {
|
||||
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`
|
||||
const top = up ? at.top - r.height - 6 : at.bottom + 6
|
||||
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||
}
|
||||
place()
|
||||
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()
|
||||
return {
|
||||
redraw() {
|
||||
if (openPop?.el !== el) return
|
||||
draw()
|
||||
place()
|
||||
},
|
||||
close: closePopover,
|
||||
}
|
||||
}
|
||||
|
||||
export function popoverOpen() {
|
||||
return !!openPop
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
|
||||
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
|
||||
// task's page (what it does, when, its settings, the next runs, its history with each run's
|
||||
// result, Run now, edit, pause, delete), and a task said in words: the server reads them into a
|
||||
// rule and an instruction (compile), the next runs are shown, the settings chosen, and only then
|
||||
// is it saved. The server runs them; this page only reads and asks.
|
||||
//
|
||||
// The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract
|
||||
// fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`):
|
||||
//
|
||||
// GET /schedules { schedules: [schedule] }, newest first
|
||||
// POST /schedules { title, instruction, request, rule, model, notify,
|
||||
// mode, effort, agents } -> 201 { schedule }
|
||||
// GET /schedules/{id} { schedule }
|
||||
// PATCH /schedules/{id} any of the create fields -> { schedule }
|
||||
// DELETE /schedules/{id}?keep_chat=true 204
|
||||
// POST /schedules/{id}/pause | /resume { schedule }
|
||||
// POST /schedules/{id}/run 202 { run }: now, not counted against the rule
|
||||
// GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first
|
||||
// GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted)
|
||||
// POST /schedules/compile { text } -> { ok, reason, title, instruction,
|
||||
// target, rule, description, next_runs, timezone }
|
||||
// POST /schedules/preview { rule } -> { rule, description, next_runs, timezone }
|
||||
// GET /schedules/models { models: [model + free, busy, chosen], instance, mine }
|
||||
// PUT /schedules/models { models: [id] }: the person's narrowing
|
||||
// GET /events `schedules` frames, { version, schedules }: followed
|
||||
// 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.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { api, get, post, patch, del, why, listen } from "./bridge.js"
|
||||
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(), 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
|
||||
let shown = null // the task whose page is open: { id, last, running }
|
||||
|
||||
const STATUS = {
|
||||
ok: () => t("Done"),
|
||||
silent: () => t("Nothing to report"),
|
||||
failed: () => t("Failed"),
|
||||
missed: () => t("Missed"),
|
||||
skipped: () => t("Skipped"),
|
||||
waiting: () => t("Waiting for a free model"),
|
||||
running: () => t("Running"),
|
||||
}
|
||||
|
||||
const NOTIFY = {
|
||||
matters: () => t("When it matters"),
|
||||
always: () => t("Always"),
|
||||
never: () => t("Never"),
|
||||
}
|
||||
|
||||
const NOTIFY_HINT = {
|
||||
matters: () => t("A result, but not a run with nothing to report."),
|
||||
always: () => t("Every result, even with nothing to report."),
|
||||
never: () => t("Only in its history."),
|
||||
}
|
||||
|
||||
const MODE = {
|
||||
fresh: () => t("A new chat for each run"),
|
||||
continue: () => t("One chat that grows"),
|
||||
}
|
||||
|
||||
const TIMING = {
|
||||
late: () => t("late"),
|
||||
catch_up: () => t("caught up"),
|
||||
manual: () => t("run by hand"),
|
||||
}
|
||||
|
||||
const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS
|
||||
const enc = encodeURIComponent
|
||||
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 = 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}` })
|
||||
}
|
||||
|
||||
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
|
||||
function stamp(value) {
|
||||
const d = new Date(value)
|
||||
if (!value || Number.isNaN(d.getTime())) return ""
|
||||
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
function nextWords(task) {
|
||||
if (!task.enabled) return t("Paused")
|
||||
if (!task.next_run) return t("No more runs")
|
||||
return `${when(task.next_run)} · ${stamp(task.next_run)}`
|
||||
}
|
||||
|
||||
function onPage() {
|
||||
return !$("page-scheduled").hidden
|
||||
}
|
||||
|
||||
// --- Reading ------------------------------------------------------------------------------------
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tasks = (await get("/schedules")).schedules || []
|
||||
missing = false
|
||||
} catch (err) {
|
||||
tasks = []
|
||||
missing = err?.status === 404 || err?.status === 405
|
||||
if (!missing) throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** The scheduling models, in the order they are tried; null when the server has none to say. */
|
||||
async function schedulingModels() {
|
||||
try {
|
||||
return await get("/schedules/models")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the page: the list, or one task's page (`id`). */
|
||||
export async function showScheduled(id) {
|
||||
view = id ? { name: "task", id } : { name: "list" }
|
||||
await draw()
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
const body = clear($("scheduled-body"))
|
||||
clear($("scheduled-tools"))
|
||||
clear($("scheduled-place"))
|
||||
shown = null
|
||||
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
try {
|
||||
if (view.name === "task") await drawTask(view.id)
|
||||
else if (view.name === "form") await drawForm(view.task)
|
||||
else {
|
||||
await load()
|
||||
drawList()
|
||||
}
|
||||
} catch (err) {
|
||||
clear(body).append(h("p", { class: "status is-error", text: why(err) }))
|
||||
}
|
||||
}
|
||||
|
||||
/** The page's title; with a way back, "Scheduled ›" before it. */
|
||||
function header(title, back) {
|
||||
const h1 = $("scheduled-title")
|
||||
if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title }))
|
||||
else h1.textContent = title
|
||||
}
|
||||
|
||||
function go(next) {
|
||||
view = next
|
||||
draw()
|
||||
}
|
||||
|
||||
// --- The list -------------------------------------------------------------------------------------
|
||||
|
||||
function drawList() {
|
||||
header(t("Scheduled"))
|
||||
const tools = clear($("scheduled-tools"))
|
||||
const body = clear($("scheduled-body"))
|
||||
if (missing) {
|
||||
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
|
||||
return
|
||||
}
|
||||
tools.append(
|
||||
h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })),
|
||||
)
|
||||
if (!tasks.length) {
|
||||
body.append(
|
||||
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
|
||||
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
|
||||
)
|
||||
return
|
||||
}
|
||||
const list = h("ul", { class: "tasks-list", role: "list" })
|
||||
for (const task of tasks) list.append(taskRow(task))
|
||||
body.append(list)
|
||||
}
|
||||
|
||||
function empty(title, text) {
|
||||
return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text }))
|
||||
}
|
||||
|
||||
/** One task: icon · title and its rule in words · next run · last status · pause switch. */
|
||||
function taskRow(task) {
|
||||
const toggle = h("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
checked: !!task.enabled,
|
||||
"aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }),
|
||||
title: task.enabled ? t("Pause") : t("Resume"),
|
||||
onclick: (e) => e.stopPropagation(),
|
||||
onchange: (e) => setEnabled(task, e.target.checked),
|
||||
})
|
||||
return h(
|
||||
"li",
|
||||
{ class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } },
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
|
||||
h("span", { class: "task__icon" }, icon(ICONS.clock)),
|
||||
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })),
|
||||
h(
|
||||
"span",
|
||||
{ class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" },
|
||||
h("span", { class: "task__label", text: t("Next run") }),
|
||||
h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }),
|
||||
),
|
||||
h("span", { class: "task__last" }, statusChip(task.last_run, task.running)),
|
||||
),
|
||||
h("label", { class: "toggle task__toggle" }, toggle),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pause or resume: the server's own verbs, which answer the schedule as it now is. */
|
||||
async function setEnabled(task, on) {
|
||||
try {
|
||||
const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`)
|
||||
Object.assign(task, r.schedule || { enabled: on })
|
||||
} catch (err) {
|
||||
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||
}
|
||||
if (view.name === "list") drawList()
|
||||
else draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheduling models: the ones scheduled work may run on, in the order they are tried, each
|
||||
* free now or busy (and why). A task without a model of its own runs on the first free one of
|
||||
* those ticked; ticking none means all of them.
|
||||
*/
|
||||
async function modelsPanel(anchor) {
|
||||
const data = await schedulingModels()
|
||||
if (!data) return
|
||||
let mine = new Set(data.mine || [])
|
||||
const said = h("p", { class: "status is-error", hidden: true })
|
||||
const save = async () => {
|
||||
try {
|
||||
const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) })
|
||||
Object.assign(data, r)
|
||||
mine = new Set(r.mine || [])
|
||||
said.hidden = true
|
||||
} catch (err) {
|
||||
said.textContent = why(err)
|
||||
said.hidden = false
|
||||
}
|
||||
pop.redraw()
|
||||
}
|
||||
const content = () =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "pop__body smodels" },
|
||||
h("p", { class: "pop__title", text: t("Models for scheduled tasks") }),
|
||||
h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }),
|
||||
h(
|
||||
"ul",
|
||||
{ class: "smodels__list", role: "list" },
|
||||
data.models.map((m) =>
|
||||
h(
|
||||
"li",
|
||||
null,
|
||||
h(
|
||||
"label",
|
||||
{ class: "smodel" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: mine.has(m.id),
|
||||
onchange: (e) => {
|
||||
if (e.target.checked) mine.add(m.id)
|
||||
else mine.delete(m.id)
|
||||
save()
|
||||
},
|
||||
}),
|
||||
avatar(m),
|
||||
h("span", { class: "smodel__name", text: m.name }),
|
||||
h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }),
|
||||
said,
|
||||
)
|
||||
const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") })
|
||||
}
|
||||
|
||||
// --- One task -----------------------------------------------------------------------------------
|
||||
|
||||
async function drawTask(id) {
|
||||
const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)])
|
||||
const task = r.schedule
|
||||
shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running }
|
||||
header(task.title || t("Scheduled"), () => go({ name: "list" }))
|
||||
const tools = clear($("scheduled-tools"))
|
||||
tools.append(
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
|
||||
h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool",
|
||||
"aria-label": t("More"),
|
||||
title: t("More"),
|
||||
onclick: (e) =>
|
||||
menu(e.currentTarget, [
|
||||
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
|
||||
...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []),
|
||||
null,
|
||||
{ label: t("Delete"), danger: true, run: () => remove(task) },
|
||||
]),
|
||||
},
|
||||
icon(ICONS.more),
|
||||
),
|
||||
)
|
||||
const [smodels, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||
const models = smodels?.models || ctx.models()
|
||||
const body = clear($("scheduled-body"))
|
||||
body.append(
|
||||
h(
|
||||
"section",
|
||||
{ class: "sched" },
|
||||
task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null,
|
||||
h(
|
||||
"div",
|
||||
{ class: "sched__facts" },
|
||||
fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""),
|
||||
fact(t("Next run"), h("span", { text: nextWords(task) })),
|
||||
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
||||
fact(t("Model"), modelWords(models, task.model)),
|
||||
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
||||
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 || []),
|
||||
),
|
||||
history(task, page.runs || [], page.more),
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
|
||||
function modelWords(models, id) {
|
||||
if (!id) return h("span", { text: t("The first free scheduling model") })
|
||||
const m = models.find((x) => x.id === id)
|
||||
return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id })
|
||||
}
|
||||
|
||||
function upcoming(next) {
|
||||
if (!next.length) return null
|
||||
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
|
||||
}
|
||||
|
||||
/** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */
|
||||
function history(task, runs, more) {
|
||||
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
|
||||
if (!runs.length) {
|
||||
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
|
||||
return box
|
||||
}
|
||||
const list = h("ul", { class: "runs" })
|
||||
const add = (page) => {
|
||||
for (const run of page) list.append(runRow(task, run))
|
||||
}
|
||||
add(runs)
|
||||
box.append(list)
|
||||
if (more) {
|
||||
const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") })
|
||||
earlier.addEventListener("click", async () => {
|
||||
earlier.disabled = true
|
||||
try {
|
||||
const last = list.lastElementChild?.dataset.id || ""
|
||||
const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`)
|
||||
add(r.runs || [])
|
||||
if (!r.more) earlier.remove()
|
||||
else earlier.disabled = false
|
||||
} catch (err) {
|
||||
earlier.disabled = false
|
||||
await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||
}
|
||||
})
|
||||
box.append(earlier)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
/** What a run that did not finish well says: waiting and until when, or its error. */
|
||||
function runNote(run) {
|
||||
if (run.status === "waiting") {
|
||||
const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : ""
|
||||
return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ")
|
||||
}
|
||||
return run.error?.text || ""
|
||||
}
|
||||
|
||||
function runRow(task, run) {
|
||||
const model = run.model || {}
|
||||
const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : ""
|
||||
const hasResult = run.message_id || run.report_path || run.preview
|
||||
const detail = h("div", { class: "run__detail", hidden: true })
|
||||
const open = hasResult
|
||||
? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") })
|
||||
: null
|
||||
open?.addEventListener("click", async () => {
|
||||
const opening = detail.hidden
|
||||
open.setAttribute("aria-expanded", String(opening))
|
||||
if (!opening) {
|
||||
detail.hidden = true
|
||||
return
|
||||
}
|
||||
detail.hidden = false
|
||||
detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") }))
|
||||
try {
|
||||
const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`)
|
||||
detail.replaceChildren(
|
||||
r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }),
|
||||
r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null,
|
||||
r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null,
|
||||
)
|
||||
} catch (err) {
|
||||
detail.replaceChildren(h("p", { class: "status is-error", text: why(err) }))
|
||||
}
|
||||
})
|
||||
const note = runNote(run)
|
||||
return h(
|
||||
"li",
|
||||
{ class: "run", dataset: { id: run.id } },
|
||||
h("span", { class: "run__status" }, statusChip(run)),
|
||||
h(
|
||||
"span",
|
||||
{ class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" },
|
||||
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||
),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
async function runNow(task, btn) {
|
||||
btn.disabled = true
|
||||
try {
|
||||
await post(`/schedules/${enc(task.id)}/run`)
|
||||
await draw()
|
||||
} catch (err) {
|
||||
btn.disabled = false
|
||||
await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(task) {
|
||||
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/schedules/${enc(task.id)}?keep_chat=true`)
|
||||
go({ name: "list" })
|
||||
} catch (err) {
|
||||
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||
}
|
||||
}
|
||||
|
||||
// --- A task said in words: new, or an existing one edited ---------------------------------------
|
||||
|
||||
/**
|
||||
* The form. The words first: Read them asks the server (compile) for the rule, its title and
|
||||
* instruction, and the next runs; the title and instruction can then be changed. Below, the
|
||||
* settings the server keeps per task. An existing task starts from what it is: its words, title,
|
||||
* instruction, and its rule read back (preview) -- a paused task's next runs too.
|
||||
*/
|
||||
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 [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 || "",
|
||||
title: task?.title || "",
|
||||
instruction: task?.instruction || "",
|
||||
rule: task?.rule || null,
|
||||
target: task?.target || "chat",
|
||||
model: task?.model || "",
|
||||
effort: task?.effort || "",
|
||||
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
|
||||
|
||||
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request })
|
||||
const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") })
|
||||
const said = h("p", { class: "status is-error", hidden: true })
|
||||
const when_ = h("span", { class: "sched__desc", id: "sched-when" })
|
||||
const zone = h("p", { class: "hint" })
|
||||
const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") })
|
||||
const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction })
|
||||
const settings = h("div", { class: "fields sched__settings" })
|
||||
const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") })
|
||||
const saveSaid = h("p", { class: "status is-error", hidden: true })
|
||||
const nextBox = h("div", { class: "sched__nextbox" })
|
||||
const manual = manualRule((rule) => {
|
||||
post("/schedules/preview", { rule })
|
||||
.then((r) => {
|
||||
reading = r
|
||||
draft.rule = r.rule || {}
|
||||
drawWhen()
|
||||
})
|
||||
.catch((err) => {
|
||||
saveSaid.textContent = why(err)
|
||||
saveSaid.hidden = false
|
||||
})
|
||||
})
|
||||
const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) })
|
||||
const rest = h(
|
||||
"div",
|
||||
{ class: "sched__card", hidden: !editing },
|
||||
h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)),
|
||||
manualBtn,
|
||||
manual,
|
||||
nextBox,
|
||||
h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }),
|
||||
instruction,
|
||||
h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }),
|
||||
settings,
|
||||
saveSaid,
|
||||
h("div", { class: "set__buttons" }, saveBtn),
|
||||
)
|
||||
|
||||
const drawWhen = () => {
|
||||
const r = reading
|
||||
const next = r?.next_runs || []
|
||||
const runs = !!(draft.rule && Object.keys(draft.rule).length)
|
||||
when_.textContent = (runs && r?.description) || t("These words give no run in the future.")
|
||||
when_.title = when_.textContent
|
||||
when_.classList.toggle("is-error", !runs)
|
||||
zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : ""
|
||||
nextBox.replaceChildren(upcoming(next) || "")
|
||||
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) =>
|
||||
h(
|
||||
"select",
|
||||
{ id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) },
|
||||
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })),
|
||||
)
|
||||
const modelSelect = h(
|
||||
"select",
|
||||
{ id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) },
|
||||
h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }),
|
||||
smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })),
|
||||
draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null,
|
||||
)
|
||||
const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : []
|
||||
const effort = h(
|
||||
"select",
|
||||
{ id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) },
|
||||
h("option", { value: "", text: t("The model's own"), selected: !draft.effort }),
|
||||
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })),
|
||||
// A level the task has that its model no longer offers stays, as it is, until changed.
|
||||
draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null,
|
||||
)
|
||||
const agents = h(
|
||||
"select",
|
||||
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
||||
h("option", { value: "", text: t("None"), selected: !draft.agents }),
|
||||
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"),
|
||||
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
||||
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
|
||||
)
|
||||
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
||||
}
|
||||
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "sched sched--new" },
|
||||
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
|
||||
words,
|
||||
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
|
||||
h("div", { class: "set__buttons" }, readBtn),
|
||||
said,
|
||||
rest,
|
||||
)
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
const text = words.value.trim()
|
||||
if (!text) return words.focus()
|
||||
readBtn.disabled = true
|
||||
said.hidden = true
|
||||
try {
|
||||
const c = await post("/schedules/compile", { text })
|
||||
draft.request = text
|
||||
draft.rule = c.rule || {}
|
||||
if (c.title || !editing) title.value = c.title || ""
|
||||
if (c.instruction || !editing) instruction.value = c.instruction || text
|
||||
if (c.target) draft.target = c.target
|
||||
reading = c
|
||||
if (!c.ok && c.reason) {
|
||||
said.textContent = c.reason
|
||||
said.hidden = false
|
||||
}
|
||||
// The words gave no rule: the form for one, rather than a task that cannot run.
|
||||
if (!(c.rule && Object.keys(c.rule).length)) {
|
||||
manual.hidden = false
|
||||
manualBtn.hidden = true
|
||||
}
|
||||
rest.hidden = false
|
||||
drawWhen()
|
||||
;(c.ok ? saveBtn : instruction).focus()
|
||||
} catch (err) {
|
||||
said.textContent = why(err)
|
||||
said.hidden = false
|
||||
} finally {
|
||||
readBtn.disabled = false
|
||||
}
|
||||
})
|
||||
words.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||
})
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true
|
||||
saveSaid.hidden = true
|
||||
const body = {
|
||||
request: draft.request || words.value.trim(),
|
||||
title: title.value.trim(),
|
||||
instruction: instruction.value.trim(),
|
||||
rule: draft.rule,
|
||||
target: draft.target,
|
||||
model: draft.model,
|
||||
effort: draft.effort,
|
||||
agents: draft.agents,
|
||||
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 })
|
||||
} catch (err) {
|
||||
saveSaid.textContent = why(err)
|
||||
saveSaid.hidden = false
|
||||
saveBtn.disabled = false
|
||||
}
|
||||
})
|
||||
clear($("scheduled-body")).append(form)
|
||||
drawSettings()
|
||||
drawWhen()
|
||||
words.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule set by hand, for words the server could not read into one: the days of the week (none
|
||||
* ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0).
|
||||
* `onRule(rule)` on every change; the server reads it back (preview).
|
||||
*/
|
||||
function manualRule(onRule) {
|
||||
const lang = document.documentElement.lang || undefined
|
||||
const days = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday
|
||||
return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) }))
|
||||
})
|
||||
const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true })
|
||||
const box = h(
|
||||
"div",
|
||||
{ class: "sched__manual", hidden: true },
|
||||
h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")),
|
||||
)
|
||||
const changed = () => {
|
||||
if (!time.value) return
|
||||
const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value))
|
||||
onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } })
|
||||
}
|
||||
box.addEventListener("change", changed)
|
||||
return box
|
||||
}
|
||||
|
||||
/** A field on the form's shared tracks: label, control, hint -- always all three. */
|
||||
function field(label, control, hint, forId, hintId) {
|
||||
return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" }))
|
||||
}
|
||||
|
||||
// --- Live: the list as the server's event stream gives it ---------------------------------------
|
||||
|
||||
/** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */
|
||||
function onSchedules(list) {
|
||||
if (!Array.isArray(list?.schedules)) return
|
||||
tasks = list.schedules
|
||||
missing = false
|
||||
if (!onPage() || popoverOpenHere()) return
|
||||
if (view.name === "list") drawList()
|
||||
else if (view.name === "task" && shown) {
|
||||
const now = tasks.find((x) => x.id === shown.id)
|
||||
if (!now) return go({ name: "list" })
|
||||
const last = now.last_run || {}
|
||||
if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw()
|
||||
}
|
||||
}
|
||||
|
||||
function popoverOpenHere() {
|
||||
return !!document.querySelector(".pop .smodels")
|
||||
}
|
||||
|
||||
/** The palette's commands for scheduled tasks. */
|
||||
export function scheduledCommands() {
|
||||
return [
|
||||
{
|
||||
label: t("New scheduled task"),
|
||||
run: () => {
|
||||
ctx.show()
|
||||
go({ name: "form" })
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function initScheduled(context) {
|
||||
ctx = context
|
||||
listen("schedules", (e) => onSchedules(e.payload))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// 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, the language and theme, notifications, and
|
||||
// this release.
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.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
|
||||
|
||||
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")],
|
||||
]
|
||||
|
||||
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
|
||||
export function scrollToSection(id) {
|
||||
const el = $(`set-${id}`)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ block: "start" })
|
||||
markNav(id)
|
||||
el.querySelector("button, select, input")?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function markNav(id) {
|
||||
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||
}
|
||||
|
||||
function section(id, title, ...body) {
|
||||
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
|
||||
}
|
||||
|
||||
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
|
||||
function line(label, hint, control) {
|
||||
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
|
||||
}
|
||||
|
||||
export function renderSettings(s) {
|
||||
if (!s) return
|
||||
const nav = clear($("settings-nav"))
|
||||
for (const [id, ico, title] of SECTIONS) {
|
||||
if (id === "folders" && !(s.linked && s.remote)) continue
|
||||
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
|
||||
}
|
||||
const box = clear($("settings-body"))
|
||||
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
||||
box.append(
|
||||
section(
|
||||
"account",
|
||||
t("Account"),
|
||||
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
|
||||
line(
|
||||
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
|
||||
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
|
||||
box.append(
|
||||
section(
|
||||
"agent",
|
||||
t("Agent"),
|
||||
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
||||
agentBlock(s),
|
||||
h(
|
||||
"div",
|
||||
{ class: "set__buttons" },
|
||||
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
|
||||
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// The folders.
|
||||
const r = s.remote
|
||||
if (s.linked && r) {
|
||||
const list = h("ul", { class: "folders" })
|
||||
if (!r.enabled || !r.roots.length) list.append(h("li", { class: "empty", text: t("No folder yet: the agent can open nothing. Add the folder of a project.") }))
|
||||
for (const dir of r.enabled ? r.roots : []) {
|
||||
list.append(
|
||||
h(
|
||||
"li",
|
||||
null,
|
||||
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
|
||||
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
||||
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
const mode = h(
|
||||
"select",
|
||||
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
||||
[
|
||||
["manual", t("Manual — asks first")],
|
||||
["edit", t("Edit — asks before commands")],
|
||||
["auto", t("Auto — asks nothing")],
|
||||
["plan", t("Plan — changes nothing")],
|
||||
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
||||
)
|
||||
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
||||
box.append(
|
||||
section(
|
||||
"folders",
|
||||
t("Project folders"),
|
||||
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
||||
list,
|
||||
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
||||
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
||||
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
|
||||
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
box.append(lookSection())
|
||||
|
||||
box.append(
|
||||
section(
|
||||
"notify",
|
||||
t("Notifications"),
|
||||
line(
|
||||
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
|
||||
t("Scheduled tasks tell you of a result, too, while the app runs."),
|
||||
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const advised = recommendation(s)
|
||||
box.append(
|
||||
section(
|
||||
"about",
|
||||
t("About"),
|
||||
line(
|
||||
`LLeMbas Desktop ${s.version}`,
|
||||
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||
),
|
||||
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) {
|
||||
current = getStatus
|
||||
listen("remote-error", (e) => {
|
||||
folderError = String(e.payload)
|
||||
if (!$("settings").hidden) renderSettings(current())
|
||||
})
|
||||
listen("status", () => {
|
||||
folderError = ""
|
||||
})
|
||||
onAgentChange(() => {
|
||||
if (!$("settings").hidden) renderSettings(current())
|
||||
})
|
||||
$("settings-body").addEventListener("scroll", () => {
|
||||
// The group at the top is the one marked beside them.
|
||||
const top = $("settings-body").getBoundingClientRect().top
|
||||
let at = ""
|
||||
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
|
||||
if (at) markNav(at)
|
||||
})
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
||||
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
||||
// server's device login: the approval page opens in the system's browser; this page shows the
|
||||
// code to compare and waits. Where the server offers it (`one_approval`) and the agent's link does
|
||||
// not work yet, the same approval links this computer for the agent: there is no second one.
|
||||
|
||||
import { $, h, clear } from "./dom.js"
|
||||
import { invoke, listen, why } from "./bridge.js"
|
||||
|
||||
let code = null // the code being approved, while signing in
|
||||
let failed = ""
|
||||
|
||||
export function renderSetup(s) {
|
||||
const box = clear($("setup-body"))
|
||||
$("setup-version").textContent = s.version
|
||||
const server = h("section", { "aria-labelledby": "setup-server-h" }, h("h2", { id: "setup-server-h", text: t("Server") }))
|
||||
const address = h("input", {
|
||||
id: "address",
|
||||
name: "address",
|
||||
type: "text",
|
||||
inputmode: "url",
|
||||
autocomplete: "url",
|
||||
placeholder: "https://ai.example",
|
||||
spellcheck: "false",
|
||||
value: s.server || "",
|
||||
})
|
||||
const hint = h("p", { class: "hint", id: "server-hint", "aria-live": "polite" })
|
||||
if (!s.checked) hint.textContent = t("Looking for a LLeMbas server there…")
|
||||
else if (s.refused) {
|
||||
hint.textContent = s.refused
|
||||
hint.classList.add("is-error")
|
||||
} else if (s.discovery) hint.textContent = t("%(name)s, LLeMbas %(version)s.", { name: s.discovery.name, version: s.discovery.version })
|
||||
else hint.textContent = t("LLeMbas Desktop needs a LLeMbas 2.0 server or later.")
|
||||
const connect = h("button", { type: "submit", class: "btn btn--primary", text: t("Connect") })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "field", novalidate: true },
|
||||
h("label", { for: "address", text: t("Address of your LLeMbas server") }),
|
||||
h("div", { class: "row" }, address, connect),
|
||||
hint,
|
||||
)
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
connect.disabled = true
|
||||
hint.className = "hint"
|
||||
hint.textContent = t("Looking for a LLeMbas server there…")
|
||||
try {
|
||||
await invoke("set_server", { address: address.value })
|
||||
} catch (err) {
|
||||
hint.className = "hint is-error"
|
||||
hint.textContent = why(err)
|
||||
} finally {
|
||||
connect.disabled = false
|
||||
}
|
||||
})
|
||||
server.append(form)
|
||||
if (s.refused && s.server) {
|
||||
server.append(h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Try again"), onclick: () => invoke("recheck_server") })))
|
||||
}
|
||||
box.append(server)
|
||||
|
||||
if (s.discovery && !s.account) {
|
||||
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
||||
// The same approval links this computer unless the agent's link works already (Rust decides
|
||||
// the same way, `link_works`).
|
||||
const linked = s.agent?.state === "running" || (s.agent?.state === "elsewhere" && s.agent.detail?.linked)
|
||||
const both = s.discovery.one_approval && !linked
|
||||
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
||||
if (s.signing_in && code) {
|
||||
sign.append(
|
||||
h("p", null, t("Approve this code in the page that opened in your browser:")),
|
||||
h("p", { class: "code", text: code.user_code }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", { type: "button", class: "btn", text: t("Open the page again"), onclick: () => invoke("open_external", { url: code.verification_uri_complete }) }),
|
||||
h("button", { type: "button", class: "btn", text: t("Cancel"), onclick: () => invoke("sign_in_cancel") }),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
sign.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "row--buttons" },
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn btn--primary",
|
||||
text: s.signing_in ? t("Waiting for the browser…") : t("Sign in"),
|
||||
disabled: s.signing_in,
|
||||
onclick: () => {
|
||||
failed = ""
|
||||
invoke("sign_in").catch((err) => {
|
||||
failed = why(err)
|
||||
renderSetup(s)
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (failed) sign.append(h("p", { class: "status is-error", text: failed }))
|
||||
box.append(sign)
|
||||
}
|
||||
}
|
||||
|
||||
export function initSetup(current) {
|
||||
listen("signin", (e) => {
|
||||
const p = e.payload
|
||||
if (p.state === "code") code = p.code
|
||||
if (p.state === "done") code = null
|
||||
if (p.state === "failed") {
|
||||
code = null
|
||||
failed = p.message
|
||||
}
|
||||
renderSetup(current())
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
||||
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
||||
// does that for an undecorated one). The menu opens the app's native menu; the agent's chip opens
|
||||
// a small panel with what the agent says and the one thing to do about it.
|
||||
|
||||
import { $, h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, listen, win } from "./bridge.js"
|
||||
import { agentBlock, onAgentChange } from "./agent.js"
|
||||
import { popover, closePopover } from "./popup.js"
|
||||
|
||||
const WORDS = {
|
||||
off: () => t("agent off"),
|
||||
not_linked: () => t("not linked"),
|
||||
starting: () => t("agent starting…"),
|
||||
connecting: () => t("agent connecting…"),
|
||||
running: () => t("agent running"),
|
||||
waiting: () => t("agent waiting"),
|
||||
refused: () => t("agent refused"),
|
||||
elsewhere: () => t("agent running (service)"),
|
||||
elsewhere_waiting: () => t("agent waiting"),
|
||||
failed: () => t("agent stopped"),
|
||||
no_folders: () => t("agent: no folders"),
|
||||
}
|
||||
|
||||
let ctx = null // { status(), openSettings() }
|
||||
let pop = null
|
||||
|
||||
/**
|
||||
* The chip's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
||||
* into no folder is said too -- a green light would say it works when it cannot.
|
||||
*/
|
||||
export function pillState(s) {
|
||||
const a = s.agent
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||
if (linked && noFolders) return "no_folders"
|
||||
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
|
||||
return a.state
|
||||
}
|
||||
|
||||
/** Why the agent is not working, in the CLI's words, when it said. */
|
||||
export function agentWhy(a) {
|
||||
if (a.state === "elsewhere") return a.detail?.why || ""
|
||||
return typeof a.detail === "string" ? a.detail : ""
|
||||
}
|
||||
|
||||
export function renderBar(s) {
|
||||
let host = ""
|
||||
try {
|
||||
host = s.server ? new URL(s.server).host : ""
|
||||
} catch {}
|
||||
$("host").textContent = host
|
||||
$("host").title = s.server || ""
|
||||
const state = pillState(s)
|
||||
const reason = agentWhy(s.agent)
|
||||
$("agent").dataset.state = state
|
||||
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
|
||||
$("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent")
|
||||
$("update").hidden = !s.update
|
||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
||||
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||
pop?.redraw()
|
||||
}
|
||||
|
||||
/** The chip's panel: the agent's state and its button, then start or stop, and Settings. */
|
||||
function agentPanel() {
|
||||
const s = ctx.status()
|
||||
const box = h("div", { class: "pop__body" }, h("p", { class: "pop__title", text: t("Agent") }), agentBlock(s))
|
||||
const row = h("div", { class: "pop__actions" })
|
||||
if (s.linked) {
|
||||
row.append(h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }))
|
||||
}
|
||||
row.append(
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "btn btn--sm",
|
||||
text: t("Settings"),
|
||||
onclick: () => {
|
||||
closePopover()
|
||||
ctx.openSettings("agent")
|
||||
},
|
||||
}),
|
||||
)
|
||||
box.append(row)
|
||||
return box
|
||||
}
|
||||
|
||||
function toggleAgent() {
|
||||
if (pop) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
$("agent").setAttribute("aria-expanded", "true")
|
||||
pop = popover($("agent"), agentPanel, {
|
||||
align: "end",
|
||||
label: t("Agent"),
|
||||
onClose: () => {
|
||||
pop = null
|
||||
$("agent").setAttribute("aria-expanded", "false")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function maximized(on) {
|
||||
const b = $("maximize")
|
||||
b.replaceChildren(icon(on ? ICONS.restore : ICONS.max))
|
||||
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
|
||||
b.title = on ? t("Restore") : t("Maximise")
|
||||
}
|
||||
|
||||
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
|
||||
function menuAt(button) {
|
||||
const r = button.getBoundingClientRect()
|
||||
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
||||
}
|
||||
|
||||
export function initBar(context) {
|
||||
ctx = context
|
||||
$("minimize").replaceChildren(icon(ICONS.min))
|
||||
$("close").replaceChildren(icon(ICONS.close))
|
||||
$("menu").replaceChildren(icon(ICONS.menu))
|
||||
$("side-toggle").replaceChildren(icon(ICONS.side))
|
||||
$("minimize").addEventListener("click", () => win.minimize())
|
||||
$("maximize").addEventListener("click", () => win.toggleMaximize())
|
||||
$("close").addEventListener("click", () => win.close())
|
||||
$("update").addEventListener("click", () => invoke("update_app"))
|
||||
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
||||
$("agent").addEventListener("click", toggleAgent)
|
||||
onAgentChange(() => pop?.redraw())
|
||||
listen("maximized", (e) => maximized(!!e.payload))
|
||||
win.isMaximized().then(maximized).catch(() => maximized(false))
|
||||
}
|
||||
+200
@@ -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 {}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
|
||||
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
|
||||
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
|
||||
// out. What a file says is never part of the app's page.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke } from "./bridge.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
import { codeView } from "./code.js"
|
||||
|
||||
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
|
||||
const LANGUAGES = {
|
||||
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
|
||||
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
|
||||
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
|
||||
}
|
||||
|
||||
export function showKind(path) {
|
||||
const name = String(path).split("/").pop() || ""
|
||||
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
|
||||
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
|
||||
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
|
||||
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
|
||||
return language ? { kind: "code", language } : { kind: "text", language: "" }
|
||||
}
|
||||
|
||||
let current = null // { editor, sealed }
|
||||
let onChange = () => {}
|
||||
|
||||
/** Called when the viewer opens or closes (the layout makes room for it). */
|
||||
export function onViewer(fn) {
|
||||
onChange = fn
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (!current) return
|
||||
current.editor?.destroy()
|
||||
if (current.sealed) invoke("sealed_close", { url: current.sealed })
|
||||
current = null
|
||||
}
|
||||
|
||||
export function closeViewer() {
|
||||
teardown()
|
||||
$("viewer").hidden = true
|
||||
clear($("viewer-body"))
|
||||
onChange(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a file has no text, in words: this computer's `withheld` is "binary" | "too_big"; the
|
||||
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
|
||||
* language already.
|
||||
*/
|
||||
function withheldWords(withheld, size) {
|
||||
if (typeof withheld === "object" && withheld.message) return String(withheld.message)
|
||||
const reason = typeof withheld === "object" ? withheld.reason : withheld
|
||||
return reason === "too_big" || reason === "too_large"
|
||||
? t("This file is too big to show here (%(size)s).", { size: bytes(size) })
|
||||
: t("This is not a text file; it cannot be shown here.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
|
||||
* withheld? (see withheldWords), size?, error? }.
|
||||
*/
|
||||
export async function openViewer(file) {
|
||||
teardown()
|
||||
const kind = file.kind || showKind(file.path || file.name).kind
|
||||
const lang = file.language ?? showKind(file.path || file.name).language
|
||||
$("viewer").hidden = false
|
||||
onChange(true)
|
||||
$("viewer-name").textContent = file.name || file.path || ""
|
||||
$("viewer-name").title = file.path || ""
|
||||
$("viewer-where").textContent = file.where || ""
|
||||
const body = clear($("viewer-body"))
|
||||
const modes = clear($("viewer-modes"))
|
||||
current = {}
|
||||
if (file.error) {
|
||||
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
|
||||
return
|
||||
}
|
||||
if (file.withheld) {
|
||||
body.append(h("p", { class: "viewer__note", text: withheldWords(file.withheld, file.size) }))
|
||||
return
|
||||
}
|
||||
const text = file.text ?? ""
|
||||
// Markdown and HTML can be seen drawn or as their source.
|
||||
const drawn = kind === "markdown" || kind === "html"
|
||||
const show = async (asSource) => {
|
||||
teardown()
|
||||
current = {}
|
||||
clear(body)
|
||||
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
|
||||
if (asSource || !drawn) {
|
||||
const holder = h("div", { class: "viewer__code" })
|
||||
body.append(holder)
|
||||
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
|
||||
} else if (kind === "markdown") {
|
||||
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
|
||||
} else {
|
||||
const url = await invoke("sealed_open", { html: text })
|
||||
current.sealed = url
|
||||
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
|
||||
// scripts and nothing else. The address's own policy says the same again.
|
||||
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
|
||||
}
|
||||
}
|
||||
if (drawn) {
|
||||
modes.append(
|
||||
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
|
||||
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
|
||||
)
|
||||
}
|
||||
await show(false)
|
||||
}
|
||||
|
||||
export function initViewer() {
|
||||
$("viewer-close").replaceChildren(icon(ICONS.close))
|
||||
$("viewer-close").addEventListener("click", closeViewer)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
|
||||
})
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
|
||||
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
|
||||
// `models` scope (src-tauri/src/server.rs):
|
||||
//
|
||||
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
|
||||
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
|
||||
//
|
||||
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
|
||||
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
|
||||
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
|
||||
|
||||
import { $, h, icon, ICONS } from "./dom.js"
|
||||
import { invoke, why } from "./bridge.js"
|
||||
|
||||
let me = null
|
||||
|
||||
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||
/** Whether a message can be dictated here. */
|
||||
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
|
||||
/** Whether a reply can be read aloud here. */
|
||||
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
|
||||
|
||||
function say(text) {
|
||||
const box = $("thread-note")
|
||||
if (!box) return
|
||||
box.textContent = text
|
||||
box.hidden = !text
|
||||
}
|
||||
|
||||
// --- Dictate --------------------------------------------------------------------------------------
|
||||
|
||||
let recorder = null // the recording in progress
|
||||
let busy = false // a recording being turned into words
|
||||
|
||||
function drawMic() {
|
||||
const b = $("dictate")
|
||||
if (!b) return
|
||||
b.hidden = !canDictate()
|
||||
const on = !!recorder
|
||||
b.classList.toggle("is-recording", on)
|
||||
b.classList.toggle("is-busy", busy)
|
||||
b.disabled = busy
|
||||
b.setAttribute("aria-pressed", String(on))
|
||||
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
|
||||
b.setAttribute("aria-label", label)
|
||||
b.title = label
|
||||
}
|
||||
|
||||
async function start() {
|
||||
say("")
|
||||
let stream
|
||||
try {
|
||||
await invoke("voice_arm")
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (err) {
|
||||
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||
return
|
||||
}
|
||||
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
|
||||
let rec
|
||||
try {
|
||||
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
|
||||
} catch (err) {
|
||||
stream.getTracks().forEach((x) => x.stop())
|
||||
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
|
||||
rec.addEventListener("stop", async () => {
|
||||
stream.getTracks().forEach((x) => x.stop())
|
||||
recorder = null
|
||||
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
|
||||
if (!blob.size) return drawMic()
|
||||
busy = true
|
||||
drawMic()
|
||||
try {
|
||||
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
|
||||
intoComposer(text)
|
||||
} catch (err) {
|
||||
say(why(err))
|
||||
}
|
||||
busy = false
|
||||
drawMic()
|
||||
})
|
||||
recorder = rec
|
||||
rec.start()
|
||||
drawMic()
|
||||
}
|
||||
|
||||
/** The words heard, where the cursor is in the message box, a space either side where needed. */
|
||||
function intoComposer(text) {
|
||||
text = String(text || "").trim()
|
||||
if (!text) return
|
||||
const box = $("composer-text")
|
||||
const at = box.selectionStart ?? box.value.length
|
||||
const end = box.selectionEnd ?? at
|
||||
const before = box.value.slice(0, at)
|
||||
const after = box.value.slice(end)
|
||||
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
|
||||
box.value = before + words + after
|
||||
box.selectionStart = box.selectionEnd = before.length + words.length
|
||||
box.dispatchEvent(new Event("input"))
|
||||
box.focus()
|
||||
}
|
||||
|
||||
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
|
||||
export function stopDictating() {
|
||||
if (recorder?.state === "recording") recorder.stop()
|
||||
}
|
||||
|
||||
// --- Read aloud -----------------------------------------------------------------------------------
|
||||
|
||||
let playing = null // { id, audio, button }
|
||||
|
||||
/** A reply's words without the markdown around them, and without its code. */
|
||||
export function plain(markdown) {
|
||||
return String(markdown || "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
|
||||
.replace(/[*_`#>|~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stopReading() {
|
||||
if (!playing) return
|
||||
playing.audio?.pause()
|
||||
playing.button.setAttribute("aria-pressed", "false")
|
||||
playing.button.classList.remove("is-busy")
|
||||
playing = null
|
||||
}
|
||||
|
||||
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
|
||||
export function readAloudButton(id, text) {
|
||||
if (!canSpeak()) return null
|
||||
const label = t("Read this aloud")
|
||||
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
|
||||
b.addEventListener("click", async () => {
|
||||
if (playing?.id === id) return stopReading()
|
||||
stopReading()
|
||||
const words = plain(text())
|
||||
if (!words) return
|
||||
const mine = { id, audio: null, button: b }
|
||||
playing = mine
|
||||
b.setAttribute("aria-pressed", "true")
|
||||
b.classList.add("is-busy")
|
||||
try {
|
||||
const url = await invoke("speak", { text: words })
|
||||
if (playing !== mine) return
|
||||
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
|
||||
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
|
||||
mine.audio = new Audio(url)
|
||||
b.classList.remove("is-busy")
|
||||
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
|
||||
await mine.audio.play()
|
||||
} catch (err) {
|
||||
if (playing === mine) stopReading()
|
||||
say(why(err?.message ? { detail: err.message } : err))
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
|
||||
export function voiceFor(account) {
|
||||
me = account
|
||||
drawMic()
|
||||
}
|
||||
|
||||
export function initVoice() {
|
||||
const b = $("dictate")
|
||||
b.replaceChildren(icon(ICONS.mic))
|
||||
b.addEventListener("click", () => {
|
||||
if (busy) return
|
||||
if (recorder) recorder.stop()
|
||||
else start()
|
||||
})
|
||||
drawMic()
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it
|
||||
moves when the host name or the agent's state changes length. */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: var(--titlebar-h);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: grid;
|
||||
/* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */
|
||||
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) 1px repeat(3, 2.875rem);
|
||||
align-items: center;
|
||||
height: var(--titlebar-h);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 600 var(--text-sm) / 1 var(--font-ui);
|
||||
}
|
||||
|
||||
.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; }
|
||||
.bar__name b { color: var(--leaf); font-weight: 700; }
|
||||
.bar__host {
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--ink-muted);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
height: var(--titlebar-h);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
.bar__agent {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5rem auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
padding: 0 var(--sp-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.dot { width: 0.5rem; height: 0.5rem; background: var(--ink-muted); }
|
||||
.bar__agent[data-state="running"] .dot,
|
||||
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
|
||||
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
|
||||
.bar__agent[data-state="failed"] .dot,
|
||||
.bar__agent[data-state="not_linked"] .dot { background: var(--warning); }
|
||||
|
||||
.bar__update {
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
}
|
||||
.bar__update:hover { color: var(--accent-ink); background: var(--accent-hover); }
|
||||
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
|
||||
.bar__update[hidden] { display: block; width: 0; padding: 0; overflow: hidden; }
|
||||
|
||||
.bar__sep { width: 1px; height: 1.25rem; background: var(--border); }
|
||||
|
||||
.bar__btn { display: grid; place-items: center; }
|
||||
.bar__btn svg { width: 16px; height: 16px; }
|
||||
.bar__btn--close:hover { color: #fff; background: var(--danger); }
|
||||
|
||||
#maximize .i-restore { display: none; }
|
||||
#maximize[data-maximized] .i-max { display: none; }
|
||||
#maximize[data-maximized] .i-restore { display: block; }
|
||||
|
||||
/* Narrow windows: the host goes first, then the agent's words (its dot stays). */
|
||||
@media (max-width: 34rem) {
|
||||
.bar__host { visibility: hidden; }
|
||||
#agent-text { display: none; }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>LLeMbas</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="titlebar.css">
|
||||
<script src="titlebar.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar" data-tauri-drag-region>
|
||||
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
||||
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
||||
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
||||
|
||||
<button class="bar__update" id="update" type="button" hidden>update</button>
|
||||
<button class="bar__agent" id="agent" type="button" title="Agent">
|
||||
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
|
||||
</button>
|
||||
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
|
||||
</button>
|
||||
<span class="bar__sep" aria-hidden="true"></span>
|
||||
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise">
|
||||
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the
|
||||
// buttons call the window directly; the menu and the agent pill open the app's native menu (the
|
||||
// tray's items), which can reach past this 36-pixel-high page.
|
||||
"use strict"
|
||||
|
||||
const { invoke } = window.__TAURI__.core
|
||||
const { listen } = window.__TAURI__.event
|
||||
const win = window.__TAURI__.window.getCurrentWindow()
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
|
||||
const WORDS = {
|
||||
off: "agent off",
|
||||
not_linked: "not linked",
|
||||
starting: "agent starting…",
|
||||
running: "agent running",
|
||||
elsewhere: "agent running (service)",
|
||||
failed: "agent stopped",
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
let host = ""
|
||||
try {
|
||||
host = s.server ? new URL(s.server).host : ""
|
||||
} catch {}
|
||||
$("host").textContent = host
|
||||
$("host").title = s.server || ""
|
||||
const state = s.agent.state
|
||||
$("agent").dataset.state = state
|
||||
$("agent-text").textContent = WORDS[state] ?? state
|
||||
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent"
|
||||
$("update").hidden = !s.update
|
||||
$("update").textContent = s.update ? `update ${s.update}` : "update"
|
||||
$("update").title = s.update ? `LLeMbas ${s.update} is available (this is ${s.version})` : ""
|
||||
}
|
||||
|
||||
function maximized(on) {
|
||||
const b = $("maximize")
|
||||
b.toggleAttribute("data-maximized", on)
|
||||
b.setAttribute("aria-label", on ? "Restore" : "Maximise")
|
||||
b.title = on ? "Restore" : "Maximise"
|
||||
}
|
||||
|
||||
$("minimize").addEventListener("click", () => win.minimize())
|
||||
$("maximize").addEventListener("click", () => win.toggleMaximize())
|
||||
$("close").addEventListener("click", () => win.close())
|
||||
$("update").addEventListener("click", () => invoke("update_app"))
|
||||
$("menu").addEventListener("click", () => invoke("titlebar_menu"))
|
||||
$("agent").addEventListener("click", () => invoke("titlebar_menu"))
|
||||
|
||||
listen("status", (e) => render(e.payload))
|
||||
listen("maximized", (e) => maximized(!!e.payload))
|
||||
invoke("get_status").then(render)
|
||||
win.isMaximized().then(maximized)
|
||||
+578
-15
@@ -1,54 +1,617 @@
|
||||
/* LLeMbas Desktop's own pages: one set of tokens (Moria dark, Shire light), as on llembas.eu. */
|
||||
/* 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.
|
||||
|
||||
Every colour, space and radius in the application resolves through a variable
|
||||
declared here. Component CSS must never hard-code a hex value -- that is what
|
||||
makes adding a theme a matter of writing one new block rather than auditing
|
||||
every stylesheet.
|
||||
|
||||
Themes are selected with data-theme on <html>. `moria` is the default and is
|
||||
declared on :root so the page is styled even before the theme script runs.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
/* --- Type ------------------------------------------------------------- */
|
||||
/* The chrome -- buttons, navigation, tabs, labels, headings --
|
||||
is set in `--font-ui`, a monospace face: "Middle-earth on a terminal".
|
||||
What people *read* at length, a reply or a note, stays in `--font-body`,
|
||||
because eighty columns of mono prose is tiring and the look is not worth
|
||||
that. `--font-display` follows the chrome. */
|
||||
--font-ui: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo,
|
||||
Consolas, monospace;
|
||||
--font-display: var(--font-ui);
|
||||
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Fira Code",
|
||||
"Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-md: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.6;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* --- Space (4px scale) ------------------------------------------------ */
|
||||
/* Half a step: the gap between the lines of one tool result, the bleed of a
|
||||
mention's highlight. The one place a 2px space is allowed to be named. */
|
||||
--sp-half: 0.125rem;
|
||||
--sp-1: 0.25rem;
|
||||
--sp-2: 0.5rem;
|
||||
--sp-3: 0.75rem;
|
||||
--sp-4: 1rem;
|
||||
--sp-5: 1.25rem;
|
||||
--sp-6: 1.5rem;
|
||||
--sp-8: 2rem;
|
||||
--radius: 2px;
|
||||
--press: 2px;
|
||||
--control-h: 2.5rem;
|
||||
--card-max: 34rem;
|
||||
--sp-10: 2.5rem;
|
||||
--sp-12: 3rem;
|
||||
|
||||
/* --- Radius & shadow -------------------------------------------------- */
|
||||
/* Blocks, not pills. Small on purpose: a corner of 2px reads as
|
||||
square at reading distance and still stops an edge looking cut. Avatars and
|
||||
dots keep `--radius-full`. */
|
||||
--radius-sm: 2px;
|
||||
--radius: 2px;
|
||||
--radius-md: 3px;
|
||||
--radius-lg: 4px;
|
||||
--radius-xl: 6px;
|
||||
--radius-full: 999px;
|
||||
/* Square, on purpose: the sidebar's Chats/Agents switch, which has
|
||||
to read as one solid control rather than as two buttons. */
|
||||
--radius-none: 0;
|
||||
|
||||
/* How far a block button travels when pressed: into its own shadow, which
|
||||
is drawn exactly this far out. The two are one number on purpose. */
|
||||
--press: 2px;
|
||||
|
||||
/* The load-state dot on a model's avatar in the model menu. Its colours are
|
||||
tokens of their own, defaulting to the theme's success and warning, so
|
||||
an instance whose success colour is not green can still say "loaded" in
|
||||
green -- that is what people read a dot beside a name as. */
|
||||
--model-state-size: 0.625rem;
|
||||
--model-state-loaded: var(--success);
|
||||
--model-state-loading: var(--warning);
|
||||
|
||||
/*
|
||||
--- Controls ----------------------------------------------------------
|
||||
Every button, input and select resolves its height from these. That is the
|
||||
whole reason things line up: a row of mixed controls has one height, not
|
||||
whatever each element's padding and font happened to add up to.
|
||||
*/
|
||||
--control-h: 2.25rem;
|
||||
--control-h-sm: 1.75rem;
|
||||
--control-h-lg: 2.75rem;
|
||||
--control-px: 0.75rem;
|
||||
--control-px-sm: 0.5rem;
|
||||
|
||||
/* --- Layout ----------------------------------------------------------- */
|
||||
--sidebar-width: 17.5rem;
|
||||
--inspector-width: 24rem;
|
||||
/* Wider than the inspector because the content is not prose: eighty columns
|
||||
of --font-mono do not fit in 24rem, and a terminal narrower than eighty
|
||||
re-wraps everything a program prints. */
|
||||
--terminal-width: 34rem;
|
||||
--terminal-width-min: 24rem;
|
||||
--thread-max-width: 48rem;
|
||||
--header-height: 3.5rem;
|
||||
|
||||
/* --- The sizes of things -------------------------------------------------
|
||||
Caps and floors that used to be a literal rem in seventy places across
|
||||
three stylesheets -- 20, 22, 24, 26 -- with no way to tell which of them
|
||||
meant the same thing. A handful of roles instead, and a component takes the
|
||||
one that names what it is: a theme can then widen every menu at once, and
|
||||
two popovers cannot drift a rem apart by accident. Control heights are
|
||||
`--control-h`; these are the boxes around them. */
|
||||
--w-num: 4.5rem; /* a column of figures: tokens, a share, an elapsed time */
|
||||
--w-field-xs: 8rem; /* a short input: a count, a duration */
|
||||
--w-field-sm: 10rem; /* a short select's floor */
|
||||
--w-field: 14rem; /* a capped field, a label column, a small menu */
|
||||
--w-chip: 16rem; /* a name that truncates: a model, a path */
|
||||
--w-menu-sm: 20rem; /* a compact popover, an attachment chip */
|
||||
--w-menu: 24rem; /* a popover, a results list, a card's column */
|
||||
--w-menu-lg: 28rem; /* a wide popover */
|
||||
--w-dialog: 34rem; /* a dialog with a list in it */
|
||||
--w-prose-sm: 32rem; /* a short reading measure: an empty state */
|
||||
--w-prose: 44rem; /* a reading measure: a lede, a card grid */
|
||||
--h-list: 22rem; /* a scrolling list or menu */
|
||||
--h-block: 24rem; /* a scrolling block inside a card */
|
||||
--h-block-lg: 26rem; /* a reasoning body, a preview */
|
||||
--h-media: 20rem; /* a picture in the transcript */
|
||||
|
||||
/* The live reply's two one-line rows -- the waiting row and the metrics --
|
||||
keep this height whether or not they have anything in them, so their
|
||||
content arriving or leaving never moves the transcript. One line of the
|
||||
small type. */
|
||||
--row-h: calc(var(--text-xs) * var(--leading-normal) + 2 * var(--sp-2));
|
||||
|
||||
/* A dot that marks state: unsaved, unread. */
|
||||
--dot-size: 0.375rem;
|
||||
/* The streaming caret: a thin bar the height of a line. */
|
||||
--caret-bar-w: 0.45rem;
|
||||
/* The drag handle on a panel's edge: how wide, and how far it overhangs the
|
||||
panel so the border itself is inside the target. */
|
||||
--resize-w: 9px;
|
||||
--resize-overhang: 3px;
|
||||
/* A faded control: something present but not the point. */
|
||||
--opacity-dim: 0.45;
|
||||
|
||||
/* What `--header-height` does at the top of the shell, this does at the
|
||||
bottom. The sidebar's footer and the composer sit either side of the same
|
||||
vertical line and both are content-sized, so without this they end at
|
||||
different heights -- and neither can be made to match the other by
|
||||
accident: the footer's height depends on which entries the reader's
|
||||
permissions allow, and the composer's on how much they have typed.
|
||||
|
||||
This began as the fix for a broken *line*: both carried a top border, and
|
||||
the two met the sidebar's edge at different heights. The borders are gone
|
||||
now -- an edge that content scrolls under reads better undrawn than drawn
|
||||
and aligned -- and the token stays, because the two ends of the shell
|
||||
sitting at different heights is visible without any border to prove it.
|
||||
|
||||
A calc of the pieces the footer is actually built from -- four rows at
|
||||
`--control-h`, the gaps between them, and its own padding -- so it stays
|
||||
true if those tokens move. Applied as a `min-height` to both: it holds the
|
||||
footer at full height even for somebody who sees fewer entries, and lifts
|
||||
the composer to meet it. A composer that grows past this as somebody types
|
||||
is expected; the input is getting bigger, and nothing is pretending the
|
||||
sidebar should follow it. */
|
||||
--footer-height: calc(4 * var(--control-h) + 3 * var(--sp-1) + 2 * var(--sp-2));
|
||||
|
||||
/* The terminal's own type. xterm holds this as a number rather than reading
|
||||
it from CSS, so terminal.js parses it back out -- it must stay a plain
|
||||
pixel value. */
|
||||
--terminal-font-size: 13px;
|
||||
|
||||
/*
|
||||
--- Stacking ----------------------------------------------------------
|
||||
These were four bare numbers scattered across two stylesheets, which is
|
||||
fine until something new has to sit between two of them and nobody can
|
||||
say what is already there.
|
||||
*/
|
||||
--z-raised: 5;
|
||||
--z-handle: 10;
|
||||
--z-dropdown: 30;
|
||||
--z-panel: 40;
|
||||
--z-toast: 60;
|
||||
|
||||
--transition-fast: 120ms ease;
|
||||
--transition: 200ms ease;
|
||||
|
||||
/* --- Borders -----------------------------------------------------------
|
||||
A hairline was a literal `1px` in about ninety places, which made it the
|
||||
largest category of hard-coded value left in the codebase -- and the one
|
||||
thing a theme cannot currently change. */
|
||||
--border-w: 1px;
|
||||
--border-w-thick: 2px;
|
||||
--border-w-accent: 3px;
|
||||
|
||||
/* The focus outline's own width. Not `--border-w-thick`, though they are the
|
||||
same number today: an outline is drawn outside the box and takes no space,
|
||||
a border is part of the box and does. Making one of them follow the other
|
||||
means a theme that wants a heavier border gets a heavier focus ring too,
|
||||
which is two decisions tied together by a coincidence. */
|
||||
--outline-w: 2px;
|
||||
/* How far the focus ring stands off the box it rings. */
|
||||
--outline-offset: 2px;
|
||||
|
||||
/* --- Touch --------------------------------------------------------------
|
||||
A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is
|
||||
36 -- comfortable with a pointer and under every published minimum for a
|
||||
finger -- so the coarse-pointer block at the foot of this file raises the
|
||||
control tokens to this rather than patching components one at a time.
|
||||
Raising the token is the only version that reaches all of them, and it is
|
||||
what `--control-h` exists for. */
|
||||
--tap-min: 2.75rem;
|
||||
/* A tick box, which does not take its size from `--control-h`: the browser
|
||||
draws it and only `width`/`height` move it. */
|
||||
--check-size: 1rem;
|
||||
|
||||
/* --- Controls as a family -----------------------------------------
|
||||
The dropdown's caret is a solid ▾ drawn with two gradients rather than a
|
||||
glyph -- a <select> takes no pseudo-element -- and it sits behind a rule,
|
||||
`[ value │ ▾ ]`, so a select reads as a field with a handle rather than as a
|
||||
button. `--caret-gutter` is everything to the right of the value: the rule,
|
||||
the caret, and the padding between them, so `padding-right` and the
|
||||
background positions are computed from one number. */
|
||||
--caret-w: 0.6rem;
|
||||
--caret-h: 0.3rem;
|
||||
--caret-gutter: 2.25rem;
|
||||
|
||||
/* --- The window's own edges ---------------------------------------------
|
||||
Installed on a phone, the page runs under the notch and the home
|
||||
indicator: base.html asks iOS for `black-translucent`, which is what puts
|
||||
it there, and `viewport-fit=cover` is what lets these resolve to anything
|
||||
but zero. Declared here so no component spells `env()` out -- and so a
|
||||
desktop browser, where all four are 0, costs nothing. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
|
||||
/* --- Breakpoints --------------------------------------------------------
|
||||
A media query cannot read a custom property, so these cannot be *used*
|
||||
here. They are declared anyway so the numbers have one home and a grep for
|
||||
one lands somewhere that says what it means -- and
|
||||
`tests/test_layout_bounds.py` refuses a width in any stylesheet that is not
|
||||
declared here, so a fourth breakpoint invented in passing fails the suite
|
||||
rather than joining the set unannounced.
|
||||
|
||||
--bp-admin 44rem 704px a two-column reference row stacks
|
||||
--bp-narrow 48rem 768px the sidebar becomes a drawer, and controls
|
||||
grow to a thumb's size
|
||||
--bp-wide 64rem 1024px the right-hand panels become overlays */
|
||||
--bp-admin: 44rem;
|
||||
--bp-narrow: 48rem;
|
||||
--bp-wide: 64rem;
|
||||
|
||||
/* --- Motion -------------------------------------------------------------
|
||||
Durations and curves, so the `prefers-reduced-motion` block at the foot of
|
||||
this file keeps covering everything by construction: a literal `1.6s` in a
|
||||
component is a value that block can still neutralise, but one nobody can
|
||||
tune. `--ease-out` is the one to reach for -- something arriving should
|
||||
decelerate; `--ease-spring` overshoots slightly and belongs on a thing
|
||||
that appears, never on a thing that moves under the pointer. */
|
||||
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--dur-1: 120ms;
|
||||
--dur-2: 200ms;
|
||||
--dur-3: 320ms;
|
||||
--dur-slow: 1.6s;
|
||||
|
||||
/* --- Panel minimums -----------------------------------------------------
|
||||
`api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored
|
||||
against an account and only two of them -- the two with a drag handle --
|
||||
had a `-min` token or a `min-width` to clamp with. The other two are not
|
||||
draggable, so nothing in the interface could produce a bad value; but the
|
||||
endpoint takes one from anybody signed in, `base.html` applies stored
|
||||
widths to <html> before first paint, and with no clamp a stored 800px
|
||||
sidebar is one nothing in the application can drag back. */
|
||||
--sidebar-width-min: 12.5rem;
|
||||
--inspector-width-min: 17.5rem;
|
||||
|
||||
/* The focus treatment, written once. Three components spelled it out. It
|
||||
resolves --accent-soft at the point of use, so it follows the theme even
|
||||
though it is declared above them. */
|
||||
--ring: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/*
|
||||
---------------------------------------------------------------------------
|
||||
MORIA (default, dark)
|
||||
|
||||
The halls under the mountain on a terminal screen: stone-black surfaces kept
|
||||
near-neutral so the ithildin and mallorn accents carry all the colour, and
|
||||
the doors themselves drawn in moon-letter pixels on the empty screens.
|
||||
---------------------------------------------------------------------------
|
||||
*/
|
||||
:root,
|
||||
:root[data-theme="moria"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* "Ithildin phosphor": stone-black, with the moon-letters' silver
|
||||
cyan for focus and the primary action and a phosphor leaf for the brand.
|
||||
Contrast measured, not eyeballed -- ink is 15.7:1 on bg, ink-faint 5.4:1 on
|
||||
a card, border-strong 3.3:1. */
|
||||
--bg: #0B0D10;
|
||||
--bg-sunken: #07090B;
|
||||
--surface: #11151A;
|
||||
--surface-raised: #171C22;
|
||||
--surface-hover: #1D232B;
|
||||
--surface-active: #232B34;
|
||||
|
||||
--border: #26303A;
|
||||
/* Bright enough to be an edge (3:1 on a card): the blocky look is drawn in
|
||||
edges, and a 1.9:1 hairline disappears on a dim screen. */
|
||||
--border-strong: #5C6976;
|
||||
|
||||
--ink: #E2E8EC;
|
||||
--ink-muted: #A6B2BC;
|
||||
--ink-faint: #808D98;
|
||||
--ink-inverse: #07090B;
|
||||
|
||||
/* Ithildin: the cool primary, focus and interactive accents. */
|
||||
--accent: #8FD8E8;
|
||||
--accent-hover: #B4E6F1;
|
||||
--accent-ink: #061418;
|
||||
--accent-soft: rgba(143, 216, 232, 0.12);
|
||||
/* Selected text where the glyphs are drawn by something other than the
|
||||
selection -- the composer, whose visible text is its mirror. Stronger
|
||||
than --accent-soft, which is a tint for a background nobody has to find. */
|
||||
--selection: rgba(143, 216, 232, 0.32);
|
||||
|
||||
/* Mallorn: the brand, the assistant's mark, the leaf badges -- phosphor
|
||||
green on the dark, the colour of an old terminal's text. */
|
||||
--leaf: #7EE081;
|
||||
--leaf-hover: #A2EAA3;
|
||||
--leaf-soft: rgba(126, 224, 129, 0.12);
|
||||
|
||||
--danger: #F0735F;
|
||||
--danger-hover: #F4907F;
|
||||
--danger-soft: rgba(240, 115, 95, 0.13);
|
||||
|
||||
/* Teal, a hue away from the leaf: a dot beside a brand badge has to be
|
||||
tellable from it at a glance. */
|
||||
--success: #5FD6B0;
|
||||
--success-soft: rgba(95, 214, 176, 0.13);
|
||||
/* Forge amber. */
|
||||
--warning: #F0A840;
|
||||
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
|
||||
--danger: #E5534B;
|
||||
--titlebar-h: 36px;
|
||||
--warning-soft: rgba(240, 168, 64, 0.13);
|
||||
|
||||
--bubble-user: #161C23;
|
||||
--bubble-assistant: transparent;
|
||||
--code-bg: #07090B;
|
||||
--code-border: #26303A;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Blocks: a hard offset shadow, `--press` out, that a button presses into.
|
||||
On the dark the accent one also glows -- ithildin shows by its light. */
|
||||
--shadow-block: var(--press) var(--press) 0 rgba(0, 0, 0, 0.55);
|
||||
--shadow-block-accent: var(--press) var(--press) 0
|
||||
color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 0 22px -6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
--shadow-block-lg: 6px 6px 0 rgba(0, 0, 0, 0.6),
|
||||
0 0 40px -12px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
|
||||
/* The two mostly-empty screens -- a new chat, signing in -- sit on a
|
||||
screen: fine scanlines over a grid that fades out from the middle, lit
|
||||
faintly from the top. Mixed from the accent so a custom theme on this base
|
||||
gets its own colour. Nowhere else: a conversation is read for hours. */
|
||||
--texture-screen:
|
||||
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.22) 0 1px, transparent 1px 3px),
|
||||
radial-gradient(ellipse 60% 45% at 50% 18%,
|
||||
color-mix(in srgb, var(--accent) 10%, transparent), transparent 70%),
|
||||
radial-gradient(ellipse 75% 65% at 50% 35%, transparent 45%, var(--bg) 92%),
|
||||
linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px)
|
||||
0 0 / 24px 24px,
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px,
|
||||
transparent 1px) 0 0 / 24px 24px,
|
||||
var(--bg);
|
||||
--glow-title: 0 0 18px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
|
||||
/* The pixel artwork's colours. Each drawing is a set of named roles
|
||||
(scripts/build_artwork.py); a theme only supplies these. */
|
||||
--art-leaf: #7EE081;
|
||||
--art-leaf-dark: #4FAE5A;
|
||||
--art-leaf-light: #B6F2A6;
|
||||
--art-vein: #D8FFD0;
|
||||
--art-line: #8FD8E8;
|
||||
--art-faint: #4F8794;
|
||||
--art-star: #EAFCFF;
|
||||
--art-glow: rgba(143, 216, 232, 0.55);
|
||||
|
||||
--scrim: rgba(5, 7, 9, 0.7);
|
||||
|
||||
/*
|
||||
The sixteen ANSI colours, for the terminal panel.
|
||||
|
||||
A shell picks its own colours -- `ls --color`, a git diff, htop's meters --
|
||||
and until these existed it got xterm's defaults, which are a different
|
||||
palette from this one and read as a foreign window pasted into the
|
||||
application. Where a slot has an obvious counterpart above it takes it, so
|
||||
an error in the terminal is the same ember as an error anywhere else.
|
||||
|
||||
"black" is not #000: it is what a program picks for a dim background or
|
||||
faint text, and on a near-black surface a true black is invisible.
|
||||
*/
|
||||
--ansi-black: #1A1F26;
|
||||
--ansi-red: #F0735F;
|
||||
--ansi-green: #7EE081;
|
||||
--ansi-yellow: #F0A840;
|
||||
--ansi-blue: #7AA7E8;
|
||||
--ansi-magenta: #C79BE0;
|
||||
--ansi-cyan: #8FD8E8;
|
||||
--ansi-white: #C6CDD4;
|
||||
--ansi-bright-black: #5C6976;
|
||||
--ansi-bright-red: #F4907F;
|
||||
--ansi-bright-green: #A2EAA3;
|
||||
--ansi-bright-yellow: #F5C06E;
|
||||
--ansi-bright-blue: #9CC0F0;
|
||||
--ansi-bright-magenta: #DBBBEB;
|
||||
--ansi-bright-cyan: #B4E6F1;
|
||||
--ansi-bright-white: #E2E8EC;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
/*
|
||||
---------------------------------------------------------------------------
|
||||
SHIRE (light)
|
||||
|
||||
Parchment, ink and moss, printed in one bit: warm and easy to read for a
|
||||
long time, with hard ink edges where Moria glows. Backgrounds are
|
||||
deliberately off-white -- pure #FFF next to the leaf accent reads as
|
||||
clinical rather than as paper.
|
||||
---------------------------------------------------------------------------
|
||||
*/
|
||||
/*
|
||||
`[data-base="shire"]` as well as `[data-theme="shire"]`, and that second
|
||||
selector is what makes a custom theme possible. A custom theme is a handful of
|
||||
token overrides served from /branding.css; everything it does not set has to
|
||||
come from somewhere, and Moria's block above matches bare `:root` so it always
|
||||
applies. Without this a custom *light* theme would be four light colours on
|
||||
near-black surfaces. `<html>` carries both attributes -- see base.html and
|
||||
app.js:applyTheme.
|
||||
*/
|
||||
:root[data-theme="shire"],
|
||||
:root[data-base="shire"] {
|
||||
color-scheme: light;
|
||||
|
||||
/* "1-bit almanac": parchment and ink, a door-green primary, moss
|
||||
for the brand, brass for warnings -- and ink-black block shadows, which is
|
||||
what makes it read as printed. Ink is 13.9:1 on bg, ink-faint 5.6:1. */
|
||||
--bg: #F1EAD6;
|
||||
--bg-sunken: #E7DEC6;
|
||||
--surface: #F8F3E5;
|
||||
--surface-raised: #FCF9F0;
|
||||
--surface-hover: #ECE4CF;
|
||||
--surface-active: #E2D8BD;
|
||||
|
||||
--border: #CBBF9D;
|
||||
/* Ink, not a darker parchment: the blocks are drawn in it. */
|
||||
--border-strong: #2A2E22;
|
||||
|
||||
--ink: #1F2418;
|
||||
--ink-muted: #474D3B;
|
||||
--ink-faint: #5E6250;
|
||||
--ink-inverse: #F8F3E5;
|
||||
|
||||
/* The round door's green: the primary. */
|
||||
--accent: #285F3C;
|
||||
--accent-hover: #1F4D30;
|
||||
--accent-ink: #F8F3E5;
|
||||
--accent-soft: rgba(40, 95, 60, 0.1);
|
||||
--selection: rgba(40, 95, 60, 0.24);
|
||||
|
||||
/* The same mallorn as moss, dark enough to be text on parchment. */
|
||||
--leaf: #3D6B1C;
|
||||
--leaf-hover: #2F5615;
|
||||
--leaf-soft: rgba(61, 107, 28, 0.1);
|
||||
|
||||
--danger: #A3321F;
|
||||
--danger-hover: #862818;
|
||||
--danger-soft: rgba(163, 50, 31, 0.1);
|
||||
|
||||
--success: #2B6A55;
|
||||
--success-soft: rgba(43, 106, 85, 0.1);
|
||||
/* Brass. */
|
||||
--warning: #7E5200;
|
||||
--shadow-card: 4px 4px 0 var(--border-strong);
|
||||
--danger: #B3261E;
|
||||
--warning-soft: rgba(126, 82, 0, 0.1);
|
||||
|
||||
--bubble-user: #ECE4CF;
|
||||
--bubble-assistant: transparent;
|
||||
--code-bg: #F1EAD6;
|
||||
--code-border: #CBBF9D;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(42, 46, 34, 0.1);
|
||||
--shadow: 0 4px 14px rgba(42, 46, 34, 0.12);
|
||||
--shadow-lg: 0 12px 34px rgba(42, 46, 34, 0.18);
|
||||
|
||||
/* On paper the block shadow is solid ink: one bit, no blur. */
|
||||
--shadow-block: var(--press) var(--press) 0 var(--border-strong);
|
||||
--shadow-block-accent: var(--press) var(--press) 0 var(--border-strong);
|
||||
--shadow-block-lg: 6px 6px 0 var(--border-strong);
|
||||
|
||||
/* A fine dot dither in place of Moria's scanlines, fading at the edges. */
|
||||
--texture-screen:
|
||||
radial-gradient(ellipse 80% 70% at 50% 30%, transparent 50%, var(--bg) 95%),
|
||||
radial-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0.8px, transparent 1.1px)
|
||||
0 0 / 6px 6px,
|
||||
var(--bg);
|
||||
--glow-title: none;
|
||||
|
||||
--art-leaf: #4F8A2A;
|
||||
--art-leaf-dark: #2F5E1A;
|
||||
--art-leaf-light: #86B553;
|
||||
--art-vein: #E9F2C8;
|
||||
--art-grass: #5D9A3A;
|
||||
--art-grass-light: #86B553;
|
||||
--art-grass-dark: #3F7428;
|
||||
--art-sky: #CFE3E8;
|
||||
--art-sun: #E8B84A;
|
||||
--art-frame: #5A4425;
|
||||
--art-door: #2F6B3E;
|
||||
--art-door-dark: #24553A;
|
||||
--art-brass: #E8B84A;
|
||||
--art-window: #F3D27A;
|
||||
--art-path: #B89A6A;
|
||||
--art-flower-a: #D9534F;
|
||||
--art-flower-b: #F0C94A;
|
||||
|
||||
--scrim: rgba(31, 36, 24, 0.4);
|
||||
|
||||
/*
|
||||
The same sixteen on parchment, and the reason they are not the Moria set
|
||||
lightened: every one of these has to be readable as *text* on the code
|
||||
background, so the whole palette is darkened rather than brightened.
|
||||
"bright" therefore means more emphatic here, not lighter -- a light
|
||||
terminal theme that made bright yellow actually bright would render it
|
||||
invisible.
|
||||
*/
|
||||
--ansi-black: #1F2418;
|
||||
--ansi-red: #A3321F;
|
||||
--ansi-green: #3D6B1C;
|
||||
--ansi-yellow: #7E5200;
|
||||
--ansi-blue: #2F5D86;
|
||||
--ansi-magenta: #74336A;
|
||||
--ansi-cyan: #2B6A55;
|
||||
--ansi-white: #474D3B;
|
||||
--ansi-bright-black: #6F6A55;
|
||||
--ansi-bright-red: #862818;
|
||||
--ansi-bright-green: #2F5615;
|
||||
--ansi-bright-yellow: #634000;
|
||||
--ansi-bright-blue: #244A6C;
|
||||
--ansi-bright-magenta: #5C2853;
|
||||
--ansi-bright-cyan: #1F5443;
|
||||
--ansi-bright-white: #2A2E22;
|
||||
}
|
||||
|
||||
/*
|
||||
--- Touch -----------------------------------------------------------------
|
||||
A pointer is precise and a finger is about 9mm across, so the same control
|
||||
cannot be the right size for both. `--control-h` is 36px, which is comfortable
|
||||
with a mouse and under every published minimum for a thumb; `--control-h-sm`
|
||||
is 28px, which is a target most people miss.
|
||||
|
||||
Raised here rather than patched per component, because there are upwards of
|
||||
forty of them and the next one added would be 36px again. `--control-h` is
|
||||
what every button, input and select resolves its height from, so one block
|
||||
moves all of them -- which is the reason that token exists.
|
||||
|
||||
Two conditions, either of which is enough.
|
||||
|
||||
`(pointer: coarse)` is the honest one: it is the input device that decides how
|
||||
big a target has to be, and a touchscreen laptop at 1440px has the same thumb
|
||||
as a phone. But a layout below the phone breakpoint is a one-column, drawer-
|
||||
navigated layout whatever is pointing at it -- there is room for bigger
|
||||
controls and every reason to use it -- and that half is also the half a
|
||||
headless browser can be made to prove, which is not nothing: a rule that can
|
||||
only be checked by holding a phone is a rule that quietly rots.
|
||||
*/
|
||||
@media (pointer: coarse), (max-width: 48rem) {
|
||||
:root {
|
||||
--control-h: var(--tap-min);
|
||||
/* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
|
||||
action, not an unimportant one -- Edit, Enable and Use default are all
|
||||
`.btn--sm`, and on a phone they are the whole interaction. */
|
||||
--control-h-sm: 2.5rem;
|
||||
--control-px: var(--sp-4);
|
||||
--control-px-sm: var(--sp-3);
|
||||
/* A native checkbox is 13-16px whatever the surrounding type is, and no
|
||||
amount of padding on its label changes the box itself. It is the
|
||||
smallest target in the application on a phone by some margin. */
|
||||
--check-size: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect a stated preference for reduced motion everywhere, at once. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
/* The motion tokens too, for anything that composes a duration rather than
|
||||
declaring one -- a `transition: transform var(--dur-3)` is neutralised by
|
||||
the rule above, but an `animation-delay` built from one is not. */
|
||||
:root {
|
||||
--dur-1: 0.01ms;
|
||||
--dur-2: 0.01ms;
|
||||
--dur-3: 0.01ms;
|
||||
--dur-slow: 0.01ms;
|
||||
--transition-fast: 0.01ms;
|
||||
--transition: 0.01ms;
|
||||
--transition-slow: 0.01ms;
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/* esm.sh - @codemirror/autocomplete@6.20.4 */
|
||||
import{Annotation as je,StateEffect as O,EditorSelection as v,codePointAt as w,codePointSize as T,fromCodePoint as Ee,Facet as De,combineConfig as He,StateField as ce,Prec as fe,Transaction as Ve,Text as qe,MapMode as J,RangeValue as Ke,RangeSet as ze,CharCategory as Z}from"./cm-state.mjs";import{Direction as Ye,logException as _,showTooltip as Qe,EditorView as L,ViewPlugin as Xe,getTooltip as Re,Decoration as q,WidgetType as Ge,keymap as Pe}from"./cm-view.mjs";import{syntaxTree as $,indentUnit as Je}from"./cm-language.mjs";var U=class{constructor(e,t,n,o){this.state=e,this.pos=t,this.explicit=n,this.view=o,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=$(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),n=Math.max(t.from,this.pos-250),o=t.text.slice(n-t.from,this.pos-t.from),s=o.search(Le(e,!1));return s<0?null:{from:n+s,to:this.pos,text:o.slice(s)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,n){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),n&&n.onDocChange&&(this.abortOnDocChange=!0))}};function ge(i){let e=Object.keys(i).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function Ze(i){let e=Object.create(null),t=Object.create(null);for(let{label:o}of i){e[o[0]]=!0;for(let s=1;s<o.length;s++)t[o[s]]=!0}let n=ge(e)+ge(t)+"*$";return[new RegExp("^"+n),new RegExp(n)]}function _e(i){let e=i.map(o=>typeof o=="string"?{label:o}:o),[t,n]=e.every(o=>/^\w+$/.test(o.label))?[/\w*$/,/\w+$/]:Ze(e);return o=>{let s=o.matchBefore(n);return s||o.explicit?{from:s?s.from:o.pos,options:e,validFor:t}:null}}function zt(i,e){return t=>{for(let n=$(t.state).resolveInner(t.pos,-1);n;n=n.parent){if(i.indexOf(n.name)>-1)return e(t);if(n.type.isTop)break}return null}}function Yt(i,e){return t=>{for(let n=$(t.state).resolveInner(t.pos,-1);n;n=n.parent){if(i.indexOf(n.name)>-1)return null;if(n.type.isTop)break}return e(t)}}var j=class{constructor(e,t,n,o){this.completion=e,this.source=t,this.match=n,this.score=o}};function E(i){return i.selection.main.from}function Le(i,e){var t;let{source:n}=i,o=e&&n[0]!="^",s=n[n.length-1]!="$";return!o&&!s?i:new RegExp(`${o?"^":""}(?:${n})${s?"$":""}`,(t=i.flags)!==null&&t!==void 0?t:i.ignoreCase?"i":"")}var he=je.define();function et(i,e,t,n){let{main:o}=i.selection,s=t-o.from,l=n-o.from;return{...i.changeByRange(a=>{if(a!=o&&t!=n&&i.sliceDoc(a.from+s,a.from+l)!=i.sliceDoc(t,n))return{range:a};let r=i.toText(e);return{changes:{from:a.from+s,to:n==o.from?a.to:a.from+l,insert:r},range:v.cursor(a.from+s+r.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}var ye=new WeakMap;function tt(i){if(!Array.isArray(i))return i;let e=ye.get(i);return e||ye.set(i,e=_e(i)),e}var H=O.define(),M=O.define(),ee=class{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t<e.length;){let n=w(e,t),o=T(n);this.chars.push(n);let s=e.slice(t,t+o),l=s.toUpperCase();this.folded.push(w(l==s?s.toLowerCase():l,0)),t+=o}this.astral=e.length!=this.chars.length}ret(e,t){return this.score=e,this.matched=t,this}match(e){if(this.pattern.length==0)return this.ret(-100,[]);if(e.length<this.pattern.length)return null;let{chars:t,folded:n,any:o,precise:s,byWord:l}=this;if(t.length==1){let d=w(e,0),R=T(d),C=R==e.length?0:-100;if(d!=t[0])if(d==n[0])C+=-200;else return null;return this.ret(C,[0,R])}let a=e.indexOf(this.pattern);if(a==0)return this.ret(e.length==this.pattern.length?0:-100,[0,this.pattern.length]);let r=t.length,h=0;if(a<0){for(let d=0,R=Math.min(e.length,200);d<R&&h<r;){let C=w(e,d);(C==t[h]||C==n[h])&&(o[h++]=d),d+=T(C)}if(h<r)return null}let c=0,f=0,u=!1,p=0,m=-1,b=-1,k=/[a-z]/.test(e),I=!0;for(let d=0,R=Math.min(e.length,200),C=0;d<R&&f<r;){let x=w(e,d);a<0&&(c<r&&x==t[c]&&(s[c++]=d),p<r&&(x==t[p]||x==n[p]?(p==0&&(m=d),b=d+1,p++):p=0));let W,Y=x<255?x>=48&&x<=57||x>=97&&x<=122?2:x>=65&&x<=90?1:0:(W=Ee(x))!=W.toLowerCase()?1:W!=W.toUpperCase()?2:0;(!d||Y==1&&k||C==0&&Y!=0)&&(t[f]==x||n[f]==x&&(u=!0)?l[f++]=d:l.length&&(I=!1)),C=Y,d+=T(x)}return f==r&&l[0]==0&&I?this.result(-100+(u?-200:0),l,e):p==r&&m==0?this.ret(-200-e.length+(b==e.length?0:-100),[0,b]):a>-1?this.ret(-700-e.length,[a,a+this.pattern.length]):p==r?this.ret(-900-e.length,[m,b]):f==r?this.result(-100+(u?-200:0)+-700+(I?0:-1100),l,e):t.length==2?null:this.result((o[0]?-700:0)+-200+-1100,o,e)}result(e,t,n){let o=[],s=0;for(let l of t){let a=l+(this.astral?T(w(n,l)):1);s&&o[s-1]==l?o[s-1]=a:(o[s++]=l,o[s++]=a)}return this.ret(e-n.length,o)}},te=class{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length<this.pattern.length)return null;let t=e.slice(0,this.pattern.length),n=t==this.pattern?0:t.toLowerCase()==this.folded?-20Line truncated
|
||||
//# sourceMappingURL=autocomplete.bundle.mjs.map
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/* esm.sh - codemirror@6.0.2 */
|
||||
import{lineNumbers as p,highlightActiveLineGutter as c,highlightSpecialChars as t,drawSelection as e,dropCursor as h,rectangularSelection as n,crosshairCursor as s,highlightActiveLine as f,keymap as i}from"./cm-view.mjs";import{EditorView as j}from"./cm-view.mjs";import{EditorState as u}from"./cm-state.mjs";import{foldGutter as g,indentOnInput as y,syntaxHighlighting as o,defaultHighlightStyle as r,bracketMatching as d,foldKeymap as S}from"./cm-language.mjs";import{history as a,defaultKeymap as l,historyKeymap as m}from"./cm-commands.mjs";import{highlightSelectionMatches as K,searchKeymap as k}from"./cm-search.mjs";import{closeBrackets as b,autocompletion as w,closeBracketsKeymap as x,completionKeymap as C}from"./cm-autocomplete.mjs";import{lintKeymap as M}from"./cm-lint.mjs";var I=[p(),c(),t(),a(),g(),e(),h(),u.allowMultipleSelections.of(!0),y(),o(r,{fallback:!0}),d(),b(),w(),n(),s(),f(),K(),i.of([...x,...l,...k,...m,...S,...C,...M])],N=[t(),a(),e(),o(r,{fallback:!0}),i.of([...l,...m])];export{j as EditorView,I as basicSetup,N as minimalSetup};
|
||||
//# sourceMappingURL=codemirror.bundle.mjs.map
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/* esm.sh - @codemirror/commands@6.11.1 */
|
||||
import{Annotation as ke,Facet as Se,combineConfig as nt,StateField as rt,Transaction as K,ChangeSet as ot,ChangeDesc as lt,EditorSelection as h,StateEffect as Ae,Text as Be,findClusterBreak as T,countColumn as ee,CharCategory as C}from"./cm-state.mjs";import{EditorView as O,Direction as ct}from"./cm-view.mjs";import{IndentContext as Ce,getIndentation as De,indentString as G,matchBrackets as E,syntaxTree as te,getIndentUnit as Q,indentUnit as st}from"./cm-language.mjs";import{NodeProp as X}from"./lezer-common.mjs";var it=e=>{let{state:t}=e,n=t.doc.lineAt(t.selection.main.from),r=ne(e.state,n.from);return r.line?ut(e):r.block?at(e):!1};function x(e,t){return({state:n,dispatch:r})=>{if(n.readOnly)return!1;let l=e(t,n);return l?(r(n.update(l)),!0):!1}}var ut=x(re,0),An=x(re,1),Bn=x(re,2),ft=x(N,0),Cn=x(N,1),Dn=x(N,2),at=x((e,t)=>N(e,t,dt(t)),0);function ne(e,t){let n=e.languageDataAt("commentTokens",t,1);return n.length?n[0]:{}}var I=50;function ht(e,{open:t,close:n},r,l){let o=e.sliceDoc(r-I,r),c=e.sliceDoc(l,l+I),s=/\s*$/.exec(o)[0].length,i=/^\s*/.exec(c)[0].length,f=o.length-s;if(o.slice(f-t.length,f)==t&&c.slice(i,i+n.length)==n)return{open:{pos:r-s,margin:s&&1},close:{pos:l+i,margin:i&&1}};let u,a;l-r<=2*I?u=a=e.sliceDoc(r,l):(u=e.sliceDoc(r,r+I),a=e.sliceDoc(l-I,l));let d=/^\s*/.exec(u)[0].length,S=/\s*$/.exec(a)[0].length,y=a.length-S-n.length;return u.slice(d,d+t.length)==t&&a.slice(y,y+n.length)==n?{open:{pos:r+d+t.length,margin:/\s/.test(u.charAt(d+t.length))?1:0},close:{pos:l-S-n.length,margin:/\s/.test(a.charAt(y-1))?1:0}}:null}function dt(e){let t=[];for(let n of e.selection.ranges){let r=e.doc.lineAt(n.from),l=n.to<=r.to?r:e.doc.lineAt(n.to);l.from>r.from&&l.from==n.to&&(l=n.to==r.to+1?r:e.doc.lineAt(n.to-1));let o=t.length-1;o>=0&&t[o].to>r.from?t[o].to=l.to:t.push({from:r.from+/^\s*/.exec(r.text)[0].length,to:l.to})}return t}function N(e,t,n=t.selection.ranges){let r=n.map(o=>ne(t,o.from).block);if(!r.every(o=>o))return null;let l=n.map((o,c)=>ht(t,r[c],o.from,o.to));if(e!=2&&!l.every(o=>o))return{changes:t.changes(n.map((o,c)=>l[c]?[]:[{from:o.from,insert:r[c].open+" "},{from:o.to,insert:" "+r[c].close}]))};if(e!=1&&l.some(o=>o)){let o=[];for(let c=0,s;c<l.length;c++)if(s=l[c]){let i=r[c],{open:f,close:u}=s;o.push({from:f.pos-i.open.length,to:f.pos+f.margin},{from:u.pos-u.margin,to:u.pos+i.close.length})}return{changes:o}}return null}function re(e,t,n=t.selection.ranges){let r=[],l=-1;e:for(let{from:o,to:c}of n){let s=r.length,i=1e9,f;for(let u=o;u<=c;){let a=t.doc.lineAt(u);if(f==null&&(f=ne(t,a.from).line,!f))continue e;if(a.from>l&&(o==c||c>a.from)){l=a.from;let d=/^\s*/.exec(a.text)[0].length,S=d==a.length,y=a.text.slice(d,d+f.length)==f?d:-1;d<a.text.length&&d<i&&(i=d),r.push({line:a,comment:y,token:f,indent:d,empty:S,single:!1})}u=a.to+1}if(i<1e9)for(let u=s;u<r.length;u++)r[u].indent<r[u].line.text.length&&(r[u].indent=i);r.length==s+1&&(r[s].single=!0)}if(e!=2&&r.some(o=>o.comment<0&&(!o.empty||o.single))){let o=[];for(let{line:s,token:i,indent:f,empty:u,single:a}of r)(a||!u)&&o.push({from:s.from+f,insert:i+" "});let c=t.changes(o);return{changes:c,selection:t.selection.map(c,1)}}else if(e!=1&&r.some(o=>o.comment>=0)){let o=[];for(let{line:c,comment:s,token:i}of r)if(s>=0){let f=c.from+s,u=f+i.length;c.text[u-c.from]==" "&&u++,o.push({from:f,to:u})}return{changes:o}}return null}var Y=ke.define(),mt=ke.define(),pt=Se.define(),Le=Se.define({combine(e){return nt(e,{minDepth:100,newGroupDelay:500,joinToEvent:(t,n)=>n},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(t,n)=>(r,l)=>t(r,l)||n(r,l)})}}),R=rt.define({create(){return L.empty},update(e,t){let n=t.state.facet(Le),r=t.annotation(Y);if(r){let i=A.fromTransaction(t,r.selection),f=r.side,u=f==0?e.undone:e.done;return i?u=V(u,u.length,n.minDepth,i):u=Te(u,t.startState.selection),new L(f==0?r.rest:u,f==0?u:r.rest)}let l=t.annotation(mt);if((l=="full"||l=="before")&&(e=e.isolate()),t.annotation(K.addToHistory)===!1)return t.changes.empty?e:e.addMapping(t.changes.desc);let o=A.fromTransaction(t),c=t.annotation(K.time),s=t.annotation(K.userEvent);return o?e=e.addChanges(o,c,s,n,t):t.selection&&(e=e.addSelection(t.startState.selection,c,s,n.newGroupDelay)),(l=="full"||l=="after")&&(e=e.isolate()),e},toJSON(e){return{done:e.done.map(t=>t.toJSON()),undone:e.undone.map(t=>t.toJSON())}},fromJSON(e){return new L(e.done.map(A.fromJSON),e.undone.map(A.fromJSON))}});function Ln(e={}){return[R,Le.of(e),O.domEventHandlers({beforeinput(t,n){let r=t.inputType=="historyUndo"?xe:t.inputType=="historyRedo"?Z:null;return r?(t.preventDefault(),r(n)):!1}})]}var xn=R;function P(e,t){return function({state:n,dispatch:r}){if(!t&&n.readOnly)return!1;let l=n.field(R,!1);if(!l)return!1;let o=l.pop(e,n,t);return o?(r(o),!0):!1}}var xe=P(0,!1),Z=P(1,!1),gt=P(0,!0),yt=P(1,!0);function Me(e){return function(t){let n=t.field(R,!1);if(!n)return 0;let r=e==0?n.done:n.undone;return r.length-(r.length&&!r[0].changes?1:0)}}var Mn=Me(0),bn=Me(1);function be(e){return fLine truncated
|
||||
//# sourceMappingURL=commands.bundle.mjs.map
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user