Compare commits
28
Commits
| 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
|
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
||||||
# page's scripts, the pins (tokens, CodeMirror), the public-repository hygiene -- and the app
|
# page's scripts, the pins (tokens, CodeMirror, xterm), the public-repository hygiene, the page
|
||||||
# started for real, on X11 and on Wayland, to prove it gets as far as its window. The packages
|
# itself under a headless browser (tests/ui) -- and the app started for real, on X11 and on
|
||||||
# are built only by release.yml, from a tag.
|
# Wayland, to prove it gets as far as its window. The packages are built only by release.yml,
|
||||||
|
# from a tag.
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -40,11 +41,32 @@ jobs:
|
|||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
- name: The page's scripts parse, and none sets HTML
|
- name: The page's scripts parse, and none sets HTML
|
||||||
run: scripts/check-ui.sh
|
run: scripts/check-ui.sh
|
||||||
- name: The design tokens and CodeMirror are the server's, as pinned
|
- name: The design tokens, CodeMirror and xterm are the server's, as pinned
|
||||||
run: |
|
run: |
|
||||||
scripts/fetch-tokens.sh
|
scripts/fetch-tokens.sh
|
||||||
scripts/fetch-vendor.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
|
# 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,
|
# 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
|
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
|
||||||
|
|||||||
+146
-1
@@ -4,7 +4,152 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.0-beta.3
|
2.0.0-beta.7
|
||||||
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"
|
||||||
+16
-4
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Put the server's vendored CodeMirror build where the app's own pages read it from:
|
# Put the server's vendored CodeMirror build and xterm where the app's own pages read them from:
|
||||||
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
|
# ui/vendor/*.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
|
# 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
|
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
|
||||||
@@ -47,8 +49,8 @@ if [ $# -eq 0 ]; then
|
|||||||
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK")
|
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"/*.mjs; do
|
for f in "$OUT"/*; do
|
||||||
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
||||||
done
|
done
|
||||||
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
||||||
@@ -87,6 +89,16 @@ for name in $names; do
|
|||||||
fi
|
fi
|
||||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
||||||
done
|
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).
|
# 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"
|
stub="src/lembas/web/static/js/node-process.mjs"
|
||||||
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
||||||
@@ -101,7 +113,7 @@ done
|
|||||||
|
|
||||||
rm -rf "$OUT"
|
rm -rf "$OUT"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
cp "$work"/out/*.mjs "$OUT/"
|
cp "$work"/out/*.mjs "$work"/out/xterm.js "$work"/out/xterm.css "$work"/out/xterm-addon-fit.js "$OUT/"
|
||||||
{
|
{
|
||||||
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
||||||
last=$((${#entries[@]} - 1))
|
last=$((${#entries[@]} - 1))
|
||||||
|
|||||||
+870
-39
File diff suppressed because it is too large.
Load diff
Generated
+86
-4
@@ -279,6 +279,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -511,6 +520,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -623,6 +638,15 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -686,6 +710,12 @@ dependencies = [
|
|||||||
"syn 3.0.8",
|
"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]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.12"
|
version = "0.9.12"
|
||||||
@@ -775,8 +805,19 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"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]]
|
[[package]]
|
||||||
@@ -1552,6 +1593,15 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
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]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -2085,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.4"
|
version = "1.0.0-beta.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2097,7 +2147,9 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"webkit2gtk",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3488,6 +3540,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3496,7 +3559,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4539,6 +4602,25 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.4"
|
version = "1.0.0-beta.7"
|
||||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||||
authors = ["Jaroslav Beneš"]
|
authors = ["Jaroslav Beneš"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -26,10 +26,17 @@ tauri-plugin-updater = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
|
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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# The webview's own settings: getUserMedia is off in WebKitGTK until the app turns it on.
|
||||||
|
# The same crate and version wry already builds on.
|
||||||
|
webkit2gtk = { version = "2.0", features = ["v2_4"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
+363
-9
@@ -22,6 +22,7 @@ mod remote;
|
|||||||
mod sealed;
|
mod sealed;
|
||||||
mod server;
|
mod server;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod terminal;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -35,13 +36,14 @@ use serde::Serialize;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
use tauri::tray::TrayIconBuilder;
|
use tauri::tray::TrayIconBuilder;
|
||||||
|
use tauri::webview::{PermissionKind, PermissionResponse};
|
||||||
use tauri::window::Color;
|
use tauri::window::Color;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||||
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
@@ -115,10 +117,15 @@ struct App {
|
|||||||
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
||||||
next_stream: AtomicU64,
|
next_stream: AtomicU64,
|
||||||
|
/// The terminal panel's sockets (`terminal.rs`), by the page's id for each.
|
||||||
|
terminals: terminal::Sessions,
|
||||||
sealed: sealed::Pages,
|
sealed: sealed::Pages,
|
||||||
watch: watch::Watch,
|
watch: watch::Watch,
|
||||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||||
dropped: Mutex<Vec<PathBuf>>,
|
dropped: Mutex<Vec<PathBuf>>,
|
||||||
|
/// Until when the page's own Dictate button may have the microphone (`voice_arm`): a request
|
||||||
|
/// for it at any other moment is refused.
|
||||||
|
mic_until: Mutex<Option<std::time::Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrayItems {
|
struct TrayItems {
|
||||||
@@ -150,6 +157,13 @@ impl App {
|
|||||||
Some((server.app, account.token))
|
Some((server.app, account.token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server's own address and the app's token: for the `/v1` routes (voice, pictures).
|
||||||
|
fn door_v1(&self) -> Option<(String, String)> {
|
||||||
|
let server = self.server.lock().unwrap().clone()?;
|
||||||
|
let account = self.account.lock().unwrap().clone()?;
|
||||||
|
Some((server.base, account.token))
|
||||||
|
}
|
||||||
|
|
||||||
/// The folders the agent may use: what the explorer and the viewer may read.
|
/// The folders the agent may use: what the explorer and the viewer may read.
|
||||||
fn roots(&self) -> Vec<String> {
|
fn roots(&self) -> Vec<String> {
|
||||||
self.remote
|
self.remote
|
||||||
@@ -271,6 +285,7 @@ async fn set_server(handle: AppHandle, address: String) -> Result<Status, String
|
|||||||
if before.as_deref() != Some(d.base.as_str()) {
|
if before.as_deref() != Some(d.base.as_str()) {
|
||||||
app.agent.stop();
|
app.agent.stop();
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
}
|
}
|
||||||
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
||||||
*app.server.lock().unwrap() = Some(d);
|
*app.server.lock().unwrap() = Some(d);
|
||||||
@@ -380,6 +395,7 @@ fn sign_out(handle: AppHandle) {
|
|||||||
let account = app.account.lock().unwrap().take();
|
let account = app.account.lock().unwrap().take();
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
if let Some(a) = account {
|
if let Some(a) = account {
|
||||||
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
||||||
}
|
}
|
||||||
@@ -391,6 +407,7 @@ fn token_refused(handle: &AppHandle) {
|
|||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
if app.account.lock().unwrap().take().is_some() {
|
if app.account.lock().unwrap().take().is_some() {
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
|
app.terminals.close_all();
|
||||||
announce(handle);
|
announce(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,6 +463,77 @@ async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError
|
|||||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Voice and pictures (`/v1`, the app's token holds the `models` scope) -----------------------
|
||||||
|
|
||||||
|
/// Run a `/v1` call off the page's thread, as the signed-in account; a 401 signs the app out.
|
||||||
|
async fn on_v1<T: Send + 'static>(
|
||||||
|
handle: &AppHandle,
|
||||||
|
f: impl FnOnce(&str, &str) -> Result<T, ApiError> + Send + 'static,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
let Some((base, token)) = handle.state::<App>().door_v1() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || f(&base, &token))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||||
|
if matches!(&result, Err(e) if e.status == 401) {
|
||||||
|
token_refused(handle);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page is about to ask for the microphone, because the person pressed Dictate: for the
|
||||||
|
/// next few seconds a request for it is allowed (`permission`), and at no other time.
|
||||||
|
#[tauri::command]
|
||||||
|
fn voice_arm(app: tauri::State<'_, App>) {
|
||||||
|
*app.mic_until.lock().unwrap() =
|
||||||
|
Some(std::time::Instant::now() + std::time::Duration::from_secs(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording made in the composer, in words: its bytes as the raw body of the call, its media
|
||||||
|
/// type in `x-type`.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn transcribe(
|
||||||
|
handle: AppHandle,
|
||||||
|
request: tauri::ipc::Request<'_>,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
|
||||||
|
return Err(ApiError::new(400, "No recording."));
|
||||||
|
};
|
||||||
|
if bytes.len() as u64 > server::RECORDING_LIMIT {
|
||||||
|
return Err(ApiError::new(413, "That recording is too long."));
|
||||||
|
}
|
||||||
|
let kind = request
|
||||||
|
.headers()
|
||||||
|
.get("x-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let bytes = bytes.clone();
|
||||||
|
on_v1(&handle, move |base, token| {
|
||||||
|
server::transcribe(base, token, &kind, &bytes)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reply read aloud, as a `data:` URL of its audio.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn speak(handle: AppHandle, text: String) -> Result<String, ApiError> {
|
||||||
|
on_v1(&handle, move |base, token| {
|
||||||
|
server::speak(base, token, &text)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pictures drawn by the instance for `prompt`, as `data:` URLs.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn draw(handle: AppHandle, prompt: String, size: String) -> Result<Vec<String>, ApiError> {
|
||||||
|
on_v1(&handle, move |base, token| {
|
||||||
|
server::draw(base, token, &prompt, &size)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
|
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
|
||||||
/// first word of "continue in a terminal" for an agent chat on this computer.
|
/// first word of "continue in a terminal" for an agent chat on this computer.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -511,6 +599,71 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Open a shell on a linked device, as the terminal panel asks: an agent chat's (`chat`), or the
|
||||||
|
/// one before the chat exists (`device` and `directory`). The socket is opened here, with the
|
||||||
|
/// app's token in its handshake; the page gets its frames as `terminal` events with this id --
|
||||||
|
/// `output` (base64), `control` (the server's JSON), and a last `end`.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_open(
|
||||||
|
handle: AppHandle,
|
||||||
|
chat: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
directory: Option<String>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<u64, ApiError> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let Some((app_url, token)) = app.door() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let target = match (chat, device) {
|
||||||
|
(Some(chat), None) => terminal::Target::Chat(chat),
|
||||||
|
(None, Some(device)) => terminal::Target::Device {
|
||||||
|
device,
|
||||||
|
directory: directory.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
_ => return Err(ApiError::new(400, "A chat or a device, not both.")),
|
||||||
|
};
|
||||||
|
let url = terminal::url(&app_url, &target, cols, rows)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let on_end = handle.clone();
|
||||||
|
Ok(app.terminals.open(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
move |down| {
|
||||||
|
let _ = h.emit("terminal", down);
|
||||||
|
},
|
||||||
|
move |id, end| {
|
||||||
|
on_end.state::<App>().terminals.forget(id);
|
||||||
|
if end.unauthorized() {
|
||||||
|
token_refused(&on_end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys typed (or pasted) into a terminal, as text: sent to the shell as its bytes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_input(app: tauri::State<'_, App>, id: u64, data: String) -> bool {
|
||||||
|
app.terminals
|
||||||
|
.send(id, terminal::Up::Keys(data.into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal's size changed.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_resize(app: tauri::State<'_, App>, id: u64, cols: u16, rows: u16) -> bool {
|
||||||
|
app.terminals.send(id, terminal::Up::Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page let go of a terminal: its socket is closed (the shell on the device lives on, as
|
||||||
|
/// the server keeps it).
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_close(app: tauri::State<'_, App>, id: u64) {
|
||||||
|
app.terminals.close(id);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Attachments ----------------------------------------------------------------------------------
|
// --- Attachments ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
||||||
@@ -619,6 +772,75 @@ async fn attach_dropped(
|
|||||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What an upload into the library came to: the directory's listing after the last file the
|
||||||
|
/// server took, and the files it did not take, each with the reason.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct LibraryUpload {
|
||||||
|
listing: Option<Value>,
|
||||||
|
sent: usize,
|
||||||
|
failed: Vec<Uploaded>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose files with the system's dialog and send them into the person's library, into the
|
||||||
|
/// directory `dir`. Nothing is sent when the dialog is cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn library_upload(
|
||||||
|
handle: AppHandle,
|
||||||
|
dir: String,
|
||||||
|
title: String,
|
||||||
|
) -> Result<LibraryUpload, ApiError> {
|
||||||
|
let h = handle.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let picked = h.dialog().file().set_title(title).blocking_pick_files();
|
||||||
|
let paths: Vec<PathBuf> = picked
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| p.into_path().ok())
|
||||||
|
.collect();
|
||||||
|
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let mut out = LibraryUpload {
|
||||||
|
listing: None,
|
||||||
|
sent: 0,
|
||||||
|
failed: Vec::new(),
|
||||||
|
};
|
||||||
|
for path in paths {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "file".into());
|
||||||
|
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
let sent = match read_for_upload(&path) {
|
||||||
|
Ok(bytes) => server::upload_to_library(&app_url, &token, &dir, &name, &bytes)
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.status == 401 {
|
||||||
|
token_refused(&h);
|
||||||
|
}
|
||||||
|
(None, e.detail)
|
||||||
|
}),
|
||||||
|
Err((reason, message)) => Err((Some(reason), message)),
|
||||||
|
};
|
||||||
|
match sent {
|
||||||
|
Ok(listing) => {
|
||||||
|
out.listing = Some(listing);
|
||||||
|
out.sent += 1;
|
||||||
|
}
|
||||||
|
Err((reason, message)) => out.failed.push(Uploaded {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
attachment: None,
|
||||||
|
error: Some(message),
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||||
|
}
|
||||||
|
|
||||||
/// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
|
/// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
|
||||||
fn percent_decode(text: &str) -> String {
|
fn percent_decode(text: &str) -> String {
|
||||||
let b = text.as_bytes();
|
let b = text.as_bytes();
|
||||||
@@ -1109,7 +1331,39 @@ fn build_main(handle: &AppHandle) -> tauri::Result<WebviewWindow> {
|
|||||||
.decorations(false)
|
.decorations(false)
|
||||||
.background_color(theme.background())
|
.background_color(theme.background())
|
||||||
.on_navigation(allowed)
|
.on_navigation(allowed)
|
||||||
|
.on_permission_request(permission)
|
||||||
.build()
|
.build()
|
||||||
|
.inspect(media_stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebKitGTK answers no permission request its app does not, and has no prompt of its own. The
|
||||||
|
/// microphone is allowed when the page's own Dictate button has just asked for it (`voice_arm`,
|
||||||
|
/// which only the app's page can call: sealed frames have no bridge), once; nothing else is.
|
||||||
|
fn permission(view: tauri::Webview, kind: PermissionKind) -> PermissionResponse {
|
||||||
|
if kind != PermissionKind::Microphone {
|
||||||
|
return PermissionResponse::Default;
|
||||||
|
}
|
||||||
|
let app = view.app_handle().state::<App>();
|
||||||
|
let armed = app.mic_until.lock().unwrap().take();
|
||||||
|
if armed.is_some_and(|until| std::time::Instant::now() <= until) {
|
||||||
|
PermissionResponse::Allow
|
||||||
|
} else {
|
||||||
|
PermissionResponse::Deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `getUserMedia` is off in WebKitGTK unless the app turns it on (it is on in other webviews).
|
||||||
|
/// The settings are read with their own getter: the `settings` property is write-only.
|
||||||
|
fn media_stream(window: &WebviewWindow) {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _ = window.with_webview(|pv| {
|
||||||
|
use webkit2gtk::{SettingsExt, WebViewExt};
|
||||||
|
if let Some(settings) = pv.inner().settings() {
|
||||||
|
settings.set_enable_media_stream(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let _ = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever.
|
/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever.
|
||||||
@@ -1402,9 +1656,11 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
} else {
|
} else {
|
||||||
d.title.clone()
|
d.title.clone()
|
||||||
};
|
};
|
||||||
|
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
|
||||||
let _ = handle
|
let _ = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
|
.id(id)
|
||||||
.title(title)
|
.title(title)
|
||||||
.body(if d.agent { &words.agent } else { &words.reply })
|
.body(if d.agent { &words.agent } else { &words.reply })
|
||||||
.show();
|
.show();
|
||||||
@@ -1412,6 +1668,56 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A click on one of the app's notifications, or on one of its actions: a chat's opens that chat;
|
||||||
|
/// a scheduled run's opens its task (`open`, or the notification itself), runs it again now
|
||||||
|
/// (`run_again`) or pauses it (`pause`) -- the last two without opening the window.
|
||||||
|
fn notification_action(handle: &AppHandle, done: &ActionPerformed) {
|
||||||
|
let Some(id) = done.notification().map(|n| n.id()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(note) = handle.state::<App>().watch.notes.get(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match (note, done.action_id()) {
|
||||||
|
(watch::Note::Chat(chat), "tap") => {
|
||||||
|
show(handle);
|
||||||
|
let _ = handle.emit("open-chat", chat);
|
||||||
|
}
|
||||||
|
(watch::Note::Run { schedule, run }, "tap" | "open") => {
|
||||||
|
show(handle);
|
||||||
|
let _ = handle.emit(
|
||||||
|
"open-schedule",
|
||||||
|
serde_json::json!({ "schedule": schedule, "run": run }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(watch::Note::Run { schedule, .. }, which @ ("run_again" | "pause")) => {
|
||||||
|
let path = format!(
|
||||||
|
"/schedules/{}/{}",
|
||||||
|
terminal::encode(&schedule),
|
||||||
|
if which == "pause" { "pause" } else { "run" }
|
||||||
|
);
|
||||||
|
let h = handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let Some((app_url, token)) = h.state::<App>().door() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match server::request(
|
||||||
|
&app_url,
|
||||||
|
&token,
|
||||||
|
"POST",
|
||||||
|
&path,
|
||||||
|
Some(&serde_json::json!({})),
|
||||||
|
) {
|
||||||
|
Err(e) if e.status == 401 => token_refused(&h),
|
||||||
|
Err(e) => log::write(&format!("a notification's action failed: {}", e.detail)),
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How long to wait before following the event stream again: after it closed or broke off, and
|
/// How long to wait before following the event stream again: after it closed or broke off, and
|
||||||
/// (longer) on a server that has none.
|
/// (longer) on a server that has none.
|
||||||
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
@@ -1449,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
|
|||||||
} else {
|
} else {
|
||||||
r.title
|
r.title
|
||||||
};
|
};
|
||||||
let body = match r.told {
|
let body = r.body.clone().unwrap_or_else(|| {
|
||||||
watch::Told::Result => &words.scheduled,
|
match r.told {
|
||||||
watch::Told::Failed => &words.scheduled_failed,
|
watch::Told::Result => &words.scheduled,
|
||||||
watch::Told::Pause => &words.scheduled_pause,
|
watch::Told::Failed => &words.scheduled_failed,
|
||||||
};
|
watch::Told::Pause => &words.scheduled_pause,
|
||||||
let _ = handle
|
}
|
||||||
|
.clone()
|
||||||
|
});
|
||||||
|
let id = app.watch.notes.add(watch::Note::Run {
|
||||||
|
schedule: r.schedule.clone(),
|
||||||
|
run: r.run.clone(),
|
||||||
|
});
|
||||||
|
let mut note = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
|
.id(id)
|
||||||
.title(title)
|
.title(title)
|
||||||
.body(body)
|
.body(body);
|
||||||
.show();
|
if !r.actions.is_empty() {
|
||||||
|
// One action type per set of actions; registered again, it takes the labels
|
||||||
|
// this run came with (the person's language may have changed).
|
||||||
|
let kind = format!(
|
||||||
|
"schedule-{}",
|
||||||
|
r.actions
|
||||||
|
.iter()
|
||||||
|
.map(|(a, _)| a.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
);
|
||||||
|
let actions = r
|
||||||
|
.actions
|
||||||
|
.iter()
|
||||||
|
.map(|(a, label)| Action::builder(a, label).build())
|
||||||
|
.collect();
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.register_action_types(vec![ActionType::builder(&kind)
|
||||||
|
.actions(actions)
|
||||||
|
.build()]);
|
||||||
|
note = note.action_type_id(kind);
|
||||||
|
}
|
||||||
|
let _ = note.show();
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
@@ -1560,11 +1897,20 @@ pub fn run() {
|
|||||||
terminal_word,
|
terminal_word,
|
||||||
stream_open,
|
stream_open,
|
||||||
stream_close,
|
stream_close,
|
||||||
|
terminal_open,
|
||||||
|
terminal_input,
|
||||||
|
terminal_resize,
|
||||||
|
terminal_close,
|
||||||
local_list,
|
local_list,
|
||||||
local_read,
|
local_read,
|
||||||
sealed_open,
|
sealed_open,
|
||||||
sealed_close,
|
sealed_close,
|
||||||
attach_pick,
|
attach_pick,
|
||||||
|
library_upload,
|
||||||
|
voice_arm,
|
||||||
|
transcribe,
|
||||||
|
speak,
|
||||||
|
draw,
|
||||||
attach_dropped,
|
attach_dropped,
|
||||||
attach_bytes,
|
attach_bytes,
|
||||||
set_theme,
|
set_theme,
|
||||||
@@ -1621,9 +1967,11 @@ pub fn run() {
|
|||||||
signing_in: Mutex::new(None),
|
signing_in: Mutex::new(None),
|
||||||
streams: Mutex::new(HashMap::new()),
|
streams: Mutex::new(HashMap::new()),
|
||||||
next_stream: AtomicU64::new(0),
|
next_stream: AtomicU64::new(0),
|
||||||
|
terminals: terminal::Sessions::default(),
|
||||||
sealed: sealed::Pages::default(),
|
sealed: sealed::Pages::default(),
|
||||||
watch: watch::Watch::default(),
|
watch: watch::Watch::default(),
|
||||||
dropped: Mutex::new(Vec::new()),
|
dropped: Mutex::new(Vec::new()),
|
||||||
|
mic_until: Mutex::new(None),
|
||||||
});
|
});
|
||||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||||
// starts: the title bar's menu has every item the tray has.
|
// starts: the title bar's menu has every item the tray has.
|
||||||
@@ -1635,6 +1983,12 @@ pub fn run() {
|
|||||||
watch_updates(handle.clone());
|
watch_updates(handle.clone());
|
||||||
watch_chats(handle.clone());
|
watch_chats(handle.clone());
|
||||||
watch_events(handle.clone());
|
watch_events(handle.clone());
|
||||||
|
{
|
||||||
|
let h = handle.clone();
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.on_action(move |done| notification_action(&h, done));
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|||||||
+268
-11
@@ -650,12 +650,17 @@ fn header_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body of a `multipart/form-data` request with one file in the field `file`.
|
/// The body of a `multipart/form-data` request with one file in the field `field`.
|
||||||
fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
fn multipart(boundary: &str, field: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||||
|
multipart_typed(boundary, field, name, "application/octet-stream", bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, with the file's own media type (a recording says what it is: `audio/webm`).
|
||||||
|
fn multipart_typed(boundary: &str, field: &str, name: &str, kind: &str, bytes: &[u8]) -> Vec<u8> {
|
||||||
let mut body = Vec::with_capacity(bytes.len() + 256);
|
let mut body = Vec::with_capacity(bytes.len() + 256);
|
||||||
body.extend_from_slice(
|
body.extend_from_slice(
|
||||||
format!(
|
format!(
|
||||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: {kind}\r\n\r\n",
|
||||||
header_name(name)
|
header_name(name)
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
@@ -685,6 +690,224 @@ pub fn upload(
|
|||||||
} else {
|
} else {
|
||||||
format!("/attachments?chat={chat}")
|
format!("/attachments?chat={chat}")
|
||||||
};
|
};
|
||||||
|
let v = send_file(app, token, &path, "file", name, bytes)?;
|
||||||
|
v.get("attachment")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one file into the person's library, into the directory `dir` (`/files/upload`, the
|
||||||
|
/// explorer's upload): the server's listing of that directory afterwards, as JSON.
|
||||||
|
pub fn upload_to_library(
|
||||||
|
app: &str,
|
||||||
|
token: &str,
|
||||||
|
dir: &str,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<Value, ApiError> {
|
||||||
|
if !dir.starts_with('/') || dir.chars().any(|c| c.is_control()) {
|
||||||
|
return Err(ApiError::new(400, format!("Not a folder: {dir}")));
|
||||||
|
}
|
||||||
|
let path = format!("/files/upload?path={}", crate::terminal::encode(dir));
|
||||||
|
send_file(app, token, &path, "files", name, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Voice and pictures: the OpenAI-compatible routes of `/v1`, under the `models` scope ------------
|
||||||
|
|
||||||
|
/// The most a recording may be here: the server's own limit is 25 MB.
|
||||||
|
pub const RECORDING_LIMIT: u64 = 25 * 1024 * 1024;
|
||||||
|
/// The most a reply read aloud may be, as audio.
|
||||||
|
pub const SPEECH_LIMIT: u64 = 32 * 1024 * 1024;
|
||||||
|
/// The most a generated picture may be.
|
||||||
|
pub const DRAWN_LIMIT: u64 = 24 * 1024 * 1024;
|
||||||
|
/// The media types a recording may say it is; anything else goes as `audio/webm`.
|
||||||
|
const RECORDING_TYPES: [&str; 5] = [
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp4",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A recording's media type as it may be sent: one of ours, its parameters (`;codecs=opus`) off.
|
||||||
|
pub fn recording_type(kind: &str) -> &'static str {
|
||||||
|
let bare = kind
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
RECORDING_TYPES
|
||||||
|
.iter()
|
||||||
|
.find(|t| **t == bare)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("audio/webm")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording, in words: `POST /v1/audio/transcriptions` (the instance's own model, the
|
||||||
|
/// account's language). The text the server heard.
|
||||||
|
pub fn transcribe(base: &str, token: &str, kind: &str, bytes: &[u8]) -> Result<String, ApiError> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(ApiError::new(400, "The recording was empty."));
|
||||||
|
}
|
||||||
|
let kind = recording_type(kind);
|
||||||
|
let ext = kind
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("webm")
|
||||||
|
.replace("mpeg", "mp3");
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let boundary = format!("llembas-desktop-{nanos:x}");
|
||||||
|
let res = agent(Duration::from_secs(300))
|
||||||
|
.post(&format!("{base}/v1/audio/transcriptions"))
|
||||||
|
.set("authorization", &format!("Bearer {token}"))
|
||||||
|
.set("accept", "application/json")
|
||||||
|
.set(
|
||||||
|
"content-type",
|
||||||
|
&format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.send_bytes(&multipart_typed(
|
||||||
|
&boundary,
|
||||||
|
"file",
|
||||||
|
&format!("speech.{ext}"),
|
||||||
|
kind,
|
||||||
|
bytes,
|
||||||
|
))?;
|
||||||
|
let v: Value = res
|
||||||
|
.into_json()
|
||||||
|
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||||
|
Ok(v.get("text")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bytes` of a media type of `kinds` (by its prefix), as a `data:` URL; at most `limit`.
|
||||||
|
fn as_data_url(res: ureq::Response, prefix: &str, limit: u64) -> Result<String, ApiError> {
|
||||||
|
let kind = res
|
||||||
|
.content_type()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let fits = if prefix == "image/" {
|
||||||
|
IMAGE_TYPES.contains(&kind.as_str())
|
||||||
|
} else {
|
||||||
|
kind.starts_with(prefix)
|
||||||
|
&& kind
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || "/-+.".contains(c))
|
||||||
|
};
|
||||||
|
if !fits {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
415,
|
||||||
|
format!("Not what was asked for: {kind}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
res.into_reader()
|
||||||
|
.take(limit)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||||
|
if bytes.len() as u64 >= limit {
|
||||||
|
return Err(ApiError::new(413, "The server's answer is too large."));
|
||||||
|
}
|
||||||
|
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reply read aloud: `POST /v1/audio/speech` in the account's own voice and speed. The audio,
|
||||||
|
/// as a `data:` URL the page plays.
|
||||||
|
pub fn speak(base: &str, token: &str, text: &str) -> Result<String, ApiError> {
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(ApiError::new(400, "There is nothing to read aloud."));
|
||||||
|
}
|
||||||
|
let res = agent(Duration::from_secs(300))
|
||||||
|
.post(&format!("{base}/v1/audio/speech"))
|
||||||
|
.set("authorization", &format!("Bearer {token}"))
|
||||||
|
.send_json(serde_json::json!({ "input": text, "response_format": "mp3" }))?;
|
||||||
|
as_data_url(res, "audio/", SPEECH_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id of one of the person's own files in a URL `/v1/images/generations` answered with:
|
||||||
|
/// `…/v1/files/<id>/content`, and nothing else. The picture is then fetched from this app's own
|
||||||
|
/// server by that id -- never from the address the answer named.
|
||||||
|
pub fn own_file_id(url: &str) -> Option<String> {
|
||||||
|
let rest = url.split(['?', '#']).next()?;
|
||||||
|
let tail = rest.strip_suffix("/content")?;
|
||||||
|
let (head, id) = tail.rsplit_once('/')?;
|
||||||
|
let ok = head.ends_with("/v1/files")
|
||||||
|
&& !id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||||
|
ok.then(|| id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sizes a picture may be asked for: the instance rounds to what its workflow takes.
|
||||||
|
const SIZES: [&str; 3] = ["1024x1024", "1344x768", "768x1344"];
|
||||||
|
|
||||||
|
/// Pictures drawn by the instance: `POST /v1/images/generations` (its own workflow and default
|
||||||
|
/// checkpoint), each then read from the person's own files on this server. `data:` URLs.
|
||||||
|
pub fn draw(base: &str, token: &str, prompt: &str, size: &str) -> Result<Vec<String>, ApiError> {
|
||||||
|
let prompt = prompt.trim();
|
||||||
|
if prompt.is_empty() {
|
||||||
|
return Err(ApiError::new(400, "Say what to draw."));
|
||||||
|
}
|
||||||
|
let mut body = serde_json::json!({ "prompt": prompt, "n": 1, "response_format": "url" });
|
||||||
|
if SIZES.contains(&size) {
|
||||||
|
body["size"] = Value::String(size.to_string());
|
||||||
|
}
|
||||||
|
let res = agent(Duration::from_secs(900))
|
||||||
|
.post(&format!("{base}/v1/images/generations"))
|
||||||
|
.set("authorization", &format!("Bearer {token}"))
|
||||||
|
.set("accept", "application/json")
|
||||||
|
.send_json(body)?;
|
||||||
|
let v: Value = res
|
||||||
|
.into_json()
|
||||||
|
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||||
|
let ids: Vec<String> = v
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|i| i.get("url").and_then(Value::as_str).and_then(own_file_id))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
0,
|
||||||
|
"The server's answer had no picture of yours in it.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ids.iter()
|
||||||
|
.map(|id| {
|
||||||
|
let res = agent(Duration::from_secs(60))
|
||||||
|
.get(&format!("{base}/v1/files/{id}/content"))
|
||||||
|
.set("authorization", &format!("Bearer {token}"))
|
||||||
|
.call()?;
|
||||||
|
as_data_url(res, "image/", DRAWN_LIMIT)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `multipart/form-data` POST of one file to `path` on the app's door; the JSON answer.
|
||||||
|
fn send_file(
|
||||||
|
app: &str,
|
||||||
|
token: &str,
|
||||||
|
path: &str,
|
||||||
|
field: &str,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<Value, ApiError> {
|
||||||
|
let path = checked_path(path)?;
|
||||||
let nanos = std::time::SystemTime::now()
|
let nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_nanos())
|
.map(|d| d.as_nanos())
|
||||||
@@ -698,13 +921,9 @@ pub fn upload(
|
|||||||
"content-type",
|
"content-type",
|
||||||
&format!("multipart/form-data; boundary={boundary}"),
|
&format!("multipart/form-data; boundary={boundary}"),
|
||||||
)
|
)
|
||||||
.send_bytes(&multipart(&boundary, name, bytes))?;
|
.send_bytes(&multipart(&boundary, field, name, bytes))?;
|
||||||
let v: Value = res
|
res.into_json()
|
||||||
.into_json()
|
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
|
||||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
|
||||||
v.get("attachment")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||||
@@ -957,9 +1176,45 @@ mod tests {
|
|||||||
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
|
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drawn_picture_is_read_only_as_one_of_the_persons_own_files() {
|
||||||
|
assert_eq!(
|
||||||
|
own_file_id("https://ai.example/v1/files/abc_12-3/content").as_deref(),
|
||||||
|
Some("abc_12-3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
own_file_id("http://other/v1/files/x/content?t=1").as_deref(),
|
||||||
|
Some("x")
|
||||||
|
);
|
||||||
|
assert_eq!(own_file_id("https://ai.example/v1/files/../content"), None);
|
||||||
|
assert_eq!(own_file_id("https://ai.example/v1/files//content"), None);
|
||||||
|
assert_eq!(own_file_id("https://ai.example/uploads/x/content"), None);
|
||||||
|
assert_eq!(
|
||||||
|
own_file_id("https://ai.example/v1/files/a%2Fb/content"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(own_file_id("javascript:alert(1)"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recording_says_one_of_the_known_types() {
|
||||||
|
assert_eq!(recording_type("audio/webm;codecs=opus"), "audio/webm");
|
||||||
|
assert_eq!(recording_type("Audio/OGG"), "audio/ogg");
|
||||||
|
assert_eq!(recording_type("text/html"), "audio/webm");
|
||||||
|
let body = String::from_utf8(multipart_typed(
|
||||||
|
"B",
|
||||||
|
"file",
|
||||||
|
"speech.webm",
|
||||||
|
"audio/webm",
|
||||||
|
b"x",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(body.contains("filename=\"speech.webm\"\r\nContent-Type: audio/webm\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_goes_as_one_multipart_field() {
|
fn a_file_goes_as_one_multipart_field() {
|
||||||
let body = multipart("XYZ", "a\"b\r\n.txt", b"hello");
|
let body = multipart("XYZ", "file", "a\"b\r\n.txt", b"hello");
|
||||||
let text = String::from_utf8(body).unwrap();
|
let text = String::from_utf8(body).unwrap();
|
||||||
assert!(text.starts_with(
|
assert!(text.starts_with(
|
||||||
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n"
|
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n"
|
||||||
@@ -967,6 +1222,8 @@ mod tests {
|
|||||||
assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
|
assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
|
||||||
assert_eq!(header_name(" "), "file");
|
assert_eq!(header_name(" "), "file");
|
||||||
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf");
|
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf");
|
||||||
|
let library = String::from_utf8(multipart("XYZ", "files", "b.md", b"x")).unwrap();
|
||||||
|
assert!(library.contains("name=\"files\"; filename=\"b.md\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
//! The terminal panel's socket: a shell on a linked device, through the server's app door.
|
||||||
|
//!
|
||||||
|
//! The server has two WebSockets for an app (`/api/v2`, behind the app's token):
|
||||||
|
//!
|
||||||
|
//! - `/chats/{chat}/terminal/ws` -- an agent chat's shell, the one the web page's panel opens
|
||||||
|
//! too, so the two share it;
|
||||||
|
//! - `/devices/{device}/terminal/ws?directory=` -- before the chat exists, on the device and in
|
||||||
|
//! the folder a new agent chat has chosen. The chat's first message takes that shell over.
|
||||||
|
//!
|
||||||
|
//! Both take `cols` and `rows`. Down: the shell's output as binary frames, and text frames
|
||||||
|
//! `{"t": "ready" | "closed" | "behind" | "error", ...}`. Up: keystrokes as binary,
|
||||||
|
//! `{"t": "resize", "cols", "rows"}` as text. A refused token closes with 4401.
|
||||||
|
//!
|
||||||
|
//! **The token stays here.** The socket is opened in Rust with `Authorization: Bearer` in the
|
||||||
|
//! handshake, and the page only ever gets the frames (as `terminal` events) and sends keys,
|
||||||
|
//! sizes and "close" through commands -- the same rule as every other request the page makes.
|
||||||
|
//! A page cannot open the socket itself: its content security policy allows it no connection
|
||||||
|
//! but the bridge.
|
||||||
|
//!
|
||||||
|
//! One thread per terminal owns its socket: it reads with a short timeout and, between reads,
|
||||||
|
//! sends what the page queued on its channel. Blocking sockets, no async runtime of its own.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tungstenite::http::Uri;
|
||||||
|
use tungstenite::stream::MaybeTlsStream;
|
||||||
|
use tungstenite::{ClientRequestBuilder, Error as WsError, HandshakeError, Message, WebSocket};
|
||||||
|
|
||||||
|
use crate::server::{base64, ApiError};
|
||||||
|
|
||||||
|
/// The server's close code for a token it refused (`api/v2/terminal.py`).
|
||||||
|
pub const CLOSE_UNAUTHORIZED: u16 = 4401;
|
||||||
|
/// The most one keystroke message may be: the server's own limit (a paste, at most).
|
||||||
|
pub const MAX_INPUT: usize = 256 * 1024;
|
||||||
|
/// The longest directory the server takes (`api/terminal.py` `MAX_DIR_CHARS`).
|
||||||
|
const MAX_DIR: usize = 4096;
|
||||||
|
/// How long a read waits before the thread looks at what the page queued.
|
||||||
|
const POLL: Duration = Duration::from_millis(40);
|
||||||
|
/// How long a close waits for the server's answer before the socket is simply dropped.
|
||||||
|
const CLOSE_WAIT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
type Socket = WebSocket<MaybeTlsStream<TcpStream>>;
|
||||||
|
|
||||||
|
/// Where the shell is: an agent chat's, or a device's folder before the chat exists.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
Chat(String),
|
||||||
|
Device { device: String, directory: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id as the server makes them (a chat's, a device token's): letters, digits, `-` and `_`.
|
||||||
|
fn id_ok(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A query value, percent-encoded: every byte but the unreserved ones (RFC 3986 §2.3).
|
||||||
|
pub fn encode(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
for b in text.bytes() {
|
||||||
|
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
out.push(b as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{b:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The socket's address on the app door (`https://host/api/v2` -> `wss://host/api/v2/...`).
|
||||||
|
pub fn url(app: &str, target: &Target, cols: u16, rows: u16) -> Result<String, ApiError> {
|
||||||
|
let door = if let Some(rest) = app.strip_prefix("https://") {
|
||||||
|
format!("wss://{rest}")
|
||||||
|
} else if let Some(rest) = app.strip_prefix("http://") {
|
||||||
|
format!("ws://{rest}")
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
400,
|
||||||
|
"The server's address is not http or https.",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let cols = cols.clamp(2, 1000);
|
||||||
|
let rows = rows.clamp(1, 500);
|
||||||
|
match target {
|
||||||
|
Target::Chat(chat) => {
|
||||||
|
if !id_ok(chat) {
|
||||||
|
return Err(ApiError::new(400, "Not a chat."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/chats/{chat}/terminal/ws?cols={cols}&rows={rows}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Target::Device { device, directory } => {
|
||||||
|
if !id_ok(device) {
|
||||||
|
return Err(ApiError::new(400, "Not a device."));
|
||||||
|
}
|
||||||
|
let directory = directory.trim();
|
||||||
|
if directory.is_empty() || directory.len() > MAX_DIR || directory.contains('\0') {
|
||||||
|
return Err(ApiError::new(400, "Not a folder."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/devices/{device}/terminal/ws?directory={}&cols={cols}&rows={rows}",
|
||||||
|
encode(directory)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the page asked the socket's thread to do.
|
||||||
|
pub enum Up {
|
||||||
|
Keys(Vec<u8>),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame for the page, as the `terminal` event: `output` (the bytes, base64), `control`
|
||||||
|
/// (the server's JSON), or `end` (how it ended: `{code, reason, status, error}`).
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Down {
|
||||||
|
pub id: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a terminal's socket ended. `code` is the close frame's (4401: the token was refused),
|
||||||
|
/// `status` an HTTP status the handshake was answered with, `error` why it could not connect or
|
||||||
|
/// broke off.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||||
|
pub struct End {
|
||||||
|
pub code: Option<u16>,
|
||||||
|
pub reason: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl End {
|
||||||
|
fn failed(error: impl Into<String>) -> Self {
|
||||||
|
End {
|
||||||
|
error: Some(error.into()),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server refused the app's token: the page signs in again.
|
||||||
|
pub fn unauthorized(&self) -> bool {
|
||||||
|
self.code == Some(CLOSE_UNAUTHORIZED) || self.status == Some(401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open terminals: what each one's thread listens to.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Sessions {
|
||||||
|
next: AtomicU64,
|
||||||
|
open: Mutex<HashMap<u64, Sender<Up>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sessions {
|
||||||
|
/// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last;
|
||||||
|
/// `ended` is told how it ended just before. The id is the page's handle on it.
|
||||||
|
pub fn open(
|
||||||
|
&self,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
emit: impl Fn(Down) + Send + 'static,
|
||||||
|
ended: impl FnOnce(u64, &End) + Send + 'static,
|
||||||
|
) -> u64 {
|
||||||
|
let id = self.next.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.open.lock().unwrap().insert(id, tx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let send = |kind: &'static str, data: Value| emit(Down { id, kind, data });
|
||||||
|
let end = run(&url, &token, &rx, &send);
|
||||||
|
ended(id, &end);
|
||||||
|
send("end", serde_json::to_value(&end).unwrap_or(Value::Null));
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the page's keys, size or close to the terminal's thread. False: no such terminal.
|
||||||
|
pub fn send(&self, id: u64, up: Up) -> bool {
|
||||||
|
self.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|tx| tx.send(up).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close one terminal (the page's panel let go of it).
|
||||||
|
pub fn close(&self, id: u64) {
|
||||||
|
if let Some(tx) = self.open.lock().unwrap().remove(&id) {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal's thread has ended: forget it.
|
||||||
|
pub fn forget(&self, id: u64) {
|
||||||
|
self.open.lock().unwrap().remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every terminal closed (signed out, or another server chosen).
|
||||||
|
pub fn close_all(&self) {
|
||||||
|
for (_, tx) in self.open.lock().unwrap().drain() {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TCP socket under the WebSocket, to set its read timeout.
|
||||||
|
fn tcp(ws: &Socket) -> Option<&TcpStream> {
|
||||||
|
match ws.get_ref() {
|
||||||
|
MaybeTlsStream::Plain(s) => Some(s),
|
||||||
|
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect(url: &str, token: &str) -> Result<Socket, End> {
|
||||||
|
let uri: Uri = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| End::failed(format!("Not an address: {e}")))?;
|
||||||
|
let host = uri
|
||||||
|
.host()
|
||||||
|
.ok_or_else(|| End::failed("The address has no host."))?
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.to_string();
|
||||||
|
let port = uri
|
||||||
|
.port_u16()
|
||||||
|
.unwrap_or(if uri.scheme_str() == Some("wss") {
|
||||||
|
443
|
||||||
|
} else {
|
||||||
|
80
|
||||||
|
});
|
||||||
|
let addrs = (host.as_str(), port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| End::failed(format!("Could not reach the server: {e}")))?;
|
||||||
|
let mut last = String::from("no address");
|
||||||
|
let mut stream = None;
|
||||||
|
for addr in addrs {
|
||||||
|
match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) {
|
||||||
|
Ok(s) => {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => last = e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stream =
|
||||||
|
stream.ok_or_else(|| End::failed(format!("Could not reach the server: {last}")))?;
|
||||||
|
// The handshake may take its time; reads only poll once it is done.
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let request = ClientRequestBuilder::new(uri)
|
||||||
|
.with_header("Authorization", format!("Bearer {token}"))
|
||||||
|
.with_header(
|
||||||
|
"User-Agent",
|
||||||
|
format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION")),
|
||||||
|
);
|
||||||
|
match tungstenite::client_tls(request, stream) {
|
||||||
|
Ok((ws, _)) => Ok(ws),
|
||||||
|
Err(HandshakeError::Failure(WsError::Http(res))) => Err(End {
|
||||||
|
status: Some(res.status().as_u16()),
|
||||||
|
..End::default()
|
||||||
|
}),
|
||||||
|
Err(HandshakeError::Failure(e)) => Err(End::failed(format!("Could not connect: {e}"))),
|
||||||
|
Err(HandshakeError::Interrupted(_)) => Err(End::failed("The server did not answer.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read that only timed out: nothing came in this poll.
|
||||||
|
fn idle(e: &WsError) -> bool {
|
||||||
|
matches!(e, WsError::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(url: &str, token: &str, rx: &Receiver<Up>, send: &dyn Fn(&'static str, Value)) -> End {
|
||||||
|
let mut ws = match connect(url, token) {
|
||||||
|
Ok(ws) => ws,
|
||||||
|
Err(end) => return end,
|
||||||
|
};
|
||||||
|
match tcp(&ws) {
|
||||||
|
Some(s) => {
|
||||||
|
let _ = s.set_read_timeout(Some(POLL));
|
||||||
|
}
|
||||||
|
None => return End::failed("An unexpected kind of connection."),
|
||||||
|
}
|
||||||
|
let mut closed_by_server: Option<(u16, String)> = None;
|
||||||
|
let mut closing: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
// What the page queued, until the server has said goodbye.
|
||||||
|
if closing.is_none() && closed_by_server.is_none() {
|
||||||
|
loop {
|
||||||
|
let sent = match rx.try_recv() {
|
||||||
|
Ok(Up::Keys(bytes)) if bytes.len() <= MAX_INPUT => {
|
||||||
|
ws.send(Message::binary(bytes))
|
||||||
|
}
|
||||||
|
Ok(Up::Keys(_)) => Ok(()),
|
||||||
|
Ok(Up::Resize(cols, rows)) => ws.send(Message::text(
|
||||||
|
json!({ "t": "resize", "cols": cols, "rows": rows }).to_string(),
|
||||||
|
)),
|
||||||
|
Ok(Up::Close) | Err(TryRecvError::Disconnected) => {
|
||||||
|
closing = Some(Instant::now());
|
||||||
|
let _ = ws.close(None);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
};
|
||||||
|
if let Err(e) = sent {
|
||||||
|
if !idle(&e) {
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match ws.read() {
|
||||||
|
Ok(Message::Binary(bytes)) => send("output", Value::String(base64(&bytes))),
|
||||||
|
Ok(Message::Text(text)) => {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(text.as_str()) {
|
||||||
|
send("control", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(frame)) => {
|
||||||
|
let (code, reason) = frame
|
||||||
|
.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))
|
||||||
|
.unwrap_or((1005, String::new()));
|
||||||
|
closed_by_server = Some((code, reason));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if idle(&e) => {
|
||||||
|
if closing.is_some_and(|at| at.elapsed() > CLOSE_WAIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => break,
|
||||||
|
Err(e) => {
|
||||||
|
if closing.is_some() || closed_by_server.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match closed_by_server {
|
||||||
|
Some((code, reason)) => End {
|
||||||
|
code: Some(code),
|
||||||
|
reason,
|
||||||
|
..End::default()
|
||||||
|
},
|
||||||
|
None => End::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chats_terminal_is_on_the_app_door_with_its_size() {
|
||||||
|
let t = Target::Chat("chat_1-a".into());
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 80, 24).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/chats/chat_1-a/terminal/ws?cols=80&rows=24"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url("http://ai.example:8080/sub/api/v2", &t, 0, 9999).unwrap(),
|
||||||
|
"ws://ai.example:8080/sub/api/v2/chats/chat_1-a/terminal/ws?cols=2&rows=500"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_before_the_chat_carries_its_folder_encoded() {
|
||||||
|
let t = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: " /home/dev/my project&x=1 ".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 100, 30).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/devices/token1/terminal/ws?directory=%2Fhome%2Fdev%2Fmy%20project%26x%3D1&cols=100&rows=30"
|
||||||
|
);
|
||||||
|
assert_eq!(encode("C:\\Users\\Ádám"), "C%3A%5CUsers%5C%C3%81d%C3%A1m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_an_id_and_a_folder_reaches_the_address() {
|
||||||
|
let door = "https://ai.example/api/v2";
|
||||||
|
for bad in ["", "../me", "a/b", "a?b", "a b", "x#y"] {
|
||||||
|
assert!(
|
||||||
|
url(door, &Target::Chat(bad.into()), 80, 24).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: bad.into(),
|
||||||
|
directory: "/tmp".into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
for dir in ["", " ", "a\0b"] {
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: dir.into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{dir:?}");
|
||||||
|
}
|
||||||
|
assert!(url("ftp://ai.example", &Target::Chat("c".into()), 80, 24).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_token_is_told_apart_from_a_dropped_line() {
|
||||||
|
let refused = End {
|
||||||
|
code: Some(CLOSE_UNAUTHORIZED),
|
||||||
|
..End::default()
|
||||||
|
};
|
||||||
|
assert!(refused.unauthorized());
|
||||||
|
assert!(End {
|
||||||
|
status: Some(401),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
assert!(!End::failed("reset").unauthorized());
|
||||||
|
assert!(!End {
|
||||||
|
code: Some(1000),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_for_a_closed_terminal_go_nowhere() {
|
||||||
|
let s = Sessions::default();
|
||||||
|
assert!(!s.send(7, Up::Keys(b"ls\n".to_vec())));
|
||||||
|
s.close(7);
|
||||||
|
s.close_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-4
@@ -15,8 +15,15 @@
|
|||||||
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
||||||
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
||||||
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
||||||
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
|
//! whether to pause it. A server that made the run's notification (`run.notification`, server
|
||||||
//! the stream answers 404, and nothing is said.
|
//! 2.0.0-beta.9) gives its words: the task's name and the result's first lines -- shown here as on
|
||||||
|
//! an unlocked phone, the desktop being the person's own -- and its actions with their labels in
|
||||||
|
//! the person's language (`open`, `run_again`, and `pause` on a failure that asks). An older
|
||||||
|
//! server's run is said by the schedule's title and the page's words, without actions. A server
|
||||||
|
//! without the stream answers 404, and nothing is said.
|
||||||
|
//!
|
||||||
|
//! Every notification shown is remembered by its id (`Notes`), so a click on it or on one of its
|
||||||
|
//! actions knows which chat or which run it was about.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -90,6 +97,8 @@ pub struct Watch {
|
|||||||
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
||||||
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
||||||
stop: Mutex<Arc<AtomicBool>>,
|
stop: Mutex<Arc<AtomicBool>>,
|
||||||
|
/// The notifications shown, so a click on one knows what it was about.
|
||||||
|
pub notes: Notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a scheduled task's run is told.
|
/// How a scheduled task's run is told.
|
||||||
@@ -108,6 +117,95 @@ pub enum Told {
|
|||||||
pub struct Ran {
|
pub struct Ran {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub told: Told,
|
pub told: Told,
|
||||||
|
pub schedule: String,
|
||||||
|
pub run: String,
|
||||||
|
/// The server's own words for it (`run.notification.body`): the result's first lines, or the
|
||||||
|
/// failure said. None from a server that does not make them.
|
||||||
|
pub body: Option<String>,
|
||||||
|
/// The actions it offers, `(id, label)`, in the server's order: only those the app can do.
|
||||||
|
pub actions: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actions of a scheduled run's notification the app knows how to do.
|
||||||
|
pub const RUN_ACTIONS: [&str; 3] = ["open", "run_again", "pause"];
|
||||||
|
|
||||||
|
/// The most of a result's first lines a notification carries: the server cuts them already.
|
||||||
|
const BODY_MAX: usize = 400;
|
||||||
|
|
||||||
|
/// The words and actions of a run's own notification (`run.notification`), as far as they are
|
||||||
|
/// usable: a body without control characters and not too long; actions the app knows, each with
|
||||||
|
/// its label (the id when the server gave none).
|
||||||
|
fn run_notification(run: &Value) -> (Option<String>, Vec<(String, String)>) {
|
||||||
|
let Some(n) = run.get("notification").filter(|n| n.is_object()) else {
|
||||||
|
return (None, Vec::new());
|
||||||
|
};
|
||||||
|
let body = n
|
||||||
|
.get("body")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|b| {
|
||||||
|
b.chars()
|
||||||
|
.map(|c| if c.is_control() && c != '\n' { ' ' } else { c })
|
||||||
|
.take(BODY_MAX)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.filter(|b| !b.is_empty());
|
||||||
|
let labels = n.get("labels");
|
||||||
|
let actions = n
|
||||||
|
.get("actions")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|list| {
|
||||||
|
list.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.filter(|a| RUN_ACTIONS.contains(a))
|
||||||
|
.map(|a| {
|
||||||
|
let label = labels
|
||||||
|
.and_then(|l| l.get(a))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or(a);
|
||||||
|
(a.to_string(), label.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(body, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a notification shown was about.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Note {
|
||||||
|
/// A chat whose reply or work finished.
|
||||||
|
Chat(String),
|
||||||
|
/// A scheduled task's run.
|
||||||
|
Run { schedule: String, run: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notifications shown, by id: the most recent `NOTES_KEPT`.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Notes {
|
||||||
|
next: Mutex<i32>,
|
||||||
|
by_id: Mutex<HashMap<i32, Note>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTES_KEPT: i32 = 200;
|
||||||
|
|
||||||
|
impl Notes {
|
||||||
|
/// Remember a notification about to be shown; its id.
|
||||||
|
pub fn add(&self, note: Note) -> i32 {
|
||||||
|
let mut next = self.next.lock().unwrap();
|
||||||
|
*next = next.checked_add(1).unwrap_or(1);
|
||||||
|
let id = *next;
|
||||||
|
let mut by_id = self.by_id.lock().unwrap();
|
||||||
|
by_id.insert(id, note);
|
||||||
|
by_id.retain(|k, _| *k > id - NOTES_KEPT);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: i32) -> Option<Note> {
|
||||||
|
self.by_id.lock().unwrap().get(&id).cloned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
||||||
@@ -212,6 +310,7 @@ impl Watch {
|
|||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
);
|
);
|
||||||
if let Some(told) = told {
|
if let Some(told) = told {
|
||||||
|
let (body, actions) = run_notification(run);
|
||||||
out.push(Ran {
|
out.push(Ran {
|
||||||
title: row
|
title: row
|
||||||
.get("title")
|
.get("title")
|
||||||
@@ -219,6 +318,10 @@ impl Watch {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
told,
|
told,
|
||||||
|
schedule: id.to_string(),
|
||||||
|
run: run_id.to_string(),
|
||||||
|
body,
|
||||||
|
actions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,13 +362,75 @@ mod tests {
|
|||||||
})).collect::<Vec<_>>()})
|
})).collect::<Vec<_>>()})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ran(id: &str, told: Told) -> Ran {
|
fn ran(id: &str, run: &str, told: Told) -> Ran {
|
||||||
Ran {
|
Ran {
|
||||||
title: format!("Task {id}"),
|
title: format!("Task {id}"),
|
||||||
told,
|
told,
|
||||||
|
schedule: id.into(),
|
||||||
|
run: run.into(),
|
||||||
|
body: None,
|
||||||
|
actions: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_runs_own_notification_gives_its_first_lines_and_the_actions_the_app_can_do() {
|
||||||
|
let w = Watch::default();
|
||||||
|
let row = |run: Value| {
|
||||||
|
json!({"version": "v", "schedules": [{
|
||||||
|
"id": "s1", "title": "Waybread check", "notify": "matters", "failure_streak": 3,
|
||||||
|
"last_run": run,
|
||||||
|
}]})
|
||||||
|
};
|
||||||
|
w.look_schedules(&row(json!({"id": "r0", "status": "running"})));
|
||||||
|
let out = w.look_schedules(&row(json!({"id": "r0", "status": "failed", "notified": true,
|
||||||
|
"notification": {"title": "Waybread check", "body": "Failed 3 times\u{7} in a row. Pause it?",
|
||||||
|
"actions": ["open", "run_again", "pause", "unknown"],
|
||||||
|
"labels": {"open": "Otvoriť", "run_again": "Spustiť znova", "pause": ""}}})));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].told, Told::Pause);
|
||||||
|
assert_eq!(
|
||||||
|
(out[0].schedule.as_str(), out[0].run.as_str()),
|
||||||
|
("s1", "r0")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].body.as_deref(),
|
||||||
|
Some("Failed 3 times in a row. Pause it?")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].actions,
|
||||||
|
vec![
|
||||||
|
("open".to_string(), "Otvoriť".to_string()),
|
||||||
|
("run_again".to_string(), "Spustiť znova".to_string()),
|
||||||
|
("pause".to_string(), "pause".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_notification_is_found_again_by_its_id() {
|
||||||
|
let notes = Notes::default();
|
||||||
|
let a = notes.add(Note::Chat("c1".into()));
|
||||||
|
let b = notes.add(Note::Run {
|
||||||
|
schedule: "s1".into(),
|
||||||
|
run: "r1".into(),
|
||||||
|
});
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_eq!(notes.get(a), Some(Note::Chat("c1".into())));
|
||||||
|
assert_eq!(
|
||||||
|
notes.get(b),
|
||||||
|
Some(Note::Run {
|
||||||
|
schedule: "s1".into(),
|
||||||
|
run: "r1".into()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(notes.get(b + 1), None);
|
||||||
|
for _ in 0..NOTES_KEPT {
|
||||||
|
notes.add(Note::Chat("x".into()));
|
||||||
|
}
|
||||||
|
assert_eq!(notes.get(a), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
||||||
let w = Watch::default();
|
let w = Watch::default();
|
||||||
@@ -290,7 +455,10 @@ mod tests {
|
|||||||
("c", "matters", 0, Some(("r4", "silent", false))),
|
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||||
("d", "never", 0, Some(("r5", "ok", false))),
|
("d", "never", 0, Some(("r5", "ok", false))),
|
||||||
]));
|
]));
|
||||||
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
vec![ran("a", "r2", Told::Result), ran("b", "r3", Told::Result)]
|
||||||
|
);
|
||||||
// The same list again (the server told a device meanwhile): not said twice.
|
// The same list again (the server told a device meanwhile): not said twice.
|
||||||
assert!(w
|
assert!(w
|
||||||
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [],
|
"windows": [],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
"csp": "default-src 'self'; img-src 'self' data:; media-src data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
|
||||||
"dangerousDisableAssetCspModification": [
|
"dangerousDisableAssetCspModification": [
|
||||||
"style-src"
|
"style-src"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.6",
|
"tag": "v2.0.0-beta.9",
|
||||||
"commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
|
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
|
||||||
"sha256": "e5f76f542ed5512702e06d69fa1dfa098c511f67dacbd7cc93f4a0618c6ae966"
|
"sha256": "46d010bb862b06345c05f726cd1e6ae8415866324bfb0a55b8642ce986a9e769"
|
||||||
}
|
}
|
||||||
+323
-35
@@ -122,6 +122,7 @@ textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height:
|
|||||||
|
|
||||||
label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); color: var(--ink-muted); }
|
label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); color: var(--ink-muted); }
|
||||||
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: calc(var(--text-sm) * var(--leading-normal)); }
|
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: calc(var(--text-sm) * var(--leading-normal)); }
|
||||||
|
.hint.is-warn { color: var(--warning); }
|
||||||
.status { font: var(--text-sm) / var(--leading-normal) var(--font-body); }
|
.status { font: var(--text-sm) / var(--leading-normal) var(--font-body); }
|
||||||
.status.is-ok { color: var(--leaf); }
|
.status.is-ok { color: var(--leaf); }
|
||||||
.check { display: grid; grid-template-columns: var(--check-size) minmax(0, 1fr); gap: var(--sp-2); align-items: center; color: var(--ink); }
|
.check { display: grid; grid-template-columns: var(--check-size) minmax(0, 1fr); gap: var(--sp-2); align-items: center; color: var(--ink); }
|
||||||
@@ -326,6 +327,8 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
.shell[data-side="false"] { --side-col: 0px; }
|
.shell[data-side="false"] { --side-col: 0px; }
|
||||||
.shell[data-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); }
|
.shell[data-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); }
|
||||||
.shell[data-activity="true"] { --panel-col: var(--inspector-width); }
|
.shell[data-activity="true"] { --panel-col: var(--inspector-width); }
|
||||||
|
/* The terminal: the server's width for it, giving way down to its least before the chat does. */
|
||||||
|
.shell[data-terminal="true"] { --panel-col: minmax(var(--terminal-width-min), var(--terminal-width)); }
|
||||||
|
|
||||||
/* --- The sidebar --------------------------------------------------------------------------- */
|
/* --- The sidebar --------------------------------------------------------------------------- */
|
||||||
|
|
||||||
@@ -428,6 +431,34 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
.chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); }
|
.chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); }
|
||||||
.chatrow__state.is-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; }
|
.chatrow__state.is-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; }
|
||||||
.chatrow__state.is-new { background: var(--leaf); }
|
.chatrow__state.is-new { background: var(--leaf); }
|
||||||
|
/* A chat folder's head: chevron · folder · name · count, and its own ⋯ beside it on one track. */
|
||||||
|
.list__folder { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
|
||||||
|
.folderrow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px 16px minmax(0, 1fr) auto;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
padding: 0 var(--sp-2) 0 calc(var(--sp-2) + var(--depth, 0) * var(--sp-4));
|
||||||
|
text-align: left;
|
||||||
|
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.folderrow:hover { color: var(--ink); background: var(--surface-hover); }
|
||||||
|
.folderrow__chev, .folderrow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||||
|
.folderrow__chev .icon { width: 12px; height: 12px; }
|
||||||
|
.folderrow__icon .icon { width: 14px; height: 14px; }
|
||||||
|
.folderrow__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.folderrow__count { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
|
.folderrow__more { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); color: var(--ink-faint); background: transparent; border: 0; border-radius: var(--radius-lg); cursor: pointer; opacity: 0; }
|
||||||
|
.list__folder:hover .folderrow__more, .folderrow__more:focus-visible { opacity: 1; }
|
||||||
|
.folderrow__more:hover { color: var(--ink); background: var(--surface-hover); }
|
||||||
|
@media (hover: none) { .folderrow__more { opacity: 1; } }
|
||||||
|
.chatrow.is-filed { padding-left: calc(var(--sp-2) + var(--depth, 1) * var(--sp-4)); }
|
||||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } }
|
||||||
|
|
||||||
@@ -708,26 +739,75 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
}
|
}
|
||||||
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); }
|
.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); }
|
||||||
/* The team's size: two number fields of one width with a dash between them, and in the header
|
/* The subagents, Min and Max: one range with two handles. Three rows of known height -- the
|
||||||
its word before them -- fixed tracks, so nothing moves when a number grows a digit. */
|
values said on one line, the track box (one control high), the two ends -- and in the box two
|
||||||
.agents {
|
range inputs laid over one track, the span between the handles filled. The inputs let only
|
||||||
display: inline-grid;
|
their handles take the pointer, so either can be grabbed wherever the other is. */
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
.range2 { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-1); width: 100%; }
|
||||||
gap: var(--sp-2);
|
.range2__said { font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||||
align-items: center;
|
.range2__box { position: relative; display: block; height: var(--control-h); --lo: 0%; --hi: 100%; }
|
||||||
|
.range2__track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(var(--check-size) / 2);
|
||||||
|
right: calc(var(--check-size) / 2);
|
||||||
|
height: var(--sp-1);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.range2__fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--lo);
|
||||||
|
right: calc(100% - var(--hi));
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.range2__input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--control-h);
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: none;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
.agents__dash { color: var(--ink-muted); }
|
.range2__input.is-top { z-index: 1; }
|
||||||
.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); }
|
.range2__input::-webkit-slider-runnable-track { height: 100%; background: none; border: 0; }
|
||||||
.agents--compact {
|
.range2__input::-moz-range-track { height: 100%; background: none; border: 0; }
|
||||||
grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2);
|
.range2__input::-webkit-slider-thumb {
|
||||||
width: auto;
|
-webkit-appearance: none;
|
||||||
height: var(--control-h-sm);
|
appearance: none;
|
||||||
font: var(--text-sm) var(--font-ui);
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
margin-top: calc((var(--control-h) - var(--check-size)) / 2);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: var(--border-w-thick) solid var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.agents--compact .agents__input { height: var(--control-h-sm); }
|
.range2__input::-moz-range-thumb {
|
||||||
.agents__label { color: var(--ink-muted); white-space: nowrap; }
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: var(--border-w-thick) solid var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.range2__input:focus-visible { outline: none; }
|
||||||
|
.range2__input:focus-visible::-webkit-slider-thumb { box-shadow: var(--ring); }
|
||||||
|
.range2__input:focus-visible::-moz-range-thumb { box-shadow: var(--ring); }
|
||||||
|
/* The two ends, each one handle wide, so each number sits centred under its handle at that end. */
|
||||||
|
.range2__ends { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--ink-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.range2__ends > span { width: var(--check-size); text-align: center; white-space: nowrap; }
|
||||||
/* Markdown, drawn by markdown.js from model output (never as HTML). */
|
/* Markdown, drawn by markdown.js from model output (never as HTML). */
|
||||||
.md { overflow-wrap: anywhere; min-width: 0; }
|
.md { overflow-wrap: anywhere; min-width: 0; }
|
||||||
.md > * + * { margin-top: var(--sp-3); }
|
.md > * + * { margin-top: var(--sp-3); }
|
||||||
@@ -920,6 +1000,14 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.ctx__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-muted); }
|
.ctx__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-muted); }
|
||||||
.ctx__num { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
.ctx__num { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
||||||
.ctx__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
|
.ctx__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
|
||||||
|
/* "41k until auto-compact" and the Compact button: the words give, the button keeps its size. */
|
||||||
|
.ctx__compact { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-1) var(--sp-2); align-items: center; padding-top: var(--sp-2); border-top: var(--border-w) solid var(--border); }
|
||||||
|
.ctx__until { color: var(--ink-muted); }
|
||||||
|
.ctx__error { grid-column: 1 / -1; margin: 0; font-size: var(--text-xs); color: var(--warning); }
|
||||||
|
/* Environment: an agent chat's folder and where it is. */
|
||||||
|
.env { display: grid; grid-template-columns: var(--sp-4) minmax(0, auto) auto; justify-content: start; gap: var(--sp-2); align-items: center; margin: 0; }
|
||||||
|
.env__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
|
||||||
|
.env__where { font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
|
||||||
.ctx--messages { --ctx: var(--accent); }
|
.ctx--messages { --ctx: var(--accent); }
|
||||||
.ctx--system { --ctx: var(--ink-muted); }
|
.ctx--system { --ctx: var(--ink-muted); }
|
||||||
.ctx--memory { --ctx: var(--warning); }
|
.ctx--memory { --ctx: var(--warning); }
|
||||||
@@ -943,15 +1031,41 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
track that gives, cut), elapsed time in a column of its own width, the stop button at its size
|
track that gives, cut), elapsed time in a column of its own width, the stop button at its size
|
||||||
(an empty box where there is none) -- so rows line up whatever each says. */
|
(an empty box where there is none) -- so rows line up whatever each says. */
|
||||||
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
|
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
|
||||||
.tasks__summary, .tasks__team { margin: 0; color: var(--ink-muted); }
|
.tasks__team { margin: 0; color: var(--ink-muted); }
|
||||||
.tasks__goal { margin: 0; color: var(--ink); }
|
.tasks__goal { margin: 0; color: var(--ink); }
|
||||||
.tasks__list { display: grid; gap: var(--sp-1); }
|
.tasks__group { display: grid; grid-template-columns: auto auto; justify-content: start; gap: var(--sp-2); align-items: baseline; margin: var(--sp-1) 0 0; color: var(--ink-muted); }
|
||||||
.tasks__more > summary { cursor: pointer; padding: var(--sp-1) 0; color: var(--ink-muted); }
|
.tasks__list { display: grid; gap: var(--sp-2); }
|
||||||
.tasks__more[open] > summary { margin-bottom: var(--sp-1); }
|
/* A fold ("Show 3 more", "Finished 12", a task's own "2 tasks"): a chevron that turns, then the
|
||||||
|
words; nested ones step in by the depth of what is under them. */
|
||||||
|
.tasks__more { margin-left: calc(var(--depth, 0) * var(--sp-4)); min-width: 0; }
|
||||||
|
.tasks__more > summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--row-h);
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.tasks__more > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tasks__more > summary:hover { color: var(--ink); }
|
||||||
|
.tasks__more[open] > summary { margin-bottom: var(--sp-2); }
|
||||||
|
.tasks__nested { margin-top: calc(-1 * var(--sp-1)); }
|
||||||
|
.tasks__finished { padding-top: var(--sp-1); border-top: var(--border-w) solid var(--border); }
|
||||||
|
.tasks__chev, .agent__chev { display: grid; place-items: center; width: 12px; height: 12px; color: var(--ink-faint); transition: transform var(--dur-1) var(--ease-out); }
|
||||||
|
.tasks__chev .icon, .agent__chev .icon { width: 12px; height: 12px; }
|
||||||
|
.tasks__more[open] > summary .tasks__chev, .agent[open] > .agent__row .agent__chev { transform: rotate(90deg); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tasks__chev, .agent__chev { transition: none; } }
|
||||||
|
|
||||||
|
/* A task: one card, one row of known tracks -- the chevron, what it does (the one track that
|
||||||
|
gives, cut), the elapsed time in a column of its own width, the stop square at its size (an
|
||||||
|
empty box where it cannot be stopped) -- so the rows line up whatever each says. */
|
||||||
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); min-width: 0; }
|
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); min-width: 0; }
|
||||||
.agent__row {
|
.agent__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--dot-size) minmax(0, 1fr) var(--time-col) var(--control-h-sm);
|
grid-template-columns: 12px minmax(0, 1fr) var(--time-col) var(--control-h-sm);
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: var(--control-h);
|
min-height: var(--control-h);
|
||||||
@@ -960,17 +1074,21 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.agent__row::-webkit-details-marker { display: none; }
|
.agent__row::-webkit-details-marker { display: none; }
|
||||||
.agent__dot { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); background: var(--ink-faint); }
|
.agent__row:hover { background: var(--surface-hover); border-radius: var(--radius-lg); }
|
||||||
.agent.is-working .agent__dot { background: var(--accent); }
|
|
||||||
.agent.is-queued .agent__dot, .agent.is-waiting .agent__dot { background: var(--warning); }
|
|
||||||
.agent.is-done .agent__dot { background: var(--leaf); }
|
|
||||||
.agent.is-error .agent__dot { background: var(--danger); }
|
|
||||||
.agent__what { display: grid; min-width: 0; }
|
.agent__what { display: grid; min-width: 0; }
|
||||||
.agent__name, .agent__role { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.agent__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
|
||||||
.agent__name { color: var(--ink); }
|
.agent.is-done .agent__name, .agent.is-stopped .agent__name { color: var(--ink-muted); }
|
||||||
.agent__role { font-size: var(--text-xs); color: var(--ink-muted); }
|
.agent.is-error .agent__name { color: var(--danger); }
|
||||||
|
.agent__role { margin: 0; color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||||
.agent__time { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
.agent__time { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
||||||
.agent__stop { width: var(--control-h-sm); height: var(--control-h-sm); }
|
.agent.is-queued .agent__time, .agent.is-waiting .agent__time { color: var(--warning); }
|
||||||
|
/* The stop square: a small filled square in a button the size of the others. */
|
||||||
|
.agent__stop { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); padding: 0; background: var(--surface-active); border: 0; border-radius: var(--radius-md); cursor: pointer; }
|
||||||
|
.agent__stop:hover { background: var(--surface-hover); }
|
||||||
|
.agent__stop.is-none { background: none; cursor: default; }
|
||||||
|
.agent__square { width: var(--sp-3); height: var(--sp-3); background: var(--ink); border-radius: var(--radius-sm); }
|
||||||
|
.agent__stop:hover .agent__square { background: var(--danger); }
|
||||||
|
.agent__id { font: var(--text-xs) var(--font-mono); color: var(--ink-faint); overflow-wrap: anywhere; }
|
||||||
.agent__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); min-width: 0; }
|
.agent__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); min-width: 0; }
|
||||||
.agent__body > .btn { justify-self: start; }
|
.agent__body > .btn { justify-self: start; }
|
||||||
.agent__facts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--ink-muted); }
|
.agent__facts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
@@ -1050,6 +1168,18 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
.dialog::backdrop { background: var(--scrim); }
|
.dialog::backdrop { background: var(--scrim); }
|
||||||
|
.dialog--wide { width: min(var(--w-prose), calc(100vw - 2 * var(--sp-4))); }
|
||||||
|
|
||||||
|
/* Draw a picture (draw.js): the prompt, a shape and two buttons on one line, the pictures below. */
|
||||||
|
.draw .dialog__title { display: flex; gap: var(--sp-2); align-items: center; }
|
||||||
|
.draw__prompt { width: 100%; }
|
||||||
|
.draw__bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: var(--sp-2); align-items: center; }
|
||||||
|
.draw__size { justify-self: start; max-width: 100%; }
|
||||||
|
.draw__out { display: grid; gap: var(--sp-3); max-height: 60vh; overflow-y: auto; }
|
||||||
|
.draw__out:empty { display: none; }
|
||||||
|
.draw__pic { display: grid; gap: var(--sp-1); margin: 0; }
|
||||||
|
.draw__pic img { display: block; width: 100%; height: auto; border-radius: var(--radius-lg); background: var(--surface); }
|
||||||
|
.draw__pic figcaption { font-size: var(--text-xs); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||||
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
|
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
|
||||||
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
|
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
|
||||||
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
|
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
|
||||||
@@ -1087,6 +1217,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
||||||
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
|
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
|
||||||
|
/* A hit found inside: its title over the snippet, the words that matched marked. */
|
||||||
|
.palette__label--two { display: grid; padding: var(--sp-1) 0; white-space: normal; }
|
||||||
|
.palette__title, .palette__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.palette__snippet { font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
|
.palette__snippet mark { color: var(--ink); background: var(--surface-active); border-radius: var(--radius-sm); }
|
||||||
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
|
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||||
|
|
||||||
|
|
||||||
@@ -1153,11 +1288,16 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.composer__files { justify-content: start; }
|
.composer__files { justify-content: start; }
|
||||||
.composer__bar {
|
.composer__bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto;
|
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto auto;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto; }
|
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto auto; }
|
||||||
|
/* Dictate: a round tool beside Send; while it records it says so in the accent. */
|
||||||
|
.composer__mic { width: var(--round); height: var(--round); border-radius: var(--radius-full); }
|
||||||
|
.composer .composer__mic.is-recording { color: var(--accent-ink); background: var(--accent); }
|
||||||
|
.composer__mic.is-busy, .msg__speak.is-busy { opacity: 0.6; cursor: progress; }
|
||||||
|
.msg__speak[aria-pressed="true"] { color: var(--accent); }
|
||||||
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
|
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
|
||||||
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
|
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
|
||||||
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
|
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
|
||||||
@@ -1203,6 +1343,7 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
}
|
}
|
||||||
.cchip:hover { color: var(--ink); background: var(--surface-hover); border-color: var(--border); }
|
.cchip:hover { color: var(--ink); background: var(--surface-hover); border-color: var(--border); }
|
||||||
.cchip:disabled { cursor: default; }
|
.cchip:disabled { cursor: default; }
|
||||||
|
.cchip[aria-pressed="true"], .tool[aria-pressed="true"] { color: var(--ink); background: var(--surface-active); }
|
||||||
.cchip:disabled:hover { background: transparent; border-color: transparent; }
|
.cchip:disabled:hover { background: transparent; border-color: transparent; }
|
||||||
.cchip__label { overflow: hidden; text-overflow: ellipsis; }
|
.cchip__label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.cchip__chev { display: grid; color: var(--ink-faint); }
|
.cchip__chev { display: grid; color: var(--ink-faint); }
|
||||||
@@ -1277,6 +1418,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
|
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
|
||||||
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
|
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
|
||||||
.files__where { min-width: 0; }
|
.files__where { min-width: 0; }
|
||||||
|
/* The server's pane: Upload and New folder before Read again. */
|
||||||
|
#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); }
|
||||||
|
#server-status { white-space: pre-line; }
|
||||||
.files__where select { width: 100%; height: var(--control-h-sm); }
|
.files__where select { width: 100%; height: var(--control-h-sm); }
|
||||||
.files__tree { min-height: 0; }
|
.files__tree { min-height: 0; }
|
||||||
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
|
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
|
||||||
@@ -1310,6 +1454,12 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
|
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
|
||||||
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
|
/* A row with a more button beside it: the button on the row's own line, its folder below both. */
|
||||||
|
.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
|
||||||
|
.tree__item > ul { grid-column: 1 / -1; }
|
||||||
|
.tree__more { opacity: 0; }
|
||||||
|
.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; }
|
||||||
|
@media (hover: none) { .tree__more { opacity: 1; } }
|
||||||
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
|
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
|
|
||||||
/* An empty page: an icon, a heading, a sentence, centred. */
|
/* An empty page: an icon, a heading, a sentence, centred. */
|
||||||
@@ -1400,6 +1550,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.sched + .sched__history { margin-top: var(--sp-6); }
|
.sched + .sched__history { margin-top: var(--sp-6); }
|
||||||
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
|
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
|
||||||
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
|
/* Where it runs: the computer over its folder; a computer that cannot take the run says so in colour. */
|
||||||
|
.fact__path { display: block; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||||
|
.fact.is-warning .fact__hint { color: var(--warning); }
|
||||||
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
|
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
|
||||||
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
|
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
|
||||||
.sched__desc.is-error { color: var(--warning); }
|
.sched__desc.is-error { color: var(--warning); }
|
||||||
@@ -1470,12 +1623,147 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
|
.folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
|
||||||
.folders .empty { display: block; padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); text-align: left; }
|
.folders .empty { display: block; padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); text-align: left; }
|
||||||
|
|
||||||
|
/* --- The terminal panel -------------------------------------------------------------------------
|
||||||
|
The panel column, as the activity panel has it, and never beside it (opening one closes the
|
||||||
|
other; the shell lives on). The viewer goes over the chat while it is open, as with the
|
||||||
|
activity panel. Not `.terminal`: xterm gives its own element that class. The head is the
|
||||||
|
viewer's: an icon, the device's name over the folder, then the buttons on known tracks (the
|
||||||
|
command ones only while the shell marks its commands). */
|
||||||
|
|
||||||
|
.shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||||
|
.termpanel {
|
||||||
|
grid-area: panel;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
border-left: var(--border-w) solid var(--border);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
}
|
||||||
|
.termpanel__head {
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--control-h-sm);
|
||||||
|
}
|
||||||
|
.termpanel__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
||||||
|
.termpanel__where { font-family: var(--font-mono); }
|
||||||
|
/* xterm measures this box: it has a size of its own, and the terminal scrolls inside it. */
|
||||||
|
.termpanel__screen { min-height: 0; min-width: 0; overflow: hidden; padding: var(--sp-2); background: var(--code-bg); }
|
||||||
|
.termpanel__screen .xterm { height: 100%; }
|
||||||
|
/* What happened, the last command, and Send: the words take what is left and wrap; the last
|
||||||
|
command is one clipped line; the button keeps its size. */
|
||||||
|
.termpanel__foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-top: var(--border-w) solid var(--border);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.termpanel__message { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.termpanel__message.is-error { color: var(--danger); }
|
||||||
|
.termpanel__last {
|
||||||
|
max-width: var(--w-chip);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.termpanel__last:empty { display: none; }
|
||||||
|
|
||||||
|
/* --- The subagent tree (spec 3.4.0) ------------------------------------------------------------
|
||||||
|
Rows are the tasks' rows (.agent): name and id on one line, the position, then the phase (or how
|
||||||
|
it ended) clipped to one line -- its clock is in the opened row. Ended ones are dimmed. */
|
||||||
|
|
||||||
|
.subagents { display: grid; gap: var(--sp-2); min-width: 0; }
|
||||||
|
.subagents .tasks__team, .subagents .tasks__goal { margin: 0; }
|
||||||
|
.subagent__who { display: flex; gap: var(--sp-2); align-items: baseline; min-width: 0; }
|
||||||
|
.subagent__who .agent__id { flex: none; }
|
||||||
|
.subagent__phase { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
|
.agent.is-ended { opacity: var(--opacity-dim); }
|
||||||
|
.subagent__buttons { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; }
|
||||||
|
.findings { margin: 0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + var(--sp-5)); display: grid; gap: var(--sp-3); }
|
||||||
|
.findings__item { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.ask__subagent { color: var(--ink); }
|
||||||
|
|
||||||
|
/* --- Archived chats: icon · title over its folder and date · Unarchive · more, on known tracks. */
|
||||||
|
|
||||||
|
.arch { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||||
|
.arow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr) auto var(--control-h-sm);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
border-bottom: var(--border-w) solid var(--border);
|
||||||
|
}
|
||||||
|
.arow__icon { display: grid; place-items: center; color: var(--ink-faint); }
|
||||||
|
.arow__icon.is-agent { color: var(--leaf); }
|
||||||
|
.arow__icon .icon { width: 14px; height: 14px; }
|
||||||
|
.arow__open {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.arow__open:hover .arow__title { text-decoration: underline; }
|
||||||
|
.arow__title, .arow__sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.arow__title { font: var(--text-sm) / var(--leading-tight) var(--font-body); }
|
||||||
|
.arow__sub { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
|
.arch__more { margin-top: var(--sp-3); }
|
||||||
|
.arch__page { display: grid; gap: var(--sp-3); }
|
||||||
|
.head__unarchive { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* --- A chat's tools and skills (scope.js): switches in a popover, the allowed commands below. */
|
||||||
|
|
||||||
|
.scope { max-height: min(70vh, 32rem); overflow-y: auto; }
|
||||||
|
.scope .pop__actions { justify-content: flex-start; }
|
||||||
|
.scope .pop__actions .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h-sm); }
|
||||||
|
.scope__list { display: grid; gap: var(--sp-2); }
|
||||||
|
.scope__row span { overflow-wrap: anywhere; }
|
||||||
|
.scope__hint { margin: 0; font-size: var(--text-xs); }
|
||||||
|
.scope__allow { display: grid; gap: var(--sp-1); margin: 0; padding: 0; list-style: none; }
|
||||||
|
.scope__allow code { font: var(--text-xs) var(--font-mono); overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
/* --- Memory: a new one on top, then each record over its group and date · Edit · more. */
|
||||||
|
|
||||||
|
.mnew { display: grid; gap: var(--sp-2); }
|
||||||
|
.mnew textarea { width: 100%; }
|
||||||
|
.mnew__foot { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; }
|
||||||
|
.mnew__foot select { justify-self: start; max-width: 100%; }
|
||||||
|
.mrow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
border-bottom: var(--border-w) solid var(--border);
|
||||||
|
}
|
||||||
|
.mrow.is-editing { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.mrow.is-editing textarea { width: 100%; }
|
||||||
|
.mrow.is-marked { background: var(--surface-active); border-radius: var(--radius-lg); padding-inline: var(--sp-2); }
|
||||||
|
.mrow__words { display: grid; gap: var(--sp-1); min-width: 0; }
|
||||||
|
.mrow__text { margin: 0; font: var(--text-sm) / var(--leading-normal) var(--font-body); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.mrow__sub { margin: 0; font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||||
|
|
||||||
/* --- Narrower windows ---------------------------------------------------------------------- */
|
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
||||||
@media (max-width: 64rem) {
|
@media (max-width: 64rem) {
|
||||||
.shell[data-viewer="true"], .shell[data-activity="true"] { --panel-col: 0px; }
|
.shell[data-viewer="true"], .shell[data-activity="true"], .shell[data-terminal="true"] { --panel-col: 0px; }
|
||||||
.viewer, .activity, .shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
.viewer, .activity, .termpanel, .shell[data-activity="true"] .viewer, .shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||||
.task__open { grid-template-columns: var(--sp-8) minmax(0, 1fr) var(--w-field-sm); }
|
.task__open { grid-template-columns: var(--sp-8) minmax(0, 1fr) var(--w-field-sm); }
|
||||||
.task__next { display: none; }
|
.task__next { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
+1484
-91
File diff suppressed because it is too large.
Load diff
@@ -53,6 +53,8 @@
|
|||||||
<div class="side__nav">
|
<div class="side__nav">
|
||||||
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
||||||
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
||||||
|
<button type="button" class="navrow" id="nav-archived" data-view="archived"><span class="navrow__icon"></span><span data-i18n>Archived</span></button>
|
||||||
|
<button type="button" class="navrow" id="nav-memory" data-view="memory"><span class="navrow__icon"></span><span data-i18n>Memory</span></button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
||||||
<div class="side__foot">
|
<div class="side__foot">
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
<div class="composer__bar">
|
<div class="composer__bar">
|
||||||
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||||
<div class="composer__chips" id="composer-chips"></div>
|
<div class="composer__chips" id="composer-chips"></div>
|
||||||
|
<button type="button" class="tool composer__mic" id="dictate" hidden aria-pressed="false"></button>
|
||||||
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
||||||
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,8 +110,11 @@
|
|||||||
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||||
<div class="files__head">
|
<div class="files__head">
|
||||||
<div class="files__where muted" id="server-where"></div>
|
<div class="files__where muted" id="server-where"></div>
|
||||||
|
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
|
||||||
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="status" id="server-status" hidden></p>
|
||||||
<div class="files__tree" id="server-tree"></div>
|
<div class="files__tree" id="server-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +129,24 @@
|
|||||||
<div class="page__body" id="scheduled-body"></div>
|
<div class="page__body" id="scheduled-body"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-archived" hidden aria-labelledby="archived-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="archived-title" data-i18n>Archived</h1>
|
||||||
|
<div class="head__place"></div>
|
||||||
|
<div class="head__tools"><button type="button" class="tool" id="archived-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body" id="archived-body"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-memory" hidden aria-labelledby="memory-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="memory-title" data-i18n>Memory</h1>
|
||||||
|
<div class="head__place"></div>
|
||||||
|
<div class="head__tools"><button type="button" class="tool" id="memory-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body" id="memory-body"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
||||||
@@ -135,6 +159,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||||
<header class="viewer__head activity__head">
|
<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>
|
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
|||||||
+351
-82
@@ -1,14 +1,26 @@
|
|||||||
// The chat's activity panel, beside it: what is in its context, by category, against the model's
|
// The chat's activity panel, beside it, opened from the header's button at any time: where an
|
||||||
// window; the files the chat and its agents wrote; every background agent -- a team's members as
|
// agent chat works; what is in its context, by category, against the model's window, how far it
|
||||||
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
|
// is to the automatic compaction and a button to compact now; the files the chat and its
|
||||||
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
|
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
|
||||||
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
|
// that leads them, background commands -- with how long it has run and a stop square, unfolding
|
||||||
// facts as the server's own panel (`services/activity.py`), from /api/v2:
|
// 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?since=&context= everything, with a `version`
|
||||||
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||||
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
|
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
|
||||||
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
// 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
|
// 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
|
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
||||||
@@ -23,6 +35,7 @@ import { get, post, follow, why } from "./bridge.js"
|
|||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { ask } from "./popup.js"
|
import { ask } from "./popup.js"
|
||||||
import { openServerFile } from "./explorer.js"
|
import { openServerFile } from "./explorer.js"
|
||||||
|
import { treePart, tickTree, treeState, TREE_LIVE } from "./tree.js"
|
||||||
|
|
||||||
const ACTIVE = ["queued", "working", "waiting"]
|
const ACTIVE = ["queued", "working", "waiting"]
|
||||||
// How many live agents show before "Show N more", as on the server's panel.
|
// How many live agents show before "Show N more", as on the server's panel.
|
||||||
@@ -31,6 +44,8 @@ const SHOWN = 6
|
|||||||
const CONTEXT_EVERY = 15000
|
const CONTEXT_EVERY = 15000
|
||||||
|
|
||||||
let layout = () => {} // (on) -> the shell makes room for the panel
|
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 chat = null // the open chat: { id, kind, … }
|
||||||
let stream = null // the events stream's handle
|
let stream = null // the events stream's handle
|
||||||
let retry = 0 // the reconnect timer
|
let retry = 0 // the reconnect timer
|
||||||
@@ -44,7 +59,12 @@ let readAt = 0 // when `data` was read: the elapsed times count on from there
|
|||||||
let contextAt = 0
|
let contextAt = 0
|
||||||
let reading = null // the read in flight
|
let reading = null // the read in flight
|
||||||
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
||||||
let view = null // null: the panel; { kind, id, name }: one agent's conversation
|
let view = null // null: the panel; { kind, id, name }: one agent's conversation ({ kind: "sub", what } a subagent's)
|
||||||
|
let tree = null // the subagent tree (spec 3.4.0), null for none or an older server
|
||||||
|
let treeRoom = [] // the room as the tree's answer gives it (an agent chat's comes only there)
|
||||||
|
let treeLive = true // false: an agent chat's device is away, and this is the tree the server kept
|
||||||
|
let treeNote = "" // why the last stop did not go through
|
||||||
|
let subTimer = 0 // a live subagent's conversation, read again while it works
|
||||||
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
||||||
let ticker = 0
|
let ticker = 0
|
||||||
|
|
||||||
@@ -69,12 +89,14 @@ export function compact(value) {
|
|||||||
return `${trim(n / 1000000, 2)}M`
|
return `${trim(n / 1000000, 2)}M`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
|
/**
|
||||||
|
* "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) {
|
export function summaryText(counts) {
|
||||||
if (!counts) return ""
|
if (!counts) return ""
|
||||||
const parts = []
|
const parts = []
|
||||||
if (counts.working === 1) parts.push(t("1 agent working"))
|
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
|
||||||
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
|
|
||||||
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
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.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||||
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||||
@@ -139,11 +161,16 @@ export function watchActivity(next) {
|
|||||||
data = null
|
data = null
|
||||||
room = []
|
room = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
|
tree = null
|
||||||
|
treeRoom = []
|
||||||
|
treeLive = true
|
||||||
|
treeNote = ""
|
||||||
readAt = contextAt = 0
|
readAt = contextAt = 0
|
||||||
view = null
|
view = null
|
||||||
|
compactError = ""
|
||||||
openRows.clear()
|
openRows.clear()
|
||||||
renderStrip()
|
renderStrip()
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
|
markOpen()
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
if (panelOpen()) showPanel(false)
|
if (panelOpen()) showPanel(false)
|
||||||
return
|
return
|
||||||
@@ -155,6 +182,15 @@ export function watchActivity(next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reply stream's `tree` frame: the tree as it is now. */
|
||||||
|
export function treeChanged(chatId, next) {
|
||||||
|
if (!chat || chat.id !== chatId) return
|
||||||
|
tree = next || null
|
||||||
|
if (!panelOpen()) return
|
||||||
|
if (!view) render()
|
||||||
|
else if (view.kind === "sub" && view.what === "transcript") readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
||||||
export function activityChanged() {
|
export function activityChanged() {
|
||||||
if (chat && panelOpen()) read(true)
|
if (chat && panelOpen()) read(true)
|
||||||
@@ -232,8 +268,16 @@ async function read(withContext) {
|
|||||||
const id = chat.id
|
const id = chat.id
|
||||||
reading = (async () => {
|
reading = (async () => {
|
||||||
try {
|
try {
|
||||||
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
|
const [r, tr] = await Promise.all([
|
||||||
|
get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`),
|
||||||
|
// A server before 3.4.0 has no tree: 404, and the panel is as it was.
|
||||||
|
get(`/chats/${enc(id)}/tree`).catch(() => null),
|
||||||
|
])
|
||||||
|
const a = r.activity
|
||||||
if (chat?.id !== id || !a) return
|
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
|
if (!withContext) a.context = data?.context ?? null
|
||||||
else contextAt = Date.now()
|
else contextAt = Date.now()
|
||||||
if (a.team) {
|
if (a.team) {
|
||||||
@@ -269,36 +313,58 @@ async function read(withContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The strip above the message box ---------------------------------------------------------------
|
// --- 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() {
|
function renderStrip() {
|
||||||
const strip = $("activity-strip")
|
const strip = $("activity-strip")
|
||||||
// A chip for every open chat: what runs, while something does, as the server's strip says it (a
|
// Only while something runs, as the server's own line: the header's button is the way into the
|
||||||
// chat whose helpers all finished long ago does not carry "12 finished" for ever: the panel
|
// panel at any other time (and a chat whose tasks all finished long ago does not carry
|
||||||
// still lists them); else only the way into the panel.
|
// "12 finished" for ever: the panel still lists them).
|
||||||
strip.hidden = !chat
|
const active = chat && summary?.active ? summaryText(summary) : ""
|
||||||
if (!chat) return clear(strip)
|
strip.hidden = !active
|
||||||
const active = summary?.active ? summaryText(summary) : ""
|
if (!active) return clear(strip)
|
||||||
const text = active || t("Activity")
|
|
||||||
strip.replaceChildren(
|
strip.replaceChildren(
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
id: "activity-open",
|
id: "activity-strip-btn",
|
||||||
class: `strip__btn${active ? " is-active" : ""}`,
|
class: "strip__btn is-active",
|
||||||
"aria-label": `${text} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
||||||
"aria-controls": "activity",
|
"aria-controls": "activity",
|
||||||
"aria-expanded": String(panelOpen()),
|
"aria-expanded": String(panelOpen()),
|
||||||
onclick: togglePanel,
|
onclick: togglePanel,
|
||||||
},
|
},
|
||||||
h("span", { class: "dot", "aria-hidden": "true" }),
|
h("span", { class: "dot", "aria-hidden": "true" }),
|
||||||
h("span", { class: "strip__text", text }),
|
h("span", { class: "strip__text", text: active }),
|
||||||
icon(ICONS.chevron),
|
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 ------------------------------------------------------------------------------------------
|
// --- The panel ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||||
@@ -306,14 +372,16 @@ export function showPanel(on) {
|
|||||||
if (on && !chat) return
|
if (on && !chat) return
|
||||||
$("activity").hidden = !on
|
$("activity").hidden = !on
|
||||||
layout(on)
|
layout(on)
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(on))
|
markOpen()
|
||||||
clearInterval(ticker)
|
clearInterval(ticker)
|
||||||
ticker = 0
|
ticker = 0
|
||||||
if (!on) {
|
if (!on) {
|
||||||
view = null
|
view = null
|
||||||
|
renderStrip()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticker = setInterval(tick, 1000)
|
ticker = setInterval(tick, 1000)
|
||||||
|
renderStrip()
|
||||||
render()
|
render()
|
||||||
read(true)
|
read(true)
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -330,6 +398,7 @@ function tick() {
|
|||||||
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
||||||
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
||||||
}
|
}
|
||||||
|
tickTree($("activity-body"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderError(text) {
|
function renderError(text) {
|
||||||
@@ -359,15 +428,45 @@ function render() {
|
|||||||
}
|
}
|
||||||
// Kept where the reader was: the panel is drawn again on every change.
|
// Kept where the reader was: the panel is drawn again on every change.
|
||||||
const scroll = body.scrollTop
|
const scroll = body.scrollTop
|
||||||
body.replaceChildren(
|
// 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("context", t("Context"), 0, contextPart(data.context)),
|
||||||
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
||||||
section("tasks", t("Background tasks"), 0, tasksPart()),
|
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,
|
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
||||||
)
|
]
|
||||||
|
body.replaceChildren(...parts.filter(Boolean))
|
||||||
body.scrollTop = scroll
|
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) {
|
function contextPart(c) {
|
||||||
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||||
const known = c.percent !== null && c.percent !== undefined
|
const known = c.percent !== null && c.percent !== undefined
|
||||||
@@ -410,7 +509,67 @@ function contextPart(c) {
|
|||||||
{ class: "ctx__note" },
|
{ 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 })}`,
|
`${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, note)
|
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. */
|
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||||
@@ -439,64 +598,148 @@ function outputsPart(outputs) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function tasksPart() {
|
function tasksPart(entries) {
|
||||||
const entries = data.agents || []
|
const team = tree ? null : data.team
|
||||||
const team = data.team
|
|
||||||
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
||||||
if (!entries.length && !team) {
|
if (!entries.length && !team) {
|
||||||
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
|
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
|
return box
|
||||||
}
|
}
|
||||||
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
|
|
||||||
if (team) {
|
if (team) {
|
||||||
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
|
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 }))
|
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||||
}
|
}
|
||||||
const live = entries.filter((e) => ACTIVE.includes(e.state))
|
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
|
||||||
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
|
// 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) {
|
if (live.length) {
|
||||||
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
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)))
|
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("%(n)s finished", { n: ended.length }), ended))
|
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
|
||||||
if (team) {
|
|
||||||
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
|
|
||||||
box.append(
|
|
||||||
room.length
|
|
||||||
? 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 || ""))),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
|
/** The room: as the activity read it (a chat's team), else as the tree's answer gave it. */
|
||||||
function fold(name, label, list) {
|
function roomList() {
|
||||||
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
|
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}`)))
|
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An agent and, under it, the agents it leads or started. */
|
/** A task and, folded under it, the tasks it leads or started ("Tasks: 3"). */
|
||||||
function agentRows(a) {
|
function agentRows(a) {
|
||||||
return [agentRow(a), ...(a.children || []).map(agentRows)]
|
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) {
|
function agentRow(a) {
|
||||||
@@ -512,7 +755,7 @@ function agentRow(a) {
|
|||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
class: "pane__btn agent__stop",
|
class: "agent__stop",
|
||||||
"aria-label": t("Stop %(name)s", { name: a.name }),
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
title: t("Stop %(name)s", { name: a.name }),
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
@@ -521,13 +764,15 @@ function agentRow(a) {
|
|||||||
stopAgent(a)
|
stopAgent(a)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icon(ICONS.stop),
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
)
|
)
|
||||||
: h("span", { class: "agent__stop", "aria-hidden": "true" })
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
const facts = h(
|
const facts = h(
|
||||||
"p",
|
"p",
|
||||||
{ class: "agent__facts" },
|
{ class: "agent__facts" },
|
||||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
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.agent ? h("span", { text: a.agent }) : null,
|
||||||
a.model ? h("span", { class: "agent__model", text: a.model }) : 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,
|
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||||
@@ -547,8 +792,8 @@ function agentRow(a) {
|
|||||||
h(
|
h(
|
||||||
"summary",
|
"summary",
|
||||||
{ class: "agent__row" },
|
{ class: "agent__row" },
|
||||||
h("span", { class: "agent__dot", "aria-hidden": "true" }),
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
|
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
|
||||||
time,
|
time,
|
||||||
stopBtn,
|
stopBtn,
|
||||||
),
|
),
|
||||||
@@ -556,12 +801,11 @@ function agentRow(a) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "agent__body" },
|
{ class: "agent__body" },
|
||||||
facts,
|
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,
|
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||||
step,
|
step,
|
||||||
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||||
a.transcript
|
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
|
||||||
? 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.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||||
@@ -569,6 +813,22 @@ function agentRow(a) {
|
|||||||
return el
|
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) {
|
async function stopAgent(a) {
|
||||||
if (!chat) return
|
if (!chat) return
|
||||||
if (a.kind === "member") {
|
if (a.kind === "member") {
|
||||||
@@ -602,6 +862,7 @@ function openAgent(a) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAgent() {
|
function closeAgent() {
|
||||||
|
clearTimeout(subTimer)
|
||||||
view = null
|
view = null
|
||||||
render()
|
render()
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -609,6 +870,7 @@ function closeAgent() {
|
|||||||
|
|
||||||
async function readAgent() {
|
async function readAgent() {
|
||||||
if (!chat || !view) return
|
if (!chat || !view) return
|
||||||
|
if (view.kind === "sub") return readSub()
|
||||||
const { kind, id } = view
|
const { kind, id } = view
|
||||||
let r
|
let r
|
||||||
try {
|
try {
|
||||||
@@ -634,7 +896,14 @@ async function readAgent() {
|
|||||||
a.role ? h("p", { class: "agent__role", text: a.role }) : 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,
|
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
||||||
)
|
)
|
||||||
const turns = (r.transcript || []).map((turn) => {
|
const turns = turnsOf(r.transcript || [])
|
||||||
|
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
||||||
|
if (atEnd) body.scrollTop = body.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A conversation's turns, read-only: the person's (or the harness's) messages and the replies. */
|
||||||
|
function turnsOf(list) {
|
||||||
|
return list.map((turn) => {
|
||||||
if (turn.role === "user") {
|
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--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
||||||
}
|
}
|
||||||
@@ -661,14 +930,14 @@ async function readAgent() {
|
|||||||
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
|
||||||
if (atEnd) body.scrollTop = body.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start ------------------------------------------------------------------------------------------
|
// --- Start ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
export function initActivity({ onLayout }) {
|
export function initActivity({ onLayout, here, compacted }) {
|
||||||
layout = onLayout || layout
|
layout = onLayout || layout
|
||||||
|
onThisComputer = here || onThisComputer
|
||||||
|
onCompacted = compacted || onCompacted
|
||||||
$("activity-close").replaceChildren(icon(ICONS.close))
|
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||||
$("activity-close").addEventListener("click", () => showPanel(false))
|
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||||
$("activity-back").replaceChildren(icon(ICONS.back))
|
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -66,7 +66,69 @@ export async function follow(path, { onFrame, onEnd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel's socket -----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Opened by Rust (`terminal_open`), which holds the token and the socket; the page gets the frames
|
||||||
|
// as `terminal` events -- `output` (the shell's bytes, base64), `control` (the server's JSON), a last
|
||||||
|
// `end` -- and sends keys, sizes and "close" through commands. As with a followed reply, a frame
|
||||||
|
// can come before `terminal_open` has answered with its id: those wait.
|
||||||
|
|
||||||
|
const terminals = new Map()
|
||||||
|
const earlyTerminal = new Map()
|
||||||
|
listen("terminal", (e) => {
|
||||||
|
const { id, kind, data } = e.payload
|
||||||
|
const f = terminals.get(id)
|
||||||
|
if (f) deliverTerminal(f, id, kind, data)
|
||||||
|
else {
|
||||||
|
if (!earlyTerminal.has(id)) earlyTerminal.set(id, [])
|
||||||
|
earlyTerminal.get(id).push([kind, data])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function deliverTerminal(f, id, kind, data) {
|
||||||
|
if (kind === "end") {
|
||||||
|
terminals.delete(id)
|
||||||
|
f.onEnd(data || {})
|
||||||
|
} else if (kind === "output") f.onOutput(data)
|
||||||
|
else if (kind === "control") f.onControl(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a shell: `target` is `{ chat }` or `{ device, directory }`; `onOutput(base64)`,
|
||||||
|
* `onControl(frame)`, and `onEnd({ code, reason, status, error })` once. The handle sends keys
|
||||||
|
* (`input`), a size (`resize`), and lets go (`close`; onEnd still comes).
|
||||||
|
*/
|
||||||
|
export async function openTerminal(target, cols, rows, { onOutput, onControl, onEnd }) {
|
||||||
|
const id = await invoke("terminal_open", {
|
||||||
|
chat: target.chat ?? null,
|
||||||
|
device: target.device ?? null,
|
||||||
|
directory: target.directory ?? null,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
const f = { onOutput, onControl, onEnd }
|
||||||
|
terminals.set(id, f)
|
||||||
|
for (const [kind, data] of earlyTerminal.get(id) || []) {
|
||||||
|
if (!terminals.has(id)) break
|
||||||
|
deliverTerminal(f, id, kind, data)
|
||||||
|
}
|
||||||
|
earlyTerminal.delete(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
input: (data) => invoke("terminal_input", { id, data }),
|
||||||
|
resize: (c, r) => invoke("terminal_resize", { id, cols: c, rows: r }),
|
||||||
|
close: () => invoke("terminal_close", { id }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A web link, in the system's browser. */
|
/** A web link, in the system's browser. */
|
||||||
export function openLink(url) {
|
export function openLink(url) {
|
||||||
return invoke("open_external", { url }).catch(() => {})
|
return invoke("open_external", { url }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Let go of every reply followed and every shell open (the page is about to load again). */
|
||||||
|
export function letGoAll() {
|
||||||
|
const ids = [...followers.keys()].map((id) => invoke("stream_close", { id }))
|
||||||
|
const shells = [...terminals.keys()].map((id) => invoke("terminal_close", { id }))
|
||||||
|
return Promise.allSettled([...ids, ...shells])
|
||||||
|
}
|
||||||
+437
-56
@@ -13,15 +13,20 @@ import { markdown } from "./markdown.js"
|
|||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||||
import { avatar, agentsControl, teamPossible, levelsOf, levelWord, picture } from "./models.js"
|
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
import { watchActivity, activityChanged, planList } from "./activity.js"
|
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||||
import { folderName } from "./list.js"
|
import { folderName, listed, folderTree, newFolder } from "./list.js"
|
||||||
|
import { unarchive } from "./archived.js"
|
||||||
|
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||||
|
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||||
|
import { scopePopover } from "./scope.js"
|
||||||
|
import { readAloudButton } from "./voice.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
const MODES = ["manual", "edit", "auto", "plan"]
|
||||||
|
|
||||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||||
let open = null // { chat, live, more, first }
|
let open = null // { chat, live, more, first }
|
||||||
|
|
||||||
export function openChatId() {
|
export function openChatId() {
|
||||||
@@ -30,10 +35,114 @@ export function openChatId() {
|
|||||||
|
|
||||||
// --- Reading -----------------------------------------------------------------------------------
|
// --- Reading -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
function onThisComputer(chat) {
|
export function onThisComputer(chat) {
|
||||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
|
||||||
|
function deviceOf(id) {
|
||||||
|
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This computer's device: the one linked right now, else the last one it was. */
|
||||||
|
function hereDevice() {
|
||||||
|
const mine = ctx.thisDevices()
|
||||||
|
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device a new agent chat goes to: the one chosen on its chip while it is still linked, else
|
||||||
|
* this computer's.
|
||||||
|
*/
|
||||||
|
function draftDevice() {
|
||||||
|
if (draft?.device && deviceOf(draft.device)) return draft.device
|
||||||
|
return hereDevice()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the new agent chat works here (the agent built into this app) or on another device. */
|
||||||
|
function draftHere() {
|
||||||
|
const d = draftDevice()
|
||||||
|
return !d || ctx.thisDevices().includes(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other linked devices of this account: where else a new agent chat can work. */
|
||||||
|
function otherDevices() {
|
||||||
|
const mine = ctx.thisDevices()
|
||||||
|
return (ctx.devices?.() || []).filter((d) => !mine.includes(d.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folders an agent chat on another device has worked in: the sidebar's agent chats there,
|
||||||
|
* newest first. This app cannot browse another computer; these and a typed path are the choice.
|
||||||
|
*/
|
||||||
|
function knownFolders(device) {
|
||||||
|
const out = []
|
||||||
|
for (const c of listed()) {
|
||||||
|
const dir = c.kind === "agent" && c.device?.token_id === device ? c.device.directory : ""
|
||||||
|
if (dir && !out.includes(dir)) out.push(dir)
|
||||||
|
}
|
||||||
|
return out.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||||
|
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||||
|
* terminals, or nothing.
|
||||||
|
*/
|
||||||
|
function terminalTarget() {
|
||||||
|
if (draft) {
|
||||||
|
if (draft.kind !== "agent" || !draft.dir) return null
|
||||||
|
const device = deviceOf(draftDevice())
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { device: device.id, directory: draft.dir, name: device.name || (draftHere() ? t("This computer") : t("Another device")) }
|
||||||
|
}
|
||||||
|
const chat = open?.chat
|
||||||
|
if (chat?.kind !== "agent" || !chat.device) return null
|
||||||
|
const device = deviceOf(chat.device.token_id)
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
|
||||||
|
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
|
||||||
|
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
|
||||||
|
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
|
||||||
|
|
||||||
|
async function readAgentFacts(device) {
|
||||||
|
if (!device) return null
|
||||||
|
const known = agentFacts.get(device)
|
||||||
|
if (known && Date.now() - known.at < 5000) return known
|
||||||
|
try {
|
||||||
|
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
|
||||||
|
if (!r.agent_subagents) {
|
||||||
|
agentFacts.set(device, { at: Date.now(), none: true })
|
||||||
|
return agentFacts.get(device)
|
||||||
|
}
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
|
||||||
|
agentFacts.set(device, facts)
|
||||||
|
return facts
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
|
||||||
|
function agentOffer(facts, model) {
|
||||||
|
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
|
||||||
|
const said = facts.models.get(model?.id)
|
||||||
|
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
|
||||||
|
return { cap: subagentsCap(), own: namedOnly(said) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text into the message box, after what is there, for the person to read before sending. */
|
||||||
|
export function intoComposer(text) {
|
||||||
|
const box = $("composer-text")
|
||||||
|
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
|
||||||
|
box.dispatchEvent(new Event("input"))
|
||||||
|
box.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function nearBottom() {
|
function nearBottom() {
|
||||||
const th = $("thread")
|
const th = $("thread")
|
||||||
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
||||||
@@ -184,6 +293,7 @@ function assistantMessage(m, chat) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "msg__actions" },
|
{ class: "msg__actions" },
|
||||||
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
|
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
|
||||||
|
readAloudButton(m.id, () => replyText(m)),
|
||||||
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
||||||
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
||||||
),
|
),
|
||||||
@@ -312,6 +422,24 @@ class Live {
|
|||||||
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
||||||
this.done = false
|
this.done = false
|
||||||
this.handle = null
|
this.handle = null
|
||||||
|
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
|
||||||
|
this.said = "" // the last `status` text, for a server that sends no progress
|
||||||
|
this.clock = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status line: the progress object's line where the server sends one, else its text. */
|
||||||
|
drawStatus() {
|
||||||
|
const p = this.progress
|
||||||
|
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
|
||||||
|
this.status.dataset.phase = isProgress(p) ? p.phase : ""
|
||||||
|
const ticks = progressTicks(p)
|
||||||
|
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
|
||||||
|
else if (!ticks) this.stopClock()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopClock() {
|
||||||
|
clearInterval(this.clock)
|
||||||
|
this.clock = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
@@ -327,11 +455,18 @@ class Live {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.handle?.close()
|
this.handle?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
frame(event, data) {
|
frame(event, data) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
|
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
|
||||||
|
// the object, or null for nothing running -- then the status text again.
|
||||||
|
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
|
||||||
|
this.progress = isProgress(data.progress) ? data.progress : null
|
||||||
|
if (event !== "status") this.drawStatus()
|
||||||
|
}
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "steps":
|
case "steps":
|
||||||
renderSteps(this.steps, data.steps || [], this.chat)
|
renderSteps(this.steps, data.steps || [], this.chat)
|
||||||
@@ -341,11 +476,16 @@ class Live {
|
|||||||
this.tailed(data)
|
this.tailed(data)
|
||||||
break
|
break
|
||||||
case "status":
|
case "status":
|
||||||
this.status.textContent = data.text || t("Writing…")
|
this.said = data.text || ""
|
||||||
|
this.drawStatus()
|
||||||
break
|
break
|
||||||
case "ask":
|
case "ask":
|
||||||
renderCards(this.chat, data.cards || [])
|
renderCards(this.chat, data.cards || [])
|
||||||
break
|
break
|
||||||
|
case "tree":
|
||||||
|
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
|
||||||
|
treeChanged(this.chat.id, data.tree)
|
||||||
|
break
|
||||||
case "jobs":
|
case "jobs":
|
||||||
this.jobs.hidden = !(data.jobs || []).length
|
this.jobs.hidden = !(data.jobs || []).length
|
||||||
append(
|
append(
|
||||||
@@ -360,6 +500,7 @@ class Live {
|
|||||||
break
|
break
|
||||||
case "done":
|
case "done":
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
||||||
renderCards(this.chat, [])
|
renderCards(this.chat, [])
|
||||||
if (data.chat?.title) setTitle(data.chat.title)
|
if (data.chat?.title) setTitle(data.chat.title)
|
||||||
@@ -385,6 +526,7 @@ class Live {
|
|||||||
end(err) {
|
end(err) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
if (open?.live === this) open.live = null
|
if (open?.live === this) open.live = null
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -417,10 +559,32 @@ function follower(chat, messageId) {
|
|||||||
|
|
||||||
// --- Cards ----------------------------------------------------------------------------------------
|
// --- Cards ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
|
||||||
|
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
|
||||||
|
* countdown included, and drawing it again would take away a command being corrected or an option
|
||||||
|
* already picked. New ones are added in the server's order; the ones no longer listed go.
|
||||||
|
*/
|
||||||
function renderCards(chat, cards) {
|
function renderCards(chat, cards) {
|
||||||
const box = clear($("cards"))
|
const box = $("cards")
|
||||||
box.hidden = !cards.length
|
const ids = cards.map((c) => String(c.id))
|
||||||
for (const card of cards) box.append(cardOf(chat, card))
|
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
|
||||||
|
if (!el) {
|
||||||
|
try {
|
||||||
|
el = cardOf(chat, card)
|
||||||
|
} catch (err) {
|
||||||
|
// Never an empty box under a status that says the reply waits: say what went wrong.
|
||||||
|
console.error("card", card, err)
|
||||||
|
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
|
||||||
|
}
|
||||||
|
el.dataset.card = String(card.id)
|
||||||
|
el.dataset.chat = chat.id
|
||||||
|
}
|
||||||
|
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
|
||||||
|
})
|
||||||
|
box.hidden = !box.children.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardOf(chat, card) {
|
function cardOf(chat, card) {
|
||||||
@@ -430,24 +594,44 @@ function cardOf(chat, card) {
|
|||||||
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
||||||
for (const item of card.items) {
|
for (const item of card.items) {
|
||||||
const part = h("div", { class: "ask__item" })
|
const part = h("div", { class: "ask__item" })
|
||||||
|
// A background subagent asks, not the conversation's own turn: said first (`job`).
|
||||||
|
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
|
||||||
|
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
|
||||||
|
if (item.subagent) {
|
||||||
|
const sub = item.subagent
|
||||||
|
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
|
||||||
|
}
|
||||||
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||||
|
let detailShown = false
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
||||||
if (item.editable) {
|
if (item.editable) {
|
||||||
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
|
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
|
||||||
|
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
|
||||||
|
// this item's text, and only when it differs -- an untouched one is not an edit.
|
||||||
|
const command = item.detail || item.title
|
||||||
|
detailShown = true
|
||||||
|
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
|
||||||
edit.addEventListener("input", () => {
|
edit.addEventListener("input", () => {
|
||||||
if (edit.value !== item.title) answers.texts[item.key] = edit.value
|
if (edit.value !== command) answers.texts[item.key] = edit.value
|
||||||
else delete answers.texts[item.key]
|
else delete answers.texts[item.key]
|
||||||
})
|
})
|
||||||
part.append(edit)
|
part.append(edit)
|
||||||
} else part.append(h("pre", { class: "call__pre", text: item.title }))
|
} else {
|
||||||
|
// What it wants to do (a tool's own title), then its input as the device sent it.
|
||||||
|
part.append(h("p", { class: "ask__what", text: item.title }))
|
||||||
|
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
|
||||||
|
detailShown = true
|
||||||
|
}
|
||||||
} else if (item.kind === "plan" && item.plan) {
|
} else if (item.kind === "plan" && item.plan) {
|
||||||
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
||||||
} else {
|
} else {
|
||||||
part.append(h("p", { class: "ask__title", text: item.title }))
|
part.append(h("p", { class: "ask__title", text: item.title }))
|
||||||
}
|
}
|
||||||
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
|
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
|
||||||
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
|
// The model's account of what it is for, attributed to it; then the server's reason for asking.
|
||||||
|
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
|
||||||
|
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
|
||||||
if (item.options?.length) {
|
if (item.options?.length) {
|
||||||
const picked = new Set()
|
const picked = new Set()
|
||||||
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
||||||
@@ -506,12 +690,11 @@ function cardOf(chat, card) {
|
|||||||
const actions = h("div", { class: "ask__actions" })
|
const actions = h("div", { class: "ask__actions" })
|
||||||
if (decides) {
|
if (decides) {
|
||||||
const isPlan = card.items.some((i) => i.kind === "plan")
|
const isPlan = card.items.some((i) => i.kind === "plan")
|
||||||
append(
|
append(actions, [
|
||||||
actions,
|
|
||||||
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
||||||
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
||||||
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
||||||
)
|
])
|
||||||
} else {
|
} else {
|
||||||
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
||||||
}
|
}
|
||||||
@@ -542,16 +725,13 @@ function renderHead(chat) {
|
|||||||
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (chat.device_session_id) {
|
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
|
||||||
tools.append(
|
// "Continue in a terminal" stays in the chat's menu.
|
||||||
h(
|
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||||
"button",
|
|
||||||
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
|
||||||
icon(ICONS.terminal),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// An archived chat says so where it is read, with the way back into the sidebar.
|
||||||
|
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
||||||
|
tools.append(activityButton())
|
||||||
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,13 +744,23 @@ async function chatModels(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
|
/**
|
||||||
|
* Whether this chat is offered subagents: `{ cap, own }` (the instance's most; whether only a model
|
||||||
|
* the person names could serve them), or null. The chat's own `subagents` where the server gives
|
||||||
|
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
|
||||||
|
*/
|
||||||
async function teamOffered(chat) {
|
async function teamOffered(chat) {
|
||||||
if (chat.kind !== "chat") return false
|
const said = chat.subagents
|
||||||
|
if (said !== undefined && said !== null) {
|
||||||
|
const offered = typeof said === "object" ? said.offered : said
|
||||||
|
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
|
||||||
|
}
|
||||||
|
if (chat.kind !== "chat") return null
|
||||||
try {
|
try {
|
||||||
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
|
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
|
||||||
|
return team?.offered ? { cap: subagentsCap(), own: false } : null
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,6 +789,47 @@ const MODE_WORDS = {
|
|||||||
plan: () => t("Plan — changes nothing"),
|
plan: () => t("Plan — changes nothing"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
|
||||||
|
|
||||||
|
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
|
||||||
|
|
||||||
|
/** Whether a chat here may be asked for a picture: the server draws for this account. */
|
||||||
|
function picturesOffered() {
|
||||||
|
const me = ctx.me()
|
||||||
|
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureChip() {
|
||||||
|
const b = chip({
|
||||||
|
ico: ICONS.picture,
|
||||||
|
label: t("Draw a picture"),
|
||||||
|
title: t("The reply to the next message is a picture drawn from it."),
|
||||||
|
menuish: false,
|
||||||
|
cls: "cchip--picture",
|
||||||
|
onclick: () => {
|
||||||
|
wantPicture = !wantPicture
|
||||||
|
b.setAttribute("aria-pressed", String(wantPicture))
|
||||||
|
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
|
||||||
|
$("composer-text").focus()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
b.setAttribute("aria-pressed", String(wantPicture))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message's body, asking for a picture while the switch is on. */
|
||||||
|
function takePicture(body) {
|
||||||
|
return wantPicture ? { ...body, force_tool: "image_generate" } : body
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message went: the switch goes off with it. */
|
||||||
|
function pictureSent() {
|
||||||
|
if (!wantPicture) return
|
||||||
|
wantPicture = false
|
||||||
|
$("composer-text").placeholder = t("Message")
|
||||||
|
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
|
||||||
|
}
|
||||||
|
|
||||||
function modeChip(value, onChange, disabled) {
|
function modeChip(value, onChange, disabled) {
|
||||||
return chip({
|
return chip({
|
||||||
ico: ICONS.shield,
|
ico: ICONS.shield,
|
||||||
@@ -640,26 +871,31 @@ function effortChip(model, value, onChange) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The team's size: a chip saying it, opening the two fields. `value` { min, max } or empty. */
|
/**
|
||||||
function agentsChip(value, onChange) {
|
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
|
||||||
|
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
|
||||||
|
*/
|
||||||
|
function agentsChip(value, onChange, { cap, own } = {}) {
|
||||||
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
||||||
return chip({
|
return chip({
|
||||||
ico: ICONS.users,
|
ico: ICONS.users,
|
||||||
label: `${t("Agents")} ${said}`,
|
label: `${t("Subagents")} ${said}`,
|
||||||
title: t("Its own agent included. Above one, it can add members that do the work while it leads them."),
|
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
|
||||||
|
cls: `cchip--subagents${own ? " is-warn" : ""}`,
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
const fields = agentsControl({ value, id: "agents-min" })
|
const fields = agentsControl({ value, id: "agents-min", cap })
|
||||||
popover(
|
popover(
|
||||||
e.currentTarget,
|
e.currentTarget,
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
{ class: "pop__body" },
|
{ class: "pop__body" },
|
||||||
h("label", { class: "pop__title", for: "agents-min", text: t("Agents") }),
|
h("p", { class: "pop__title", text: t("Subagents") }),
|
||||||
fields,
|
fields,
|
||||||
h("p", { class: "hint", text: t("Its own agent included. Above one, it can add members that do the work while it leads them.") }),
|
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
|
||||||
|
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
|
||||||
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
||||||
),
|
),
|
||||||
{ up: true, label: t("Agents") },
|
{ up: true, label: t("Subagents") },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -681,6 +917,51 @@ function folderChip(roots, value, onChange) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which computer a new agent chat works on: this one (the agent built into this app), or another
|
||||||
|
* linked device of the account, as `GET /devices` lists them. Shown only where there is another.
|
||||||
|
*/
|
||||||
|
function deviceChip(others) {
|
||||||
|
const here = draftHere()
|
||||||
|
const current = deviceOf(draftDevice())
|
||||||
|
const choose = (id) => {
|
||||||
|
if (id === draftDevice()) return
|
||||||
|
draft.device = id
|
||||||
|
const elsewhere = !ctx.thisDevices().includes(id)
|
||||||
|
draft.dir = elsewhere ? knownFolders(id)[0] || "" : ""
|
||||||
|
renderChips()
|
||||||
|
renderHello()
|
||||||
|
renderNotice()
|
||||||
|
}
|
||||||
|
const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }]
|
||||||
|
for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) })
|
||||||
|
const label = here ? t("This computer") : current?.name || current?.host || t("Another device")
|
||||||
|
const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) })
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder of a new agent chat on another device: one it has worked in, or one typed. */
|
||||||
|
function elsewhereFolderChip(device, value, onChange) {
|
||||||
|
const type = async () => {
|
||||||
|
const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") })
|
||||||
|
if (dir) onChange(dir)
|
||||||
|
}
|
||||||
|
const known = knownFolders(device)
|
||||||
|
if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type })
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.folder,
|
||||||
|
label: value ? folderName(value) : t("Choose a folder…"),
|
||||||
|
title: value ? `${t("Folder")}: ${value}` : "",
|
||||||
|
cls: value ? "" : "is-warn",
|
||||||
|
onclick: (e) =>
|
||||||
|
menu(e.currentTarget, [
|
||||||
|
...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
|
||||||
|
known.length ? null : undefined,
|
||||||
|
{ label: t("Type a folder…"), run: type },
|
||||||
|
].filter((x) => x !== undefined)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
||||||
|
|
||||||
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||||
@@ -694,22 +975,51 @@ async function renderChips() {
|
|||||||
const model = models.find((m) => m.id === draft.model)
|
const model = models.find((m) => m.id === draft.model)
|
||||||
const parts = []
|
const parts = []
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
const others = otherDevices()
|
||||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
if (others.length) parts.push(deviceChip(others))
|
||||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
if (draftHere()) {
|
||||||
|
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||||
|
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||||
|
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
|
} else {
|
||||||
|
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
|
}
|
||||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||||
}
|
}
|
||||||
|
const shell = terminalTarget()
|
||||||
|
aimTerminal(shell)
|
||||||
parts.push(modelChip(models, draft.model, (id) => {
|
parts.push(modelChip(models, draft.model, (id) => {
|
||||||
draft.model = id
|
draft.model = id
|
||||||
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
||||||
renderChips()
|
renderChips()
|
||||||
}, mayPick))
|
}, mayPick))
|
||||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips())))
|
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
||||||
|
else wantPicture = false
|
||||||
|
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||||
|
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||||
|
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||||
|
const known = device ? agentFacts.get(device) : null
|
||||||
|
const offer = device ? agentOffer(known, model) : null
|
||||||
|
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
|
||||||
|
if (device && (!known || Date.now() - known.at > 5000)) {
|
||||||
|
readAgentFacts(device).then((f) => {
|
||||||
|
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Last, so the chat's own settings come first where the line scrolls.
|
||||||
|
if (shell) {
|
||||||
|
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||||
|
b.dataset.terminalToggle = ""
|
||||||
|
b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
b.setAttribute("aria-controls", "terminal")
|
||||||
|
parts.push(b)
|
||||||
|
}
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chat = open?.chat
|
const chat = open?.chat
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
if (!chat?.kind) return box.replaceChildren()
|
if (!chat?.kind) return box.replaceChildren()
|
||||||
const save = async (body) => {
|
const save = async (body) => {
|
||||||
try {
|
try {
|
||||||
@@ -731,9 +1041,12 @@ async function renderChips() {
|
|||||||
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||||
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||||
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
|
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
||||||
|
else wantPicture = false
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
if (!(await teamOffered(chat)) || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
const offer = await teamOffered(chat)
|
||||||
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a })))
|
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -743,7 +1056,7 @@ async function renderChips() {
|
|||||||
function renderNotice() {
|
function renderNotice() {
|
||||||
const box = $("link-notice")
|
const box = $("link-notice")
|
||||||
const s = ctx.status()
|
const s = ctx.status()
|
||||||
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||||
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||||
box.hidden = !show
|
box.hidden = !show
|
||||||
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||||
@@ -756,6 +1069,10 @@ export function statusChanged() {
|
|||||||
if (draft) {
|
if (draft) {
|
||||||
renderChips()
|
renderChips()
|
||||||
renderHello()
|
renderHello()
|
||||||
|
} else if (open?.chat?.kind) {
|
||||||
|
// The devices were read again: whether this chat's opens a terminal may have changed.
|
||||||
|
renderHead(open.chat)
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,9 +1081,14 @@ export function chatMenu(anchor, chat) {
|
|||||||
const base = ctx.status()?.server
|
const base = ctx.status()?.server
|
||||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||||
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
|
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
|
||||||
|
if (!chat.archived) {
|
||||||
|
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
|
||||||
|
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
|
||||||
|
}
|
||||||
|
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) })
|
||||||
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
|
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
|
||||||
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
|
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
|
||||||
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
|
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||||
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
||||||
menu(anchor, items)
|
menu(anchor, items)
|
||||||
}
|
}
|
||||||
@@ -799,6 +1121,34 @@ export async function continueInTerminal(chat) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a chat can go: no folder, any of the account's, or a new one. */
|
||||||
|
function moveMenu(anchor, chat) {
|
||||||
|
const here = chat.folder_id || ""
|
||||||
|
menu(anchor, [
|
||||||
|
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
|
||||||
|
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
label: t("New folder…"),
|
||||||
|
run: async () => {
|
||||||
|
const id = await newFolder()
|
||||||
|
if (id) setChat(chat, { folder_id: id })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
|
||||||
|
async function setChat(chat, body) {
|
||||||
|
try {
|
||||||
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||||
|
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
|
||||||
|
ctx.changed(r?.chat)
|
||||||
|
} catch (err) {
|
||||||
|
alertIn(why(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renameChat(chat) {
|
async function renameChat(chat) {
|
||||||
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
|
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
|
||||||
if (!title || title === chat.title) return
|
if (!title || title === chat.title) return
|
||||||
@@ -823,6 +1173,15 @@ async function archiveChat(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An archived chat, open or in its page's menu, back into the sidebar. */
|
||||||
|
async function unarchiveChat(chat) {
|
||||||
|
if (!(await unarchive(chat))) return
|
||||||
|
if (open?.chat?.id === chat.id) {
|
||||||
|
open.chat = { ...open.chat, archived: false }
|
||||||
|
renderHead(open.chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteChat(chat) {
|
async function deleteChat(chat) {
|
||||||
const yes = await ask({
|
const yes = await ask({
|
||||||
title: t("Delete this chat?"),
|
title: t("Delete this chat?"),
|
||||||
@@ -935,7 +1294,7 @@ export function closeChat() {
|
|||||||
|
|
||||||
// --- A new chat ------------------------------------------------------------------------------------
|
// --- A new chat ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
|
let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written
|
||||||
let lastKind = "agent"
|
let lastKind = "agent"
|
||||||
|
|
||||||
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
||||||
@@ -943,6 +1302,18 @@ export function draftKind() {
|
|||||||
return draft?.kind || lastKind
|
return draft?.kind || lastKind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `/models` was read again: a new chat being written is drawn with what it says now. */
|
||||||
|
export function modelsChanged() {
|
||||||
|
if (!draft) return
|
||||||
|
const models = ctx.models()
|
||||||
|
if (!models.some((m) => m.id === draft.model)) {
|
||||||
|
const start = models.find((m) => m.default) || models[0]
|
||||||
|
draft.model = start?.id || ""
|
||||||
|
draft.effort = levelsOf(start).start
|
||||||
|
}
|
||||||
|
renderChips()
|
||||||
|
}
|
||||||
|
|
||||||
/** The greeting of a new chat, and the choice of what it is. */
|
/** The greeting of a new chat, and the choice of what it is. */
|
||||||
function renderHello() {
|
function renderHello() {
|
||||||
const box = $("new-chat")
|
const box = $("new-chat")
|
||||||
@@ -985,7 +1356,9 @@ function renderHello() {
|
|||||||
h(
|
h(
|
||||||
"p",
|
"p",
|
||||||
{ class: "hello__sub" },
|
{ class: "hello__sub" },
|
||||||
kind === "agent"
|
kind === "agent" && !draftHere()
|
||||||
|
? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") })
|
||||||
|
: kind === "agent"
|
||||||
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
||||||
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
||||||
),
|
),
|
||||||
@@ -1001,7 +1374,7 @@ export function newChat(kind) {
|
|||||||
const models = ctx.models()
|
const models = ctx.models()
|
||||||
const start = models.find((m) => m.default) || models[0]
|
const start = models.find((m) => m.default) || models[0]
|
||||||
lastKind = kind || lastKind
|
lastKind = kind || lastKind
|
||||||
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
renderCards({ id: "" }, [])
|
renderCards({ id: "" }, [])
|
||||||
alertIn("")
|
alertIn("")
|
||||||
@@ -1027,15 +1400,21 @@ async function createChat(text, attachments) {
|
|||||||
const agents = draft.agents
|
const agents = draft.agents
|
||||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = draftDevice()
|
||||||
if (!device || !agentReady(ctx.status())) {
|
if (draftHere()) {
|
||||||
renderNotice()
|
if (!device || !agentReady(ctx.status())) {
|
||||||
throw { detail: agentState(ctx.status()).sentence }
|
renderNotice()
|
||||||
|
throw { detail: agentState(ctx.status()).sentence }
|
||||||
|
}
|
||||||
|
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||||
|
} else {
|
||||||
|
if (!deviceOf(device)) throw { detail: t("This device is not linked right now.") }
|
||||||
|
if (!draft.dir) throw { detail: t("Choose a folder first.") }
|
||||||
}
|
}
|
||||||
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
|
||||||
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
||||||
}
|
}
|
||||||
const r = await post("/chats", body)
|
const r = await post("/chats", draft.kind === "chat" ? takePicture(body) : body)
|
||||||
|
pictureSent()
|
||||||
draft = null
|
draft = null
|
||||||
$("new-chat").hidden = true
|
$("new-chat").hidden = true
|
||||||
$("main").dataset.new = "false"
|
$("main").dataset.new = "false"
|
||||||
@@ -1072,9 +1451,11 @@ async function submit() {
|
|||||||
try {
|
try {
|
||||||
if (draft) await createChat(text, files)
|
if (draft) await createChat(text, files)
|
||||||
else if (open?.chat?.id) {
|
else if (open?.chat?.id) {
|
||||||
const body = { text }
|
let body = { text }
|
||||||
if (files.length) body.attachments = files
|
if (files.length) body.attachments = files
|
||||||
|
if (open.chat.kind === "chat") body = takePicture(body)
|
||||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
|
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
|
||||||
|
pictureSent()
|
||||||
if (r.message) $("messages").append(userMessage(r.message))
|
if (r.message) $("messages").append(userMessage(r.message))
|
||||||
if (r.reply) follower(open.chat, r.reply.id)
|
if (r.reply) follower(open.chat, r.reply.id)
|
||||||
} else return
|
} else return
|
||||||
|
|||||||
+9
-1
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
export function append(el, children) {
|
/** append(el, child, [more], …): children one by one or in arrays, nested as deep as they come. */
|
||||||
|
export function append(el, ...children) {
|
||||||
for (const c of children.flat(Infinity)) {
|
for (const c of children.flat(Infinity)) {
|
||||||
if (c === null || c === undefined || c === false) continue
|
if (c === null || c === undefined || c === false) continue
|
||||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
||||||
@@ -75,12 +76,14 @@ export const ICONS = {
|
|||||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||||
stop: "M4.5 4.5h7v7h-7z",
|
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",
|
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",
|
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",
|
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",
|
||||||
@@ -96,6 +99,11 @@ export const ICONS = {
|
|||||||
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
||||||
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
||||||
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
||||||
|
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
|
||||||
|
contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"],
|
||||||
|
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
|
||||||
|
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
|
||||||
|
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
|
||||||
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
|
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Draw a picture: the instance's own image generation (`POST /v1/images/generations`, its
|
||||||
|
// workflow and default checkpoint), from Ctrl+K. Offered only where `GET /me` says the account may
|
||||||
|
// (`services.images`) and the token holds `models`.
|
||||||
|
//
|
||||||
|
// The pictures are shown only as Rust read them: each one of the person's own files on this
|
||||||
|
// server (`/v1/files/<id>/content`, fetched by id from the app's own server, never from an
|
||||||
|
// address the answer named), handed over as a `data:` URL of an image type -- checked again here
|
||||||
|
// before it becomes an <img>.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, why } from "./bridge.js"
|
||||||
|
|
||||||
|
const PICTURE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/
|
||||||
|
|
||||||
|
/** Whether pictures can be drawn here, for the account `me` (`GET /me`). */
|
||||||
|
export function canDraw(me) {
|
||||||
|
const models = !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||||
|
return !!me?.services?.images && models && me?.permissions?.["tools.image"] !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog: a prompt, a shape, Draw, and the pictures under it. */
|
||||||
|
export function openDraw() {
|
||||||
|
const prompt = h("textarea", { class: "textarea draw__prompt", rows: "3", placeholder: t("What to draw…"), "aria-label": t("What to draw…") })
|
||||||
|
const size = h(
|
||||||
|
"select",
|
||||||
|
{ class: "draw__size", "aria-label": t("Shape") },
|
||||||
|
h("option", { value: "1024x1024", text: t("Square") }),
|
||||||
|
h("option", { value: "1344x768", text: t("Wide") }),
|
||||||
|
h("option", { value: "768x1344", text: t("Tall") }),
|
||||||
|
)
|
||||||
|
const status = h("p", { class: "status", role: "status", hidden: true })
|
||||||
|
const out = h("div", { class: "draw__out" })
|
||||||
|
const go = h("button", { type: "submit", class: "btn btn--primary", text: t("Draw") })
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{ class: "dialog__body draw", method: "dialog" },
|
||||||
|
h("h2", { class: "dialog__title" }, icon(ICONS.picture), h("span", { text: t("Draw a picture") })),
|
||||||
|
prompt,
|
||||||
|
h("div", { class: "draw__bar" }, size, h("button", { type: "button", class: "btn", text: t("Close"), onclick: () => close() }), go),
|
||||||
|
status,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
const dlg = h("dialog", { class: "dialog dialog--wide" }, form)
|
||||||
|
let asked = 0
|
||||||
|
const close = () => {
|
||||||
|
asked++
|
||||||
|
dlg.close()
|
||||||
|
dlg.remove()
|
||||||
|
}
|
||||||
|
const said = (text, error = false) => {
|
||||||
|
status.hidden = !text
|
||||||
|
status.textContent = text
|
||||||
|
status.className = `status${error ? " is-error" : ""}`
|
||||||
|
status.setAttribute("role", error ? "alert" : "status")
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const words = prompt.value.trim()
|
||||||
|
if (!words) return prompt.focus()
|
||||||
|
const mine = ++asked
|
||||||
|
go.disabled = true
|
||||||
|
said(t("Drawing… a picture can take a minute or two."))
|
||||||
|
try {
|
||||||
|
const urls = await invoke("draw", { prompt: words, size: size.value })
|
||||||
|
if (mine !== asked) return
|
||||||
|
const pictures = (urls || []).filter((u) => PICTURE.test(u))
|
||||||
|
if (!pictures.length) throw { detail: t("The server's answer had no picture in it.") }
|
||||||
|
out.prepend(...pictures.map((src) => h("figure", { class: "draw__pic" }, h("img", { src, alt: words }), h("figcaption", { text: words }))))
|
||||||
|
said("")
|
||||||
|
} catch (err) {
|
||||||
|
if (mine === asked) said(why(err), true)
|
||||||
|
} finally {
|
||||||
|
if (mine === asked) go.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prompt.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
dlg.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
document.body.append(dlg)
|
||||||
|
dlg.showModal()
|
||||||
|
prompt.focus()
|
||||||
|
}
|
||||||
+126
-11
@@ -3,7 +3,8 @@
|
|||||||
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { invoke, get, why } from "./bridge.js"
|
import { invoke, get, post, del, why } from "./bridge.js"
|
||||||
|
import { menu, ask } from "./popup.js"
|
||||||
import { openViewer } from "./viewer.js"
|
import { openViewer } from "./viewer.js"
|
||||||
|
|
||||||
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
|
||||||
@@ -18,12 +19,19 @@ export const SERVER_FILES = {
|
|||||||
/** A directory in either tree: the server's mounts open like its folders. */
|
/** A directory in either tree: the server's mounts open like its folders. */
|
||||||
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
|
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
|
||||||
|
|
||||||
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
|
/**
|
||||||
function tree(box, { list, open, rootPath }) {
|
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
|
||||||
|
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
|
||||||
|
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
|
||||||
|
*/
|
||||||
|
function tree(box, { list, open, rootPath, actions }) {
|
||||||
clear(box)
|
clear(box)
|
||||||
const ul = h("ul", { class: "tree", role: "tree" })
|
const ul = h("ul", { class: "tree", role: "tree" })
|
||||||
box.append(ul)
|
box.append(ul)
|
||||||
|
const levels = new Map() // path -> { ul, depth }, each directory drawn now
|
||||||
|
const opened = new Set() // the directories open now
|
||||||
const level = async (parent, path, depth) => {
|
const level = async (parent, path, depth) => {
|
||||||
|
levels.set(path, { ul: parent, depth })
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await list(path)
|
entries = await list(path)
|
||||||
@@ -42,26 +50,45 @@ function tree(box, { list, open, rootPath }) {
|
|||||||
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
|
||||||
)
|
)
|
||||||
row.style.setProperty("--depth", String(depth))
|
row.style.setProperty("--depth", String(depth))
|
||||||
const li = h("li", { role: "treeitem" }, row)
|
const items = actions?.(e) || []
|
||||||
|
const li = h(
|
||||||
|
"li",
|
||||||
|
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
|
||||||
|
row,
|
||||||
|
items.length
|
||||||
|
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
|
||||||
|
: null,
|
||||||
|
)
|
||||||
if (isDir(e)) {
|
if (isDir(e)) {
|
||||||
let kids = null
|
let kids = null
|
||||||
row.addEventListener("click", async () => {
|
const expand = async (opening) => {
|
||||||
const opening = row.getAttribute("aria-expanded") !== "true"
|
|
||||||
row.setAttribute("aria-expanded", String(opening))
|
row.setAttribute("aria-expanded", String(opening))
|
||||||
if (!opening) {
|
if (!opening) {
|
||||||
|
opened.delete(e.path)
|
||||||
|
levels.delete(e.path)
|
||||||
kids?.remove()
|
kids?.remove()
|
||||||
kids = null
|
kids = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
opened.add(e.path)
|
||||||
kids = h("ul", { role: "group" })
|
kids = h("ul", { role: "group" })
|
||||||
li.append(kids)
|
li.append(kids)
|
||||||
await level(kids, e.path, depth + 1)
|
await level(kids, e.path, depth + 1)
|
||||||
})
|
}
|
||||||
|
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
|
||||||
|
if (opened.has(e.path)) expand(true)
|
||||||
} else row.addEventListener("click", () => open(e))
|
} else row.addEventListener("click", () => open(e))
|
||||||
parent.append(li)
|
parent.append(li)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return level(ul, rootPath, 0)
|
const done = level(ul, rootPath, 0)
|
||||||
|
const reload = (path) => {
|
||||||
|
const at = levels.get(path) || levels.get(rootPath)
|
||||||
|
if (!at) return Promise.resolve()
|
||||||
|
clear(at.ul)
|
||||||
|
return level(at.ul, levels.has(path) ? path : rootPath, at.depth)
|
||||||
|
}
|
||||||
|
return { done, reload }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- This computer ------------------------------------------------------------------------------
|
// --- This computer ------------------------------------------------------------------------------
|
||||||
@@ -99,7 +126,7 @@ function renderLocal() {
|
|||||||
head.append(pick)
|
head.append(pick)
|
||||||
}
|
}
|
||||||
const root = local.root
|
const root = local.root
|
||||||
tree(box, {
|
return tree(box, {
|
||||||
rootPath: "",
|
rootPath: "",
|
||||||
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
list: async (path) => (await invoke("local_list", { root, path })).entries,
|
||||||
open: async (e) => {
|
open: async (e) => {
|
||||||
@@ -125,12 +152,96 @@ export async function readServer(path) {
|
|||||||
return get(SERVER_FILES.read(path))
|
return get(SERVER_FILES.read(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The library's writes, as the person (each answers with the directory's listing, or a refusal
|
||||||
|
// in the server's words):
|
||||||
|
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
|
||||||
|
// POST /files/mkdir { path, name }
|
||||||
|
// POST /files/rename { path, name }
|
||||||
|
// DELETE /files?path= to the trash, as on the web
|
||||||
|
|
||||||
|
let server = null // the tree drawn now: { reload(path) }
|
||||||
|
let serverRoot = "/" // the directory the server answered `/` with: the person's home
|
||||||
|
|
||||||
|
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
|
||||||
|
const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir)
|
||||||
|
|
||||||
|
function serverSaid(text, error = false) {
|
||||||
|
const line = $("server-status")
|
||||||
|
line.hidden = !text
|
||||||
|
line.textContent = text || ""
|
||||||
|
line.className = `status${error ? " is-error" : ""}`
|
||||||
|
line.setAttribute("role", error ? "alert" : "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into `dir`: files chosen in the system's dialog. */
|
||||||
|
export async function uploadInto(dir) {
|
||||||
|
serverSaid("")
|
||||||
|
try {
|
||||||
|
const r = await invoke("library_upload", { dir, title: t("Upload") })
|
||||||
|
if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true)
|
||||||
|
if (r.sent) await reloadServer(dir)
|
||||||
|
} catch (err) {
|
||||||
|
serverSaid(why(err), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFolder(dir) {
|
||||||
|
const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") })
|
||||||
|
if (!name) return
|
||||||
|
try {
|
||||||
|
await post("/files/mkdir", { path: dir, name })
|
||||||
|
serverSaid("")
|
||||||
|
await reloadServer(dir)
|
||||||
|
} catch (err) {
|
||||||
|
serverSaid(why(err), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameEntry(e) {
|
||||||
|
const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") })
|
||||||
|
if (!name || name === e.name) return
|
||||||
|
try {
|
||||||
|
await post("/files/rename", { path: e.path, name })
|
||||||
|
serverSaid("")
|
||||||
|
await reloadServer(parentOf(e.path))
|
||||||
|
} catch (err) {
|
||||||
|
serverSaid(why(err), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry(e) {
|
||||||
|
const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/files?path=${encodeURIComponent(e.path)}`)
|
||||||
|
serverSaid(t("Moved to the trash."))
|
||||||
|
await reloadServer(parentOf(e.path))
|
||||||
|
} catch (err) {
|
||||||
|
serverSaid(why(err), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What can be done to an entry of the server's tree: a mount (another store) is filled, never
|
||||||
|
* renamed or deleted from here. A read-only place is refused by the server, in its words. */
|
||||||
|
function serverActions(e) {
|
||||||
|
const items = []
|
||||||
|
if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) })
|
||||||
|
if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
export function renderServer() {
|
export function renderServer() {
|
||||||
const box = $("server-tree")
|
const box = $("server-tree")
|
||||||
tree(box, {
|
serverSaid("")
|
||||||
|
server = tree(box, {
|
||||||
rootPath: "/",
|
rootPath: "/",
|
||||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
list: async (path) => {
|
||||||
|
const r = await get(SERVER_FILES.list(path))
|
||||||
|
if (path === "/" && r.path) serverRoot = r.path
|
||||||
|
return r.entries || []
|
||||||
|
},
|
||||||
open: (e) => openServerFile(e.path, e.name),
|
open: (e) => openServerFile(e.path, e.name),
|
||||||
|
actions: serverActions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +288,10 @@ export function initExplorer() {
|
|||||||
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
$("server-refresh").addEventListener("click", renderServer)
|
$("server-refresh").addEventListener("click", renderServer)
|
||||||
|
$("server-upload").replaceChildren(icon(ICONS.send))
|
||||||
|
$("server-upload").addEventListener("click", () => uploadInto(serverRoot))
|
||||||
|
$("server-mkdir").replaceChildren(icon(ICONS.folder))
|
||||||
|
$("server-mkdir").addEventListener("click", () => makeFolder(serverRoot))
|
||||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
$("local-refresh").addEventListener("click", renderLocal)
|
$("local-refresh").addEventListener("click", renderLocal)
|
||||||
}
|
}
|
||||||
+156
-9
@@ -1,15 +1,23 @@
|
|||||||
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
|
// The sidebar's chats: agent chats and chats on the server in one list, newest first. The
|
||||||
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
|
// pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the
|
||||||
// on another device of the account says so in its title. The search line above narrows the list
|
// server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark
|
||||||
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
|
// and its project folder's name; one that works on another device of the account says so in its
|
||||||
|
// title. The search line above narrows the list by title (and an agent chat by its folder) as it
|
||||||
|
// is typed, flat; Ctrl+K searches everything.
|
||||||
|
//
|
||||||
|
// Folders are made, renamed and deleted here (`POST/PATCH/DELETE /folders`); a chat is pinned and
|
||||||
|
// moved from its own menu (`PATCH /chats/{id}` `pinned`, `folder_id`). Deleting a folder never
|
||||||
|
// deletes a chat: its chats go back to the list.
|
||||||
|
|
||||||
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||||
import { get, why } from "./bridge.js"
|
import { get, post, patch, del, why } from "./bridge.js"
|
||||||
|
import { menu, ask } from "./popup.js"
|
||||||
|
|
||||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat), note(text) }
|
||||||
let rows = []
|
let rows = []
|
||||||
let more = false
|
let more = false
|
||||||
let query = ""
|
let query = ""
|
||||||
|
let folders = [] // { id, name, parent_id, collapsed, position }
|
||||||
|
|
||||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||||
function fold(text) {
|
function fold(text) {
|
||||||
@@ -22,9 +30,10 @@ function fold(text) {
|
|||||||
/** The list again, from the server. */
|
/** The list again, from the server. */
|
||||||
export async function refreshList() {
|
export async function refreshList() {
|
||||||
try {
|
try {
|
||||||
const r = await get("/chats?limit=50")
|
const [r, f] = await Promise.all([get("/chats?limit=50"), get("/folders").catch(() => ({ folders: [] }))])
|
||||||
rows = r.chats || []
|
rows = r.chats || []
|
||||||
more = !!r.more
|
more = !!r.more
|
||||||
|
folders = f.folders || []
|
||||||
render()
|
render()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
|
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
|
||||||
@@ -77,6 +86,108 @@ function row(c) {
|
|||||||
return h("li", null, b)
|
return h("li", null, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A folder's head: it folds and unfolds; its menu renames it, makes one inside, deletes it. */
|
||||||
|
function folderHead(f, depth, count) {
|
||||||
|
const open = !f.collapsed
|
||||||
|
const head = h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "folderrow",
|
||||||
|
dataset: { folder: f.id },
|
||||||
|
style: depth ? `--depth: ${depth}` : null,
|
||||||
|
"aria-expanded": String(open),
|
||||||
|
title: f.name,
|
||||||
|
onclick: () => foldFolder(f, !f.collapsed),
|
||||||
|
},
|
||||||
|
h("span", { class: "folderrow__chev" }, icon(open ? ICONS.chevronDown : ICONS.chevron)),
|
||||||
|
h("span", { class: "folderrow__icon" }, icon(ICONS.folder)),
|
||||||
|
h("span", { class: "folderrow__name", text: f.name }),
|
||||||
|
h("span", { class: "folderrow__count", text: count ? String(count) : "" }),
|
||||||
|
)
|
||||||
|
head.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
folderMenu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : head, f)
|
||||||
|
})
|
||||||
|
const more = h("button", { type: "button", class: "folderrow__more", "aria-label": t("More"), title: t("More"), onclick: (e) => folderMenu(e.currentTarget, f) }, icon(ICONS.more))
|
||||||
|
return h("li", { class: "list__folder" }, head, more)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function foldFolder(f, collapsed) {
|
||||||
|
f.collapsed = collapsed
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
await patch(`/folders/${encodeURIComponent(f.id)}`, { collapsed })
|
||||||
|
} catch {
|
||||||
|
// Folding is the sidebar's own business: drawn as asked even where the server did not keep it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderMenu(anchor, f) {
|
||||||
|
menu(anchor, [
|
||||||
|
{ label: t("Rename folder"), run: () => renameFolder(f) },
|
||||||
|
{ label: t("New folder"), run: () => newFolder(f.id) },
|
||||||
|
null,
|
||||||
|
{ label: t("Delete folder"), danger: true, run: () => deleteFolder(f) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new folder, at the top level or inside `parent`: its id once made, else "". */
|
||||||
|
export async function newFolder(parent = "") {
|
||||||
|
const name = await ask({ title: t("New folder"), text: t("Folder name"), value: "", ok: t("Create"), cancel: t("Cancel") })
|
||||||
|
if (!name) return ""
|
||||||
|
try {
|
||||||
|
const r = await post("/folders", parent ? { name, parent_id: parent } : { name })
|
||||||
|
await refreshList()
|
||||||
|
return r.folder?.id || ""
|
||||||
|
} catch (err) {
|
||||||
|
ctx.note?.(why(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFolder(f) {
|
||||||
|
const name = await ask({ title: t("Rename folder"), text: t("Folder name"), value: f.name, ok: t("Rename"), cancel: t("Cancel") })
|
||||||
|
if (!name || name === f.name) return
|
||||||
|
try {
|
||||||
|
await patch(`/folders/${encodeURIComponent(f.id)}`, { name })
|
||||||
|
await refreshList()
|
||||||
|
} catch (err) {
|
||||||
|
ctx.note?.(why(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(f) {
|
||||||
|
const ok = await ask({ title: t("Delete folder"), text: t("“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", { name: f.name }), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await del(`/folders/${encodeURIComponent(f.id)}`)
|
||||||
|
await refreshList()
|
||||||
|
} catch (err) {
|
||||||
|
ctx.note?.(why(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's chat folders, as the list last read them. */
|
||||||
|
export function listedFolders() {
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folders as a tree, depth first, each with its depth: what a menu of them lists. */
|
||||||
|
export function folderTree() {
|
||||||
|
const out = []
|
||||||
|
const known = new Set(folders.map((f) => f.id))
|
||||||
|
const walk = (parent, depth) => {
|
||||||
|
const kids = folders.filter((f) => (f.parent_id && known.has(f.parent_id) ? f.parent_id : null) === parent).sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name))
|
||||||
|
for (const f of kids) {
|
||||||
|
out.push({ folder: f, depth })
|
||||||
|
walk(f.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(null, 0)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function isToday(stamp) {
|
function isToday(stamp) {
|
||||||
const d = new Date(stamp)
|
const d = new Date(stamp)
|
||||||
if (Number.isNaN(d.getTime())) return false
|
if (Number.isNaN(d.getTime())) return false
|
||||||
@@ -98,8 +209,44 @@ function render() {
|
|||||||
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const today = visible.filter((r) => isToday(r.updated_at))
|
// Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest.
|
||||||
const earlier = visible.filter((r) => !isToday(r.updated_at))
|
let rest = visible
|
||||||
|
if (!words.length) {
|
||||||
|
const pinned = visible.filter((r) => r.pinned)
|
||||||
|
if (pinned.length) append(list, [h("li", { class: "list__head", text: t("Pinned chats") }), pinned.map(row)])
|
||||||
|
const known = new Set(folders.map((f) => f.id))
|
||||||
|
const filed = (r) => !r.pinned && r.folder_id && known.has(r.folder_id)
|
||||||
|
const tree = folderTree()
|
||||||
|
if (tree.length) {
|
||||||
|
const inside = (id) => {
|
||||||
|
// A folder's count: its own chats and those of the folders inside it.
|
||||||
|
const ids = new Set([id])
|
||||||
|
for (const { folder } of tree) if (folder.parent_id && ids.has(folder.parent_id)) ids.add(folder.id)
|
||||||
|
return visible.filter((r) => filed(r) && ids.has(r.folder_id)).length
|
||||||
|
}
|
||||||
|
const hidden = new Set() // folders under a folded one
|
||||||
|
for (const { folder, depth } of tree) {
|
||||||
|
if (folder.parent_id && hidden.has(folder.parent_id)) {
|
||||||
|
hidden.add(folder.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list.append(folderHead(folder, depth, inside(folder.id)))
|
||||||
|
if (folder.collapsed) {
|
||||||
|
hidden.add(folder.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const r of visible.filter((x) => filed(x) && x.folder_id === folder.id)) {
|
||||||
|
const li = row(r)
|
||||||
|
li.firstChild.style.setProperty("--depth", String(depth + 1))
|
||||||
|
li.firstChild.classList.add("is-filed")
|
||||||
|
list.append(li)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = visible.filter((r) => !r.pinned && !filed(r))
|
||||||
|
}
|
||||||
|
const today = rest.filter((r) => isToday(r.updated_at))
|
||||||
|
const earlier = rest.filter((r) => !isToday(r.updated_at))
|
||||||
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
||||||
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
||||||
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
// The app's language and theme, as Settings chooses them.
|
||||||
|
//
|
||||||
|
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||||
|
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||||
|
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||||
|
// `t()` as it is needed, so a new language opens the window again.
|
||||||
|
//
|
||||||
|
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||||
|
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||||
|
|
||||||
|
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||||
|
import { terminalThemeChanged } from "./terminal.js"
|
||||||
|
|
||||||
|
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||||
|
let me = null // /me, as last read
|
||||||
|
|
||||||
|
function read(key) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || ""
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key, value) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value)
|
||||||
|
else localStorage.removeItem(key)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||||
|
function known(code) {
|
||||||
|
const c = String(code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[-_]/)[0]
|
||||||
|
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||||
|
export function accountThemes() {
|
||||||
|
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||||
|
export function localTheme() {
|
||||||
|
const name = read("lembas.theme")
|
||||||
|
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's theme, as the server says it. */
|
||||||
|
export function accountTheme() {
|
||||||
|
return me?.theme || "moria"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||||
|
export function applyTheme(name) {
|
||||||
|
name = localTheme() || name
|
||||||
|
if (!LOOKS.includes(name)) name = "moria"
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.theme = name
|
||||||
|
root.dataset.base = name
|
||||||
|
terminalThemeChanged()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||||
|
* it is now another, the window opens again in it.
|
||||||
|
*/
|
||||||
|
export function noteMe(next) {
|
||||||
|
me = next || null
|
||||||
|
paint()
|
||||||
|
if (!me || !me.language) return
|
||||||
|
const account = known(me.language)
|
||||||
|
const before = read("lembas.account-language")
|
||||||
|
write("lembas.account-language", account)
|
||||||
|
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||||
|
if (read("lembas.account-language") !== account) return
|
||||||
|
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||||
|
* server cannot be told, kept here ("" goes back to the account's).
|
||||||
|
*/
|
||||||
|
export async function chooseTheme(name) {
|
||||||
|
if (accountThemes()) {
|
||||||
|
const r = await patch("/me", { theme: name })
|
||||||
|
me = r && r.theme ? r : { ...me, theme: name }
|
||||||
|
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||||
|
paint()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||||
|
export function chosenLanguage() {
|
||||||
|
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||||
|
export function accountLanguage() {
|
||||||
|
return me?.language ? known(me.language) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose the language; the window opens again in it. */
|
||||||
|
export function chooseLanguage(choice) {
|
||||||
|
if (choice === chosenLanguage()) return
|
||||||
|
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||||
|
return reopen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the window again: the replies followed and the shells open let go first. */
|
||||||
|
async function reopen() {
|
||||||
|
await letGoAll()
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
+96
-20
@@ -10,19 +10,26 @@ import { renderBar, initBar } from "./titlebar.js"
|
|||||||
import { renderSetup, initSetup } from "./setup.js"
|
import { renderSetup, initSetup } from "./setup.js"
|
||||||
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||||
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
import { initExplorer, showLocal, showFilesTab, openServerFile } from "./explorer.js"
|
||||||
import { initList, refreshList, markCurrent } from "./list.js"
|
import { initList, refreshList, markCurrent } from "./list.js"
|
||||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||||
|
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
|
||||||
|
import { applyTheme, noteMe } from "./look.js"
|
||||||
import { initAttach, allowAttach } from "./attach.js"
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
import { initPalette, openPalette } from "./palette.js"
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
import { initAgent, link } from "./agent.js"
|
import { initAgent, link } from "./agent.js"
|
||||||
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||||
|
import { initArchived, showArchived } from "./archived.js"
|
||||||
|
import { showMemory } from "./memory.js"
|
||||||
|
import { initVoice, voiceFor } from "./voice.js"
|
||||||
|
import { canDraw, openDraw } from "./draw.js"
|
||||||
import { closePopover } from "./popup.js"
|
import { closePopover } from "./popup.js"
|
||||||
|
import { noteSubagentsCap } from "./models.js"
|
||||||
|
|
||||||
let status = null
|
let status = null
|
||||||
let view = "" // "setup" | "app"
|
let view = "" // "setup" | "app"
|
||||||
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "memory" | "settings"
|
||||||
let me = null
|
let me = null
|
||||||
let models = []
|
let models = []
|
||||||
let devices = [] // this account's linked devices
|
let devices = [] // this account's linked devices
|
||||||
@@ -61,13 +68,6 @@ function thisDevices() {
|
|||||||
return remembered.slice()
|
return remembered.slice()
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(name) {
|
|
||||||
if (name !== "moria" && name !== "shire") name = "moria"
|
|
||||||
const root = document.documentElement
|
|
||||||
root.dataset.theme = name
|
|
||||||
root.dataset.base = name
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Views ------------------------------------------------------------------------------------
|
// --- Views ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
@@ -95,8 +95,8 @@ async function startApp() {
|
|||||||
started = true
|
started = true
|
||||||
try {
|
try {
|
||||||
me = await get("/me")
|
me = await get("/me")
|
||||||
applyTheme(me.theme)
|
noteMe(me)
|
||||||
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
|
voiceFor(me)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("thread-note").textContent = why(err)
|
$("thread-note").textContent = why(err)
|
||||||
$("thread-note").hidden = false
|
$("thread-note").hidden = false
|
||||||
@@ -116,11 +116,17 @@ function stopApp() {
|
|||||||
closeViewer()
|
closeViewer()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModels() {
|
let modelsAt = 0 // when /models was last read
|
||||||
|
|
||||||
|
/** The account's models, with what a new chat on each is offered. `keep`: a failed read keeps the last list. */
|
||||||
|
async function loadModels({ keep = false } = {}) {
|
||||||
try {
|
try {
|
||||||
models = (await get("/models")).models || []
|
const r = await get("/models")
|
||||||
|
models = r.models || []
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
modelsAt = Date.now()
|
||||||
} catch {
|
} catch {
|
||||||
models = []
|
if (!keep) models = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +151,7 @@ function accountButton() {
|
|||||||
|
|
||||||
// --- The pages ----------------------------------------------------------------------------------
|
// --- The pages ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
/** Show one page in the main area: the chat, the files, the scheduled tasks, the archived chats, or the settings. */
|
||||||
function showPage(name) {
|
function showPage(name) {
|
||||||
page = name
|
page = name
|
||||||
closePopover()
|
closePopover()
|
||||||
@@ -153,9 +159,14 @@ function showPage(name) {
|
|||||||
$("main").hidden = name !== "chat"
|
$("main").hidden = name !== "chat"
|
||||||
$("page-files").hidden = name !== "files"
|
$("page-files").hidden = name !== "files"
|
||||||
$("page-scheduled").hidden = name !== "scheduled"
|
$("page-scheduled").hidden = name !== "scheduled"
|
||||||
|
$("page-archived").hidden = name !== "archived"
|
||||||
|
$("page-memory").hidden = name !== "memory"
|
||||||
$("settings").hidden = name !== "settings"
|
$("settings").hidden = name !== "settings"
|
||||||
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||||
if (name !== "chat") showPanel(false)
|
if (name !== "chat") {
|
||||||
|
showPanel(false)
|
||||||
|
showTerminal(false)
|
||||||
|
}
|
||||||
if (narrow()) sideOpen(false)
|
if (narrow()) sideOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +185,10 @@ function startNew(kind) {
|
|||||||
newChat(kind || draftKind())
|
newChat(kind || draftKind())
|
||||||
markCurrent()
|
markCurrent()
|
||||||
localPane()
|
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. */
|
/** Open a chat, from the list or the palette. */
|
||||||
@@ -193,6 +208,16 @@ function showSettings(on, section) {
|
|||||||
if (section) scrollToSection(section)
|
if (section) scrollToSection(section)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showArchivedPage() {
|
||||||
|
showPage("archived")
|
||||||
|
showArchived()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMemoryPage(focus) {
|
||||||
|
showPage("memory")
|
||||||
|
showMemory(focus || "")
|
||||||
|
}
|
||||||
|
|
||||||
function showFiles(which) {
|
function showFiles(which) {
|
||||||
showPage("files")
|
showPage("files")
|
||||||
if (which) showFilesTab(which)
|
if (which) showFilesTab(which)
|
||||||
@@ -207,9 +232,13 @@ function commands() {
|
|||||||
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||||
...scheduledCommands(),
|
...scheduledCommands(),
|
||||||
{ label: t("Files"), run: () => showFiles() },
|
{ label: t("Files"), run: () => showFiles() },
|
||||||
|
{ label: t("Archived"), run: showArchivedPage },
|
||||||
|
{ label: t("Memory"), run: () => showMemoryPage() },
|
||||||
{ label: t("Settings"), run: () => showSettings(true) },
|
{ label: t("Settings"), run: () => showSettings(true) },
|
||||||
]
|
]
|
||||||
|
if (canDraw(me)) out.push({ label: t("Draw a picture"), run: openDraw })
|
||||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||||
|
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||||
out.push(
|
out.push(
|
||||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
||||||
@@ -250,10 +279,16 @@ async function boot() {
|
|||||||
showChat(id)
|
showChat(id)
|
||||||
},
|
},
|
||||||
menu: (anchor, c) => chatMenu(anchor, c),
|
menu: (anchor, c) => chatMenu(anchor, c),
|
||||||
|
note: (text) => {
|
||||||
|
$("thread-note").textContent = text
|
||||||
|
$("thread-note").hidden = !text
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
initVoice()
|
||||||
initChat({
|
initChat({
|
||||||
status: () => status,
|
status: () => status,
|
||||||
thisDevices,
|
thisDevices,
|
||||||
|
devices: () => devices,
|
||||||
models: () => models,
|
models: () => models,
|
||||||
me: () => me,
|
me: () => me,
|
||||||
changed: () => refreshList(),
|
changed: () => refreshList(),
|
||||||
@@ -273,15 +308,38 @@ async function boot() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||||
initPalette({ commands, open: (c) => showChat(c.id) })
|
initArchived({
|
||||||
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
me: () => me,
|
||||||
|
open: (id) => showChat(id),
|
||||||
|
// Back in the sidebar: the list again.
|
||||||
|
changed: () => refreshList(),
|
||||||
|
})
|
||||||
|
initPalette({
|
||||||
|
commands,
|
||||||
|
open: (c) => showChat(c.id),
|
||||||
|
openFile: (path, name) => openServerFile(path, name),
|
||||||
|
openMemory: (id) => showMemoryPage(id),
|
||||||
|
})
|
||||||
|
initScheduled({ status: () => status, models: () => models, me: () => me, thisDevices, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||||
onViewer((on) => {
|
onViewer((on) => {
|
||||||
$("shell").dataset.viewer = String(on)
|
$("shell").dataset.viewer = String(on)
|
||||||
})
|
})
|
||||||
|
// 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({
|
initActivity({
|
||||||
onLayout: (on) => {
|
onLayout: (on) => {
|
||||||
$("shell").dataset.activity = String(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").prepend(icon(ICONS.plus))
|
||||||
@@ -296,6 +354,14 @@ async function boot() {
|
|||||||
})
|
})
|
||||||
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||||
$("nav-files").addEventListener("click", () => showFiles())
|
$("nav-files").addEventListener("click", () => showFiles())
|
||||||
|
$("nav-archived").querySelector(".navrow__icon").replaceChildren(icon(ICONS.archive))
|
||||||
|
$("nav-archived").addEventListener("click", showArchivedPage)
|
||||||
|
$("archived-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
|
$("archived-refresh").addEventListener("click", () => showArchived())
|
||||||
|
$("nav-memory").querySelector(".navrow__icon").replaceChildren(icon(ICONS.bulb))
|
||||||
|
$("nav-memory").addEventListener("click", () => showMemoryPage())
|
||||||
|
$("memory-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
|
$("memory-refresh").addEventListener("click", () => showMemory())
|
||||||
accountButton()
|
accountButton()
|
||||||
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||||
@@ -339,11 +405,21 @@ async function boot() {
|
|||||||
renderBar(status)
|
renderBar(status)
|
||||||
route()
|
route()
|
||||||
// The agent came up or went: which devices are linked changes with it.
|
// The agent came up or went: which devices are linked changes with it.
|
||||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
|
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(() => (refreshList(), statusChanged()))
|
||||||
})
|
})
|
||||||
listen("open-settings", () => {
|
listen("open-settings", () => {
|
||||||
if (view === "app") showSettings(true)
|
if (view === "app") showSettings(true)
|
||||||
})
|
})
|
||||||
|
// A notification clicked: the chat it told of, or the scheduled task whose run it was.
|
||||||
|
listen("open-chat", (e) => {
|
||||||
|
if (view === "app" && typeof e.payload === "string") showChat(e.payload)
|
||||||
|
})
|
||||||
|
listen("open-schedule", (e) => {
|
||||||
|
const id = e.payload?.schedule
|
||||||
|
if (view !== "app" || typeof id !== "string") return
|
||||||
|
showPage("scheduled")
|
||||||
|
showScheduled(id)
|
||||||
|
})
|
||||||
status = await invoke("get_status")
|
status = await invoke("get_status")
|
||||||
applyTheme(status.theme)
|
applyTheme(status.theme)
|
||||||
renderBar(status)
|
renderBar(status)
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
// Memory: what the account's models remember about the person, as the web's Settings → Memory
|
||||||
|
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
|
||||||
|
// group chosen (where there is more than one).
|
||||||
|
//
|
||||||
|
// GET /memories { memories: [memory], groups: [{ id, name }] }
|
||||||
|
// POST /memories { content, data_group_id? } -> { memory }
|
||||||
|
// PATCH /memories/{id} { content } -> { memory }
|
||||||
|
// DELETE /memories/{id} 204
|
||||||
|
//
|
||||||
|
// A record may be a model's words (`author` other than "user"): set as text, never markup.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { get, post, patch, del, why } from "./bridge.js"
|
||||||
|
import { ask, menu } from "./popup.js"
|
||||||
|
|
||||||
|
let rows = []
|
||||||
|
let groups = []
|
||||||
|
let error = ""
|
||||||
|
let loading = false
|
||||||
|
let editing = "" // the record being changed
|
||||||
|
let mark = "" // the record to bring into view (from a search hit)
|
||||||
|
|
||||||
|
/** The page, read again from the server; `focus` a record to bring into view. */
|
||||||
|
export async function showMemory(focus = "") {
|
||||||
|
mark = focus
|
||||||
|
loading = true
|
||||||
|
error = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
const r = await get("/memories")
|
||||||
|
rows = r.memories || []
|
||||||
|
groups = r.groups || []
|
||||||
|
} catch (err) {
|
||||||
|
rows = []
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(form) {
|
||||||
|
const text = form.querySelector("textarea")
|
||||||
|
const content = text.value.trim()
|
||||||
|
if (!content) return text.focus()
|
||||||
|
const group = form.querySelector("select")?.value
|
||||||
|
form.querySelector("button[type=submit]").disabled = true
|
||||||
|
try {
|
||||||
|
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
|
||||||
|
if (r?.memory) rows = [r.memory, ...rows]
|
||||||
|
error = ""
|
||||||
|
text.value = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
$("memory-new")?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(m, content) {
|
||||||
|
content = content.trim()
|
||||||
|
if (!content || content === m.content) {
|
||||||
|
editing = ""
|
||||||
|
return render()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
|
||||||
|
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
|
||||||
|
editing = ""
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(m) {
|
||||||
|
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/memories/${encodeURIComponent(m.id)}`)
|
||||||
|
rows = rows.filter((x) => x.id !== m.id)
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(m) {
|
||||||
|
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
|
||||||
|
if (editing === m.id) {
|
||||||
|
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
|
||||||
|
box.value = m.content
|
||||||
|
const done = () => save(m, box.value)
|
||||||
|
box.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation()
|
||||||
|
editing = ""
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
|
||||||
|
})
|
||||||
|
queueMicrotask(() => box.focus())
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: "mrow is-editing", dataset: { id: m.id } },
|
||||||
|
box,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "set__buttons" },
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
|
||||||
|
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
|
||||||
|
icon(ICONS.more),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const box = clear($("memory-body"))
|
||||||
|
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{
|
||||||
|
class: "mnew",
|
||||||
|
onsubmit: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
add(e.currentTarget)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "mnew__foot" },
|
||||||
|
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
|
||||||
|
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
form.querySelector("textarea").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
parts.push(form)
|
||||||
|
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||||
|
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
|
||||||
|
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
|
||||||
|
box.append(h("div", { class: "arch__page" }, parts))
|
||||||
|
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
|
||||||
|
}
|
||||||
+93
-42
@@ -123,7 +123,7 @@ export function modelPicker({ id, models, value, onChange, label }) {
|
|||||||
return btn
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
|
// --- 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:
|
// 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
|
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||||
@@ -193,69 +193,120 @@ export function effortControl({ model, value, onChange, id, compact }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs
|
* Whether a new chat on `model` is offered subagents: the model's `subagents.offered` in `/models`
|
||||||
* on its device), a model that calls tools and serves two requests at once (`parallel` null is a
|
* (server 2.0.0-beta.7: subagents on for the model and the instance, and at least one model could
|
||||||
* hosted API with no stated limit). The server decides again; this only keeps the fields away
|
* serve as one -- the model itself with room for two requests at once, or a usable model outside
|
||||||
* where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
|
* 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) {
|
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)
|
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 team's size: the fewest and the most agents, the chat's own agent included, as two number
|
* The agents a chat may run, its own agent counted: one range from 1 to `cap` with two handles,
|
||||||
* fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's
|
* Min and Max -- two range inputs overlaid on one track, the span between them filled. Each is a
|
||||||
* default for new chats then stands); `onChange({ min, max })` once a field is changed, with the
|
* real slider (arrow keys, Home/End, Page Up/Down), and Min never passes Max. `value` is
|
||||||
* empty one left out. The server checks the pair and says the bounds when it refuses one.
|
* { 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, compact }) {
|
export function agentsControl({ value, onChange, id, cap = SUBAGENTS_CAP }) {
|
||||||
const field = (which, v, label) =>
|
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", {
|
h("input", {
|
||||||
type: "number",
|
type: "range",
|
||||||
class: "agents__input",
|
class: `range2__input range2__input--${which}`,
|
||||||
id: which === "min" ? id : null,
|
id: which === "min" ? id : null,
|
||||||
min: "1",
|
min: "1",
|
||||||
|
max: String(top),
|
||||||
step: "1",
|
step: "1",
|
||||||
inputmode: "numeric",
|
|
||||||
value: v === undefined || v === null ? "" : String(v),
|
|
||||||
placeholder: compact ? null : t("default"),
|
|
||||||
"aria-label": label,
|
"aria-label": label,
|
||||||
title: label,
|
title: label,
|
||||||
})
|
})
|
||||||
const low = field("min", value?.min, t("Fewest"))
|
const low = handle("min", t("Min"))
|
||||||
const high = field("max", value?.max, t("Most"))
|
const high = handle("max", t("Max"))
|
||||||
const bounds = () => {
|
const said = h("output", { class: "range2__said", "aria-live": "polite", for: id || null })
|
||||||
high.min = low.value || "1"
|
const fill = h("span", { class: "range2__fill", "aria-hidden": "true" })
|
||||||
if (high.value) low.max = high.value
|
const box = h("span", { class: "range2__box" }, h("span", { class: "range2__track", "aria-hidden": "true" }, fill), low, high)
|
||||||
else low.removeAttribute("max")
|
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}`
|
||||||
}
|
}
|
||||||
bounds()
|
const set = (v) => {
|
||||||
const read = () => {
|
const hi = clamp(v?.max, Math.min(AGENTS_DEFAULT.max, top))
|
||||||
const out = {}
|
const lo = Math.min(hi, clamp(v?.min, AGENTS_DEFAULT.min))
|
||||||
if (low.value) out.min = Number(low.value)
|
high.value = String(hi)
|
||||||
if (high.value) out.max = Number(high.value)
|
low.value = String(lo)
|
||||||
return out
|
draw()
|
||||||
}
|
|
||||||
for (const input of [low, high]) {
|
|
||||||
input.addEventListener("change", () => {
|
|
||||||
bounds()
|
|
||||||
onChange?.(read())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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(
|
const el = h(
|
||||||
"span",
|
"span",
|
||||||
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Agents"), title: t("Its own agent included. Above one, it can add members that do the work while it leads them.") },
|
{ class: "range2", role: "group", "aria-label": t("Subagents") },
|
||||||
compact ? h("span", { class: "agents__label", text: t("Agents") }) : null,
|
said,
|
||||||
low,
|
box,
|
||||||
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
|
h("span", { class: "range2__ends", "aria-hidden": "true" }, h("span", { text: "1" }), h("span", { text: String(top) })),
|
||||||
high,
|
|
||||||
)
|
)
|
||||||
Object.defineProperty(el, "value", {
|
Object.defineProperty(el, "value", {
|
||||||
get: read,
|
get: read,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
low.value = v?.min ?? ""
|
given = v || {}
|
||||||
high.value = v?.max ?? ""
|
touched = false
|
||||||
bounds()
|
set(given)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return el
|
return el
|
||||||
|
|||||||
+70
-8
@@ -1,12 +1,16 @@
|
|||||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
||||||
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
|
// the chats of both kinds by title, newest first, and below them what the server's search finds
|
||||||
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
|
// inside them, in the file library and in memory (`GET /search`, once two letters are typed and
|
||||||
// is there.
|
// the typing pauses). Up and Down move, Enter does it, Escape closes. The chats are asked of the
|
||||||
|
// server each time it opens, so a chat started elsewhere is there.
|
||||||
|
//
|
||||||
|
// A hit's snippet is plain text with the ranges that matched (`marks`): drawn as text, the
|
||||||
|
// ranges as <mark> elements. Titles and snippets may be a model's words; nothing is markup.
|
||||||
|
|
||||||
import { h, clear, icon, ICONS, when } from "./dom.js"
|
import { h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
import { get } from "./bridge.js"
|
import { get } from "./bridge.js"
|
||||||
|
|
||||||
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
|
let ctx = null // { commands(): [{ label, hint?, run }], open(chat), openFile(path, name), openMemory(id) }
|
||||||
let dlg = null
|
let dlg = null
|
||||||
|
|
||||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||||
@@ -17,6 +21,22 @@ function fold(text) {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `text` with the `[start, end)` ranges of `marks` as <mark> elements: text nodes only. */
|
||||||
|
function marked(text, marks) {
|
||||||
|
const el = h("span", { class: "palette__snippet" })
|
||||||
|
let at = 0
|
||||||
|
const chars = [...String(text || "")]
|
||||||
|
for (const [a, b] of (marks || []).filter((m) => Array.isArray(m) && m[1] > m[0]).sort((x, y) => x[0] - y[0])) {
|
||||||
|
if (a < at) continue
|
||||||
|
el.append(chars.slice(at, a).join(""), h("mark", { text: chars.slice(a, b).join("") }))
|
||||||
|
at = b
|
||||||
|
}
|
||||||
|
el.append(chars.slice(at).join(""))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_KIND = { chats: () => t("Chats"), files: () => t("Files"), memories: () => t("Memory") }
|
||||||
|
|
||||||
function matches(text, words) {
|
function matches(text, words) {
|
||||||
const f = fold(text)
|
const f = fold(text)
|
||||||
return words.every((w) => f.includes(w))
|
return words.every((w) => f.includes(w))
|
||||||
@@ -31,7 +51,11 @@ export async function openPalette() {
|
|||||||
let chats = []
|
let chats = []
|
||||||
let shown = []
|
let shown = []
|
||||||
let at = 0
|
let at = 0
|
||||||
|
let found = [] // the server's hits for `asked`
|
||||||
|
let asked = ""
|
||||||
|
let timer = 0
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
if (!dlg) return
|
if (!dlg) return
|
||||||
dlg.close()
|
dlg.close()
|
||||||
dlg.remove()
|
dlg.remove()
|
||||||
@@ -44,16 +68,32 @@ export async function openPalette() {
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
||||||
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
||||||
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
const titled = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
||||||
shown = [
|
shown = [
|
||||||
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
|
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
|
||||||
...found.map((c) => ({
|
...titled.map((c) => ({
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
label: c.title || t("New chat"),
|
label: c.title || t("New chat"),
|
||||||
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
|
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
|
||||||
run: () => ctx.open(c),
|
run: () => ctx.open(c),
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
// What the server found inside: the chats already listed by their title once only.
|
||||||
|
const listed = new Set(titled.map((c) => c.id))
|
||||||
|
for (const group of input.value.trim() === asked ? found : []) {
|
||||||
|
for (const hit of group.hits || []) {
|
||||||
|
if (hit.kind === "chat" && listed.has(hit.id)) continue
|
||||||
|
const kind = { chat: "found-chat", file: "found-file", memory: "found-memory" }[hit.kind]
|
||||||
|
if (!kind) continue
|
||||||
|
shown.push({
|
||||||
|
kind,
|
||||||
|
label: hit.title || (hit.kind === "chat" ? t("New chat") : hit.kind === "file" ? String(hit.path || "").split("/").pop() : t("Memory")),
|
||||||
|
snippet: hit.snippet ? marked(hit.snippet, hit.marks) : null,
|
||||||
|
hint: SEARCH_KIND[group.kind]?.() || "",
|
||||||
|
run: () => (hit.kind === "chat" ? ctx.open({ id: hit.id, kind: hit.agent ? "agent" : "chat" }) : hit.kind === "file" ? ctx.openFile(hit.path, hit.title) : ctx.openMemory(hit.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
at = Math.min(at, Math.max(0, shown.length - 1))
|
at = Math.min(at, Math.max(0, shown.length - 1))
|
||||||
clear(list)
|
clear(list)
|
||||||
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
|
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
|
||||||
@@ -74,8 +114,10 @@ export async function openPalette() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "found-memory" ? ICONS.bulb : item.kind.startsWith("found") ? ICONS.search : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||||
h("span", { class: "palette__label", text: item.label }),
|
item.snippet
|
||||||
|
? h("span", { class: "palette__label palette__label--two" }, h("span", { class: "palette__title", text: item.label }), item.snippet)
|
||||||
|
: h("span", { class: "palette__label", text: item.label }),
|
||||||
h("span", { class: "palette__hint", text: item.hint }),
|
h("span", { class: "palette__hint", text: item.hint }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -87,9 +129,29 @@ export async function openPalette() {
|
|||||||
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
||||||
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
||||||
}
|
}
|
||||||
|
// The server's search, once the typing pauses: an older server without it finds nothing more.
|
||||||
|
const search = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
const q = input.value.trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
found = []
|
||||||
|
asked = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r = await get(`/search?q=${encodeURIComponent(q)}&in=all&limit=8`)
|
||||||
|
if (!dlg || input.value.trim() !== q) return
|
||||||
|
found = r.results || []
|
||||||
|
asked = q
|
||||||
|
draw()
|
||||||
|
} catch {}
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
at = 0
|
at = 0
|
||||||
draw()
|
draw()
|
||||||
|
search()
|
||||||
})
|
})
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
|||||||
+4
-3
@@ -199,7 +199,8 @@ function escPop(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
|
* A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's
|
||||||
|
* point; or above it, `up`), holding `content` (an element, or a function
|
||||||
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
||||||
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
||||||
*/
|
*/
|
||||||
@@ -211,7 +212,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
|||||||
draw()
|
draw()
|
||||||
document.body.append(el)
|
document.body.append(el)
|
||||||
const place = () => {
|
const place = () => {
|
||||||
const at = anchor.getBoundingClientRect()
|
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y }
|
||||||
const r = el.getBoundingClientRect()
|
const r = el.getBoundingClientRect()
|
||||||
const left = align === "end" ? at.right - r.width : at.left
|
const left = align === "end" ? at.right - r.width : at.left
|
||||||
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
||||||
@@ -219,7 +220,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
|
|||||||
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||||
}
|
}
|
||||||
place()
|
place()
|
||||||
openPop = { el, back: anchor, onClose }
|
openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose }
|
||||||
document.addEventListener("pointerdown", outsidePop, true)
|
document.addEventListener("pointerdown", outsidePop, true)
|
||||||
document.addEventListener("keydown", escPop, true)
|
document.addEventListener("keydown", escPop, true)
|
||||||
el.querySelector("button, input, select")?.focus()
|
el.querySelector("button, input, select")?.focus()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
+107
-9
@@ -27,6 +27,15 @@
|
|||||||
// by the app (Rust, watch.rs), handed here as the
|
// by the app (Rust, watch.rs), handed here as the
|
||||||
// `schedules` event
|
// `schedules` event
|
||||||
//
|
//
|
||||||
|
// GET /devices { devices: [{ id, name, host, scheduled, max_mode }] }:
|
||||||
|
// the linked computers, each saying whether it
|
||||||
|
// accepts scheduled work
|
||||||
|
//
|
||||||
|
// Where it runs (`runs_on`, server 2.0.0-beta.9): null on the server, else `{ device, device_name,
|
||||||
|
// cwd, mode, approval_wait_minutes }` -- an agent chat on that linked computer, in that folder and
|
||||||
|
// mode (never Plan). A run waiting for the computer says `waiting_for: "device"`; one it refused
|
||||||
|
// (it does not accept scheduled work) fails with the computer's own words.
|
||||||
|
//
|
||||||
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
||||||
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
||||||
// model's words, set as text; a result's Markdown goes through markdown.js.
|
// model's words, set as text; a result's Markdown goes through markdown.js.
|
||||||
@@ -37,7 +46,7 @@ import { ask, menu, popover } from "./popup.js"
|
|||||||
import { avatar, levelsOf, levelWord } from "./models.js"
|
import { avatar, levelsOf, levelWord } from "./models.js"
|
||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
|
|
||||||
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
let ctx = null // { status(), models(), me(), thisDevices(), openChat(id), show() }
|
||||||
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
||||||
let tasks = []
|
let tasks = []
|
||||||
let missing = false // the server has no scheduling routes
|
let missing = false // the server has no scheduling routes
|
||||||
@@ -83,7 +92,7 @@ const RUNS_PAGE = 20
|
|||||||
function statusChip(run, running) {
|
function statusChip(run, running) {
|
||||||
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
||||||
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||||
const word = STATUS[run.status]?.() || run.status
|
const word = run.status === "waiting" && run.waiting_for === "device" ? t("Waiting for the computer") : STATUS[run.status]?.() || run.status
|
||||||
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
||||||
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
||||||
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
||||||
@@ -326,7 +335,8 @@ async function drawTask(id) {
|
|||||||
icon(ICONS.more),
|
icon(ICONS.more),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const models = (await schedulingModels())?.models || ctx.models()
|
const [smodels, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||||
|
const models = smodels?.models || ctx.models()
|
||||||
const body = clear($("scheduled-body"))
|
const body = clear($("scheduled-body"))
|
||||||
body.append(
|
body.append(
|
||||||
h(
|
h(
|
||||||
@@ -341,9 +351,10 @@ async function drawTask(id) {
|
|||||||
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
||||||
fact(t("Model"), modelWords(models, task.model)),
|
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("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
||||||
fact(t("Agents"), h("span", { text: task.agents ? t("A team of up to %(n)s", { n: task.agents }) : t("No team") })),
|
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||||
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||||
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||||
|
"runs_on" in task ? whereFact(task.runs_on, devices) : null,
|
||||||
),
|
),
|
||||||
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
||||||
upcoming(task.next_runs || []),
|
upcoming(task.next_runs || []),
|
||||||
@@ -352,6 +363,46 @@ async function drawTask(id) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Where it runs ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RUN_MODES = {
|
||||||
|
manual: () => t("Manual — asks first"),
|
||||||
|
edit: () => t("Edit — asks before commands"),
|
||||||
|
auto: () => t("Auto — asks nothing"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's linked computers (`GET /devices`), or null where the server cannot say. */
|
||||||
|
async function linkedDevices() {
|
||||||
|
try {
|
||||||
|
return (await get("/devices")).devices || []
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(d) {
|
||||||
|
const name = d.name || d.host || d.id
|
||||||
|
return (ctx.thisDevices?.() || []).includes(d.id) ? t("This computer (%(name)s)", { name }) : name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Where it runs": the server, or a computer and a folder, with what it is doing about it now. */
|
||||||
|
function whereFact(on, devices) {
|
||||||
|
if (!on) return fact(t("Where it runs"), h("span", { text: t("On this server") }))
|
||||||
|
const d = (devices || []).find((x) => x.id === on.device)
|
||||||
|
const name = d ? deviceLabel(d) : on.device_name || t("A linked computer")
|
||||||
|
let state = ""
|
||||||
|
if (devices && !d) state = t("Away now: a run waits for it, then counts as missed.")
|
||||||
|
else if (d && d.scheduled === false) state = t("It does not accept scheduled work: turn on Accept scheduled work there.")
|
||||||
|
const hint = [RUN_MODES[on.mode]?.() || on.mode, t("approvals wait %(n)s min", { n: on.approval_wait_minutes }), state].filter(Boolean).join(" · ")
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ class: `fact${state ? " is-warning" : ""}` },
|
||||||
|
h("span", { class: "fact__label", text: t("Where it runs") }),
|
||||||
|
h("span", { class: "fact__value" }, h("bdi", { text: name }), h("span", { class: "fact__path", title: on.cwd }, h("bdi", { text: on.cwd }))),
|
||||||
|
h("span", { class: "fact__hint", text: hint }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function fact(label, value, hint) {
|
function fact(label, value, hint) {
|
||||||
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
||||||
}
|
}
|
||||||
@@ -448,7 +499,7 @@ function runRow(task, run) {
|
|||||||
h("span", { text: stamp(run.started_at || run.due_at) }),
|
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||||
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||||
),
|
),
|
||||||
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || (task.runs_on ? task.runs_on.device_name || "" : "") }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||||
open || h("span", { class: "run__error", title: note, text: note }),
|
open || h("span", { class: "run__error", title: note, text: note }),
|
||||||
detail,
|
detail,
|
||||||
)
|
)
|
||||||
@@ -488,7 +539,10 @@ async function drawForm(task) {
|
|||||||
const editing = !!task
|
const editing = !!task
|
||||||
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
||||||
clear($("scheduled-tools"))
|
clear($("scheduled-tools"))
|
||||||
const smodels = (await schedulingModels())?.models || []
|
const [sm, devices] = await Promise.all([schedulingModels(), linkedDevices()])
|
||||||
|
const smodels = sm?.models || []
|
||||||
|
// Offered where the server can run a schedule on a computer: its devices say whether they accept.
|
||||||
|
const canPlace = !!devices && (devices.some((d) => "scheduled" in d) || !!task?.runs_on)
|
||||||
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
||||||
const draft = {
|
const draft = {
|
||||||
request: task?.request || "",
|
request: task?.request || "",
|
||||||
@@ -501,6 +555,7 @@ async function drawForm(task) {
|
|||||||
agents: task?.agents || null,
|
agents: task?.agents || null,
|
||||||
notify: task?.notify || "matters",
|
notify: task?.notify || "matters",
|
||||||
mode: task?.mode || "fresh",
|
mode: task?.mode || "fresh",
|
||||||
|
runs_on: task?.runs_on ? { device: task.runs_on.device, cwd: task.runs_on.cwd, mode: task.runs_on.mode, approval_wait_minutes: task.runs_on.approval_wait_minutes } : null,
|
||||||
}
|
}
|
||||||
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
||||||
|
|
||||||
@@ -555,6 +610,47 @@ async function drawForm(task) {
|
|||||||
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where it runs: the server, or a linked computer that accepts scheduled work, its folder and mode. */
|
||||||
|
const whereFields = () => {
|
||||||
|
const on = draft.runs_on
|
||||||
|
const listed = devices || []
|
||||||
|
const where = h(
|
||||||
|
"select",
|
||||||
|
{
|
||||||
|
id: "sched-where",
|
||||||
|
onchange: (e) => {
|
||||||
|
const id = e.target.value
|
||||||
|
if (!id) draft.runs_on = null
|
||||||
|
else {
|
||||||
|
const mine = (ctx.thisDevices?.() || []).includes(id)
|
||||||
|
const roots = ctx.status?.()?.remote?.roots || []
|
||||||
|
draft.runs_on = { device: id, cwd: draft.runs_on?.cwd || (mine ? roots[0] || "" : ""), mode: draft.runs_on?.mode || "edit", approval_wait_minutes: draft.runs_on?.approval_wait_minutes || 30 }
|
||||||
|
}
|
||||||
|
drawSettings()
|
||||||
|
settings.querySelector(draft.runs_on ? "#sched-cwd" : "#sched-where")?.focus()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("option", { value: "", text: t("On this server"), selected: !on }),
|
||||||
|
listed.map((d) => h("option", { value: d.id, text: d.scheduled === false ? `${deviceLabel(d)} — ${t("does not accept scheduled work")}` : deviceLabel(d), disabled: d.scheduled === false && on?.device !== d.id, selected: on?.device === d.id })),
|
||||||
|
on && !listed.some((d) => d.id === on.device) ? h("option", { value: on.device, text: `${task?.runs_on?.device_name || on.device} — ${t("not linked now")}`, selected: true }) : null,
|
||||||
|
)
|
||||||
|
const out = [field(t("Where it runs"), where, on ? t("A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).") : "", "sched-where")]
|
||||||
|
if (!on) return out
|
||||||
|
const cwd = h("input", { type: "text", id: "sched-cwd", value: on.cwd, placeholder: "/home/…", spellcheck: "false", oninput: (e) => (on.cwd = e.target.value.trim()) })
|
||||||
|
const mode = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-runmode", onchange: (e) => (on.mode = e.target.value) },
|
||||||
|
Object.entries(RUN_MODES).map(([v, word]) => h("option", { value: v, text: word(), selected: on.mode === v })),
|
||||||
|
)
|
||||||
|
const wait = h("input", { type: "number", id: "sched-wait", min: "1", max: "1440", step: "1", value: String(on.approval_wait_minutes || 30), oninput: (e) => (on.approval_wait_minutes = Number(e.target.value) || 30) })
|
||||||
|
out.push(
|
||||||
|
field(t("Folder on the computer"), cwd, t("A full path, inside a folder the computer allows remote work in."), "sched-cwd"),
|
||||||
|
field(t("Mode"), mode, t("Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it."), "sched-runmode"),
|
||||||
|
field(t("An approval waits (minutes)"), wait, t("A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied."), "sched-wait"),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
const drawSettings = () => {
|
const drawSettings = () => {
|
||||||
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
||||||
const select = (id, table, key, onChange) =>
|
const select = (id, table, key, onChange) =>
|
||||||
@@ -582,15 +678,16 @@ async function drawForm(task) {
|
|||||||
const agents = h(
|
const agents = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
||||||
h("option", { value: "", text: t("No team"), selected: !draft.agents }),
|
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("A team of up to %(n)s", { n }), selected: draft.agents === n })),
|
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||||
)
|
)
|
||||||
settings.replaceChildren(
|
settings.replaceChildren(
|
||||||
|
...(canPlace ? whereFields() : []),
|
||||||
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||||
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||||
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||||
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
||||||
field(t("Agents"), agents, t("A team works on it together, its own agent included."), "sched-agents"),
|
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)
|
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
||||||
}
|
}
|
||||||
@@ -656,6 +753,7 @@ async function drawForm(task) {
|
|||||||
notify: draft.notify,
|
notify: draft.notify,
|
||||||
mode: draft.mode,
|
mode: draft.mode,
|
||||||
}
|
}
|
||||||
|
if (canPlace) body.runs_on = draft.runs_on ? { ...draft.runs_on } : null
|
||||||
try {
|
try {
|
||||||
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
||||||
go({ name: "task", id: r.schedule.id })
|
go({ name: "task", id: r.schedule.id })
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
|
||||||
|
// the server offers this chat now (web search among them) and the skills, each on or off for this
|
||||||
|
// chat only, and the commands it was told to stop asking about.
|
||||||
|
//
|
||||||
|
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
|
||||||
|
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
|
||||||
|
// POST /chats/{chat}/allow/clear -> { scope }
|
||||||
|
//
|
||||||
|
// Narrowing only, on the server: switching on what the model, the permissions or the instance
|
||||||
|
// left out reaches nothing. Labels, skill names and descriptions are set as text.
|
||||||
|
|
||||||
|
import { h } from "./dom.js"
|
||||||
|
import { get, post, why } from "./bridge.js"
|
||||||
|
import { popover } from "./popup.js"
|
||||||
|
|
||||||
|
/** The switches of `chat`, in a popover under `anchor`. */
|
||||||
|
export async function scopePopover(anchor, chat) {
|
||||||
|
const path = `/chats/${encodeURIComponent(chat.id)}`
|
||||||
|
let scope = null
|
||||||
|
let error = ""
|
||||||
|
let busy = false
|
||||||
|
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
|
||||||
|
|
||||||
|
// Drawn again after each answer: the switch that was used keeps the focus.
|
||||||
|
const redraw = (key) => {
|
||||||
|
pop.redraw()
|
||||||
|
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
|
||||||
|
}
|
||||||
|
const send = async (url, body) => {
|
||||||
|
const key = document.activeElement?.dataset?.key || ""
|
||||||
|
busy = true
|
||||||
|
redraw(key)
|
||||||
|
try {
|
||||||
|
scope = (await post(url, body))?.scope || scope
|
||||||
|
error = ""
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
redraw(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (kind, name, label, on) =>
|
||||||
|
h(
|
||||||
|
"label",
|
||||||
|
{ class: "check scope__row", title: label.title || null },
|
||||||
|
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
|
||||||
|
h("span", { text: label.text }),
|
||||||
|
)
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const body = h("div", { class: "pop__body scope" })
|
||||||
|
if (!scope) {
|
||||||
|
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
const families = scope.families || []
|
||||||
|
const skills = scope.skills || []
|
||||||
|
const allow = scope.allow || []
|
||||||
|
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
|
||||||
|
if (families.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Tools") }),
|
||||||
|
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (skills.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Skills") }),
|
||||||
|
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allow.length) {
|
||||||
|
body.append(
|
||||||
|
h("p", { class: "pop__title", text: t("Always allowed here") }),
|
||||||
|
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
|
||||||
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
|
||||||
|
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
|
||||||
|
} catch (err) {
|
||||||
|
error = why(err)
|
||||||
|
}
|
||||||
|
pop.redraw()
|
||||||
|
}
|
||||||
+123
-2
@@ -1,10 +1,13 @@
|
|||||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||||
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
||||||
// sign-in), the agent and the folders it may work in, notifications, and this release.
|
// sign-in), the agent and the folders it may work in, the language and theme, notifications, and
|
||||||
|
// this release.
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen } from "./bridge.js"
|
import { invoke, listen, get, openLink } from "./bridge.js"
|
||||||
import { agentBlock, onAgentChange, link } from "./agent.js"
|
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||||
|
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
|
||||||
|
import { why } from "./bridge.js"
|
||||||
|
|
||||||
let folderError = ""
|
let folderError = ""
|
||||||
let current = () => null
|
let current = () => null
|
||||||
@@ -13,6 +16,7 @@ const SECTIONS = [
|
|||||||
["account", ICONS.user, () => t("Account")],
|
["account", ICONS.user, () => t("Account")],
|
||||||
["agent", ICONS.terminal, () => t("Agent")],
|
["agent", ICONS.terminal, () => t("Agent")],
|
||||||
["folders", ICONS.folder, () => t("Project folders")],
|
["folders", ICONS.folder, () => t("Project folders")],
|
||||||
|
["look", ICONS.contrast, () => t("Appearance")],
|
||||||
["notify", ICONS.bell, () => t("Notifications")],
|
["notify", ICONS.bell, () => t("Notifications")],
|
||||||
["about", ICONS.info, () => t("About")],
|
["about", ICONS.info, () => t("About")],
|
||||||
]
|
]
|
||||||
@@ -118,6 +122,8 @@ export function renderSettings(s) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
box.append(lookSection())
|
||||||
|
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"notify",
|
"notify",
|
||||||
@@ -130,6 +136,7 @@ export function renderSettings(s) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const advised = recommendation(s)
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"about",
|
"about",
|
||||||
@@ -139,8 +146,122 @@ export function renderSettings(s) {
|
|||||||
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||||
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||||
),
|
),
|
||||||
|
advised
|
||||||
|
? line(
|
||||||
|
t("Your server recommends LLeMbas Desktop %(version)s", { version: advised.version }),
|
||||||
|
t("The release that goes with the server's version. This is %(current)s.", { current: s.version }),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Open the release page"), onclick: () => openLink(advised.page) }),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (s.account && appsFor !== s.server) askApps(s.server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The release the server recommends (GET /apps) --------------------------------------------------
|
||||||
|
//
|
||||||
|
// GET /apps { server: { version, channel }, apps: { desktop: { version, tag, channel, page } | null } }
|
||||||
|
//
|
||||||
|
// Shown when it is newer than this app. A stable install is never offered a beta; a beta install
|
||||||
|
// may be. Only the project's own release pages are opened.
|
||||||
|
|
||||||
|
let apps = null // the server's recommendation; null: none (an older server, or nothing newer)
|
||||||
|
let appsFor = null // the server it was asked of
|
||||||
|
const RELEASES = "https://github.com/LLeMbas/LLeMbas-Desktop/releases/tag/"
|
||||||
|
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/
|
||||||
|
|
||||||
|
/** A sortable key for `X.Y.Z` / `X.Y.Z-beta.N` (a release above its betas); null for anything else. */
|
||||||
|
export function versionKey(text) {
|
||||||
|
const m = VERSION.exec(String(text || "").trim())
|
||||||
|
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? Infinity : +m[4]] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function newer(a, b) {
|
||||||
|
for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] > b[i]
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askApps(server) {
|
||||||
|
appsFor = server
|
||||||
|
apps = null
|
||||||
|
try {
|
||||||
|
const r = (await get("/apps"))?.apps?.desktop || null
|
||||||
|
if (appsFor === server) apps = r
|
||||||
|
} catch {
|
||||||
|
apps = null
|
||||||
|
}
|
||||||
|
if (!$("settings").hidden) renderSettings(current())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recommended release, where it is newer than this one and on a channel this install takes. */
|
||||||
|
function recommendation(s) {
|
||||||
|
if (!apps) return null
|
||||||
|
const mine = versionKey(s.version)
|
||||||
|
const theirs = versionKey(apps.version)
|
||||||
|
if (!mine || !theirs) return null
|
||||||
|
if (theirs[3] !== Infinity && mine[3] === Infinity) return null
|
||||||
|
if (!newer(theirs, mine)) return null
|
||||||
|
const page = String(apps.page || "")
|
||||||
|
if (!page.startsWith(RELEASES) || !/^[A-Za-z0-9._-]+$/.test(page.slice(RELEASES.length))) return null
|
||||||
|
return { version: apps.version, page }
|
||||||
|
}
|
||||||
|
|
||||||
|
let lookError = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language and the theme. The language: the account's (the default), this computer's, or one
|
||||||
|
* the app has, kept here. The theme: the account's, saved to it; kept here where the server
|
||||||
|
* cannot be told.
|
||||||
|
*/
|
||||||
|
function lookSection() {
|
||||||
|
const named = (code) => LEMBAS_LANGS[code] || code
|
||||||
|
const account = accountLanguage()
|
||||||
|
const languages = [
|
||||||
|
["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })],
|
||||||
|
["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })],
|
||||||
|
...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]),
|
||||||
|
]
|
||||||
|
const language = h(
|
||||||
|
"select",
|
||||||
|
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
|
||||||
|
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
|
||||||
|
)
|
||||||
|
const scheme = (s) => (s === "light" ? t("light") : t("dark"))
|
||||||
|
const offered = accountThemes()
|
||||||
|
const themes = offered
|
||||||
|
? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`])
|
||||||
|
: [
|
||||||
|
["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })],
|
||||||
|
["moria", `Moria — ${t("dark")}`],
|
||||||
|
["shire", `Shire — ${t("light")}`],
|
||||||
|
]
|
||||||
|
const chosen = offered ? accountTheme() : localTheme()
|
||||||
|
const theme = h(
|
||||||
|
"select",
|
||||||
|
{
|
||||||
|
id: "set-theme",
|
||||||
|
"aria-label": t("Theme"),
|
||||||
|
onchange: async (e) => {
|
||||||
|
const el = e.target
|
||||||
|
el.disabled = true
|
||||||
|
try {
|
||||||
|
await chooseTheme(el.value)
|
||||||
|
lookError = ""
|
||||||
|
} catch (err) {
|
||||||
|
lookError = why(err)
|
||||||
|
}
|
||||||
|
renderSettings(current())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })),
|
||||||
|
)
|
||||||
|
return section(
|
||||||
|
"look",
|
||||||
|
t("Appearance"),
|
||||||
|
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
|
||||||
|
line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme),
|
||||||
|
lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSettings(getStatus) {
|
export function initSettings(getStatus) {
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
// The terminal panel: a shell on the device an agent chat works on, beside the chat -- the web
|
||||||
|
// page's panel (the server's static/js/terminal.js), in this app. Two places it opens:
|
||||||
|
//
|
||||||
|
// - an open agent chat: the chat's own shell (`{ chat }`), the one the web page shares;
|
||||||
|
// - a new agent chat, as soon as its device and folder are chosen (`{ device, directory }`):
|
||||||
|
// a shell there before the first message, which the chat that message makes takes over.
|
||||||
|
//
|
||||||
|
// The socket is Rust's (bridge.js `openTerminal`): the page never holds the token. xterm is the
|
||||||
|
// server's vendored build (ui/vendor, scripts/fetch-vendor.sh), loaded the first time the panel
|
||||||
|
// opens. Never beside the activity panel: the shell's layout has one panel column (as the web
|
||||||
|
// page has), so opening one closes the other -- the shell stays open behind it.
|
||||||
|
//
|
||||||
|
// Three things about xterm the web panel learnt the hard way, kept here: it cannot fit while its
|
||||||
|
// box is hidden (so every fit waits until the panel is shown), the panel changes size without a
|
||||||
|
// window `resize` (so a ResizeObserver), and it reads no CSS variables (so the theme is read from
|
||||||
|
// the computed tokens, and again when the theme changes).
|
||||||
|
|
||||||
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
|
import { openTerminal, why } from "./bridge.js"
|
||||||
|
|
||||||
|
const ANSI = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||||
|
const MAX_MARKED = 200
|
||||||
|
|
||||||
|
let ctx = { layout: () => {}, composer: () => {} }
|
||||||
|
let target = null // what the panel is for: { key, chat } | { key, device, directory }, with `name`
|
||||||
|
let term = null
|
||||||
|
let fit = null
|
||||||
|
let session = null // the open socket's handle, and which target it is for: { handle, key, draft }
|
||||||
|
let observer = null
|
||||||
|
let loading = null
|
||||||
|
// A device's shell marks its own commands (OSC 133 A/B/C/D, OSC 7 for where it is) and says so
|
||||||
|
// in its ready frame (`osc`): then a command and its output are read from the panel's own
|
||||||
|
// buffer. Without the marks, "send" falls back to the screen as it is, and says so.
|
||||||
|
let osc = false
|
||||||
|
let pending = null
|
||||||
|
let finished = []
|
||||||
|
let visiting = -1
|
||||||
|
|
||||||
|
const panel = () => $("terminal")
|
||||||
|
|
||||||
|
function say(text, isError) {
|
||||||
|
const el = $("terminal-message")
|
||||||
|
el.textContent = text || ""
|
||||||
|
el.classList.toggle("is-error", !!isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the panel is shown. */
|
||||||
|
export function terminalOpen() {
|
||||||
|
return !panel().hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether there is a shell to open for what the chat page shows now. */
|
||||||
|
export function terminalAvailable() {
|
||||||
|
return !!target
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- xterm, loaded once --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (loading) return loading
|
||||||
|
loading = new Promise((resolve, reject) => {
|
||||||
|
if (typeof window.Terminal === "function" && window.FitAddon) return resolve()
|
||||||
|
document.head.append(h("link", { rel: "stylesheet", href: "vendor/xterm.css" }))
|
||||||
|
const script = (src) =>
|
||||||
|
new Promise((ok, fail) => {
|
||||||
|
const s = h("script", { src })
|
||||||
|
s.addEventListener("load", ok)
|
||||||
|
s.addEventListener("error", () => fail(new Error(src)))
|
||||||
|
document.head.append(s)
|
||||||
|
})
|
||||||
|
script("vendor/xterm.js")
|
||||||
|
.then(() => script("vendor/xterm-addon-fit.js"))
|
||||||
|
.then(resolve, reject)
|
||||||
|
})
|
||||||
|
loading.catch(() => {
|
||||||
|
loading = null
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTheme() {
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
const token = (name, fallback) => (style.getPropertyValue(name) || "").trim() || fallback
|
||||||
|
const theme = {
|
||||||
|
background: token("--code-bg", "black"),
|
||||||
|
foreground: token("--ink", "white"),
|
||||||
|
cursor: token("--accent", "white"),
|
||||||
|
cursorAccent: token("--code-bg", "black"),
|
||||||
|
selectionBackground: token("--accent-soft", "gray"),
|
||||||
|
}
|
||||||
|
for (const name of ANSI) {
|
||||||
|
const value = token(`--ansi-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, "")
|
||||||
|
if (value) theme[name] = value
|
||||||
|
}
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme changed: xterm's colours are read again. */
|
||||||
|
export function terminalThemeChanged() {
|
||||||
|
if (term) term.options.theme = readTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
if (term) return
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
term = new window.Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
|
||||||
|
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: readTheme(),
|
||||||
|
})
|
||||||
|
fit = new window.FitAddon.FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.open($("terminal-screen"))
|
||||||
|
term.parser.registerOscHandler(133, onCommandMark)
|
||||||
|
term.parser.registerOscHandler(7, onWhere)
|
||||||
|
term.onData((data) => session?.handle?.input(data))
|
||||||
|
// Ctrl+C is the shell's interrupt; copy and paste are the shifted pair, as in every terminal.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || e.type !== "keydown") return true
|
||||||
|
if (osc && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||||
|
visit(e.key === "ArrowUp" ? -1 : 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (key === "c") {
|
||||||
|
const text = term.getSelection()
|
||||||
|
if (text) navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (key === "v") {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => session?.handle?.input(text))
|
||||||
|
.catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
observer = new ResizeObserver(() => refit())
|
||||||
|
observer.observe($("terminal-screen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refit() {
|
||||||
|
if (!term || !fit || !terminalOpen() || !$("terminal-screen").offsetWidth) return
|
||||||
|
try {
|
||||||
|
fit.fit()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session?.handle?.resize(term.cols, term.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The socket ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function decode(b64) {
|
||||||
|
const raw = atob(b64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!target || !term || session) return
|
||||||
|
const t0 = target
|
||||||
|
// The session exists before the socket answers, so frames that come first are this one's.
|
||||||
|
const mine = { handle: null, key: t0.key, draft: !t0.chat }
|
||||||
|
session = mine
|
||||||
|
say(t("Connecting…"))
|
||||||
|
let handle
|
||||||
|
try {
|
||||||
|
handle = await openTerminal(t0.chat ? { chat: t0.chat } : { device: t0.device, directory: t0.directory }, term.cols || 80, term.rows || 24, {
|
||||||
|
onOutput: (b64) => {
|
||||||
|
if (session === mine) term.write(decode(b64))
|
||||||
|
},
|
||||||
|
onControl: (frame) => {
|
||||||
|
if (session === mine) control(frame)
|
||||||
|
},
|
||||||
|
onEnd: (end) => {
|
||||||
|
if (session !== mine) return
|
||||||
|
session = null
|
||||||
|
ended(end)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (session === mine) session = null
|
||||||
|
say(why(err), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mine.handle = handle
|
||||||
|
// Let go of meanwhile (another chat, the panel's target changed): closed at once.
|
||||||
|
if (session !== mine) handle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(frame) {
|
||||||
|
if (frame.t === "ready") {
|
||||||
|
say(frame.shared ? t("Connected. This shell is also open in another tab, and they share a size.") : t("Connected."))
|
||||||
|
if (frame.dir) $("terminal-where").textContent = frame.dir
|
||||||
|
osc = !!frame.osc
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
refit()
|
||||||
|
if (terminalOpen()) term.focus()
|
||||||
|
} else if (frame.t === "behind") {
|
||||||
|
// This window stopped reading and was let go so the others kept up. The scrollback is the
|
||||||
|
// state: connecting again costs nothing.
|
||||||
|
say(frame.message || t("Reconnecting…"))
|
||||||
|
drop()
|
||||||
|
term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
connect()
|
||||||
|
} else if (frame.t === "closed" || frame.t === "error") {
|
||||||
|
// No reconnect of its own: a new shell has lost the folder, the environment and the
|
||||||
|
// half-typed command, and quietly putting one in its place is worse than saying it went.
|
||||||
|
say(frame.message || t("This terminal closed."), frame.t === "error")
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ended(end) {
|
||||||
|
if (end.code === 4401 || end.status === 401) say(t("Sign in again: the server no longer accepts this app's sign-in."), true)
|
||||||
|
else if (end.status === 404) say(t("The terminal needs a newer LLeMbas server. Ask its administrator to update it."), true)
|
||||||
|
else if (end.error) say(end.error, true)
|
||||||
|
else say(t("Disconnected. Close and reopen to reconnect."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let go of the socket (the shell on the device lives on, as the server keeps it). */
|
||||||
|
function drop() {
|
||||||
|
const s = session
|
||||||
|
session = null
|
||||||
|
s?.handle?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- What the panel is for -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the panel what the chat page shows: `{ chat, name, directory }` for an open agent chat,
|
||||||
|
* `{ device, directory, name }` for a new one with its device and folder chosen, or null (no shell
|
||||||
|
* to open). A new chat's shell becomes its chat's when the first message has made the chat: the
|
||||||
|
* server took that shell over, so the socket stays as it is.
|
||||||
|
*/
|
||||||
|
export function aimTerminal(next) {
|
||||||
|
const key = next ? (next.chat ? `chat:${next.chat}` : `device:${next.device}:${next.directory}`) : ""
|
||||||
|
const before = target
|
||||||
|
target = next ? { ...next, key } : null
|
||||||
|
if (target) {
|
||||||
|
$("terminal-title").textContent = target.name || t("Terminal")
|
||||||
|
$("terminal-where").textContent = target.directory || ""
|
||||||
|
$("terminal-where").title = target.directory || ""
|
||||||
|
}
|
||||||
|
if (before?.key === key) return
|
||||||
|
// The draft's shell is the new chat's now: the same socket, said for the chat from here on.
|
||||||
|
const adopted = session?.draft && before && !before.chat && target?.chat && before.directory === target.directory && before.device === target.device
|
||||||
|
if (adopted) {
|
||||||
|
session.key = key
|
||||||
|
session.draft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
drop()
|
||||||
|
if (term) term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
say("")
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
if (terminalOpen()) showTerminal(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (terminalOpen()) connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Showing it ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Show or hide the panel. Hiding keeps the shell and its socket: reopening is instant. */
|
||||||
|
export async function showTerminal(on) {
|
||||||
|
if (on && !target) return
|
||||||
|
panel().hidden = !on
|
||||||
|
ctx.layout(on)
|
||||||
|
markOpen()
|
||||||
|
if (!on) return
|
||||||
|
// Whether this showing is to open a shell: none was open as it began. One that ends meanwhile
|
||||||
|
// (before the frame below) is said, not quietly replaced.
|
||||||
|
const wanted = !session
|
||||||
|
try {
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
say(t("The terminal could not be loaded."), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!terminalOpen()) return
|
||||||
|
build()
|
||||||
|
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refit()
|
||||||
|
if (wanted && terminalOpen()) connect()
|
||||||
|
term.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTerminal() {
|
||||||
|
showTerminal(!terminalOpen())
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOpen() {
|
||||||
|
for (const b of document.querySelectorAll("[data-terminal-toggle]")) b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header's button for an open chat; a new chat has the chip under the composer instead. */
|
||||||
|
export function terminalButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
"aria-label": t("Terminal"),
|
||||||
|
title: t("Terminal"),
|
||||||
|
"aria-controls": "terminal",
|
||||||
|
"aria-pressed": String(terminalOpen()),
|
||||||
|
"data-terminal-toggle": "",
|
||||||
|
onclick: toggleTerminal,
|
||||||
|
},
|
||||||
|
icon(ICONS.terminal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A device shell's own marks ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function onCommandMark(data) {
|
||||||
|
if (!osc || !term) return false
|
||||||
|
const [kind, code] = String(data).split(";")
|
||||||
|
if (kind === "A") pending = { prompt: term.registerMarker(0) }
|
||||||
|
else if (kind === "B") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.input = term.registerMarker(0)
|
||||||
|
pending.inputX = term.buffer.active.cursorX
|
||||||
|
} else if (kind === "C") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.output = term.registerMarker(0)
|
||||||
|
pending.command = commandText(pending)
|
||||||
|
} else if (kind === "D") {
|
||||||
|
if (pending?.output) {
|
||||||
|
pending.exit = parseInt(code, 10) || 0
|
||||||
|
pending.end = term.registerMarker(0)
|
||||||
|
commandDone(pending)
|
||||||
|
}
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWhere(data) {
|
||||||
|
if (!osc) return false
|
||||||
|
let path = String(data).replace(/^file:\/\/[^/]*/, "")
|
||||||
|
try {
|
||||||
|
path = decodeURIComponent(path)
|
||||||
|
} catch {}
|
||||||
|
if (path) $("terminal-where").textContent = path
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesBetween(from, to, firstX) {
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const out = []
|
||||||
|
for (let y = from; y < to; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (!line) continue
|
||||||
|
const text = line.translateToString(true, y === from ? firstX || 0 : 0)
|
||||||
|
if (line.isWrapped && out.length) out[out.length - 1] += text
|
||||||
|
else out.push(text)
|
||||||
|
}
|
||||||
|
while (out.length && !out[out.length - 1].trim()) out.pop()
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandText(mark) {
|
||||||
|
if (!mark.input || !mark.output || mark.input.line < 0) return ""
|
||||||
|
return linesBetween(mark.input.line, Math.max(mark.output.line, mark.input.line + 1), mark.inputX).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputOf(mark) {
|
||||||
|
if (!mark.output || !mark.end || mark.output.line < 0 || mark.end.line < 0) return ""
|
||||||
|
return linesBetween(mark.output.line, mark.end.line, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandDone(mark) {
|
||||||
|
finished.push(mark)
|
||||||
|
if (finished.length > MAX_MARKED) finished.shift()
|
||||||
|
visiting = -1
|
||||||
|
showLast((mark.command || "…") + (mark.exit ? ` · exit ${mark.exit}` : ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockOf(mark) {
|
||||||
|
const where = $("terminal-where").textContent
|
||||||
|
const head = `${where ? `${where} ` : ""}$ ${mark.command || ""}`
|
||||||
|
return "```\n" + head + "\n" + outputOf(mark) + "\n```\n" + (mark.exit ? `exit ${mark.exit}\n` : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastFinished() {
|
||||||
|
for (let i = finished.length - 1; i >= 0; i--) if (finished[i].output && finished[i].output.line >= 0) return finished[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(step) {
|
||||||
|
const live = finished.filter((m) => (m.prompt || m.input || m.output)?.line >= 0)
|
||||||
|
if (!live.length) return
|
||||||
|
visiting = visiting < 0 ? live.length : visiting
|
||||||
|
visiting = Math.max(0, Math.min(live.length - 1, visiting + step))
|
||||||
|
const m = live[visiting]
|
||||||
|
term.scrollToLine((m.prompt || m.input || m.output).line)
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetMarks() {
|
||||||
|
finished = []
|
||||||
|
pending = null
|
||||||
|
visiting = -1
|
||||||
|
osc = false
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMarkControls() {
|
||||||
|
$("terminal-prev").hidden = !osc
|
||||||
|
$("terminal-next").hidden = !osc
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLast(text) {
|
||||||
|
$("terminal-last").textContent = text || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handing it to the chat --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What "send" and "copy" take: the selection; else the last command (with marks); else the screen. */
|
||||||
|
function capture() {
|
||||||
|
if (!term) return ""
|
||||||
|
const chosen = term.getSelection()
|
||||||
|
if (chosen && chosen.trim()) return "```\n" + chosen + "\n```\n"
|
||||||
|
if (osc) {
|
||||||
|
const mark = lastFinished()
|
||||||
|
if (!mark) {
|
||||||
|
say(t("Nothing has been run in this shell yet."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return blockOf(mark)
|
||||||
|
}
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const last = buffer.baseY + buffer.cursorY
|
||||||
|
const lines = []
|
||||||
|
for (let y = Math.max(0, last - 40); y <= last; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (line) lines.push(line.translateToString(true))
|
||||||
|
}
|
||||||
|
const text = lines.join("\n").replace(/\n+$/, "")
|
||||||
|
if (!text.trim()) {
|
||||||
|
say(t("Nothing to send: select some output first."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
say(t("Copied the last of the screen, as it appeared. This shell does not mark where commands begin."))
|
||||||
|
return "```\n" + text + "\n```\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into the message box, never sent: what a machine printed is for the person to read first. */
|
||||||
|
function sendToChat() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
ctx.composer(text)
|
||||||
|
// Where the panel covers the chat (a narrow window), it steps aside to show the message box.
|
||||||
|
if (window.matchMedia("(max-width: 64rem)").matches) showTerminal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyOut() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => say(t("Copied.")),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTerminal({ onLayout, composer }) {
|
||||||
|
ctx = { layout: onLayout || (() => {}), composer: composer || (() => {}) }
|
||||||
|
$("terminal-icon").replaceChildren(icon(ICONS.terminal))
|
||||||
|
$("terminal-prev").replaceChildren(icon(ICONS.chevronUp))
|
||||||
|
$("terminal-next").replaceChildren(icon(ICONS.chevronDown))
|
||||||
|
$("terminal-copy").replaceChildren(icon(ICONS.copy))
|
||||||
|
$("terminal-send").prepend(icon(ICONS.send))
|
||||||
|
$("terminal-close").replaceChildren(icon(ICONS.close))
|
||||||
|
$("terminal-prev").addEventListener("click", () => visit(-1))
|
||||||
|
$("terminal-next").addEventListener("click", () => visit(1))
|
||||||
|
$("terminal-copy").addEventListener("click", copyOut)
|
||||||
|
$("terminal-send").addEventListener("click", sendToChat)
|
||||||
|
$("terminal-close").addEventListener("click", () => showTerminal(false))
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it, the
|
||||||
|
// server's own panel in this app (`chat/_activity_tasks.html` tree_row): the main agent at the
|
||||||
|
// root, every subagent under its parent in the order added, how many slots are open, then the
|
||||||
|
// ended ones folded apart, dimmed, with how each ended. A row is the tasks' grid -- chevron, who
|
||||||
|
// it is and what it is doing, its context use in the time column, a stop square -- and opened,
|
||||||
|
// its facts (state, model, mode and request options, messages, findings), its task, its phase
|
||||||
|
// line, the last room message to or from it, and the buttons to read its conversation and its
|
||||||
|
// findings. Its subagents nest under it.
|
||||||
|
//
|
||||||
|
// The data is the server's `GET /api/v2/chats/{chat}/tree` (and the stream's `tree` frame):
|
||||||
|
//
|
||||||
|
// { min, max, size, open_slots,
|
||||||
|
// agents: [{ id, name, position, parent, state, model, mode, request, manager, task,
|
||||||
|
// context: { used, window }, progress, findings, messages }],
|
||||||
|
// ended: [{ ..., ended }] }
|
||||||
|
//
|
||||||
|
// Everything in it but the numbers is a model's words: set as text, never as markup.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { progressLine, progressText } from "./progress.js"
|
||||||
|
|
||||||
|
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
|
||||||
|
const STATES = {
|
||||||
|
working: "working",
|
||||||
|
waiting: "waiting for a slot",
|
||||||
|
finished: "finished",
|
||||||
|
idle: "idle",
|
||||||
|
paused: "paused: context full",
|
||||||
|
done: "done",
|
||||||
|
out_of_context: "out of context",
|
||||||
|
stopped: "stopped",
|
||||||
|
}
|
||||||
|
/** A subagent that may still be stopped (the server's TREE_LIVE). */
|
||||||
|
export const TREE_LIVE = ["working", "waiting", "paused"]
|
||||||
|
|
||||||
|
export function treeState(state) {
|
||||||
|
return STATES[state] ? t(STATES[state]) : String(state || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** floor(used × 100 / window), or null when the window is not known. */
|
||||||
|
export function contextPercent(c) {
|
||||||
|
const used = Number(c?.used)
|
||||||
|
const window = Number(c?.window)
|
||||||
|
if (!(window > 0) || !(used >= 0)) return null
|
||||||
|
return Math.min(100, Math.floor((used * 100) / window))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
|
||||||
|
export function treeOrder(agents) {
|
||||||
|
const names = new Map()
|
||||||
|
for (const a of agents) names.set(a.id, a.id).set(a.name, a.id)
|
||||||
|
const parentOf = (a) => {
|
||||||
|
const p = a.parent ? names.get(a.parent) : null
|
||||||
|
return p && p !== a.id ? p : null
|
||||||
|
}
|
||||||
|
const under = new Map()
|
||||||
|
for (const a of agents) {
|
||||||
|
const p = parentOf(a)
|
||||||
|
if (!under.has(p)) under.set(p, [])
|
||||||
|
under.get(p).push(a)
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
const walk = (parent, depth) => {
|
||||||
|
for (const a of under.get(parent) || []) {
|
||||||
|
if (seen.has(a.id)) continue
|
||||||
|
seen.add(a.id)
|
||||||
|
out.push({ agent: a, depth })
|
||||||
|
walk(a.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(null, 0)
|
||||||
|
for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The room's messages to or from an agent (by name). */
|
||||||
|
function messagesOf(room, a) {
|
||||||
|
return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(text) {
|
||||||
|
return String(text || "").split("\n").find((l) => l.trim()) || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree as rows. `act` holds what the rows can do: `open(a)` (its conversation, read-only),
|
||||||
|
* `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded
|
||||||
|
* across redraws. `room` is the room as read so far; `device` says the subagents run on the
|
||||||
|
* computer the chat runs on (an agent chat).
|
||||||
|
*/
|
||||||
|
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
|
||||||
|
const box = h("div", { class: "subagents" })
|
||||||
|
box.append(h("p", { class: "tasks__team", text: t("%(size)s of %(max)s", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }))
|
||||||
|
if (Number(tree.open_slots) > 0) box.append(h("p", { class: "tasks__goal subagents__slots", text: t("Open slots: %(n)s", { n: tree.open_slots }) }))
|
||||||
|
if (device) box.append(h("p", { class: "tasks__goal", text: t("They run on the computer this chat runs on.") }))
|
||||||
|
box.append(h("div", { class: "tasks__list subagents__list" }, treeOrder(tree.agents || []).map(({ agent, depth }) => row(agent, depth, { room, act, now, ended: false }))))
|
||||||
|
const ended = tree.ended || []
|
||||||
|
if (ended.length) {
|
||||||
|
const key = "fold:ended"
|
||||||
|
const fold = h(
|
||||||
|
"details",
|
||||||
|
{ class: "tasks__more subagents__ended", open: !!act.isOpen?.(key) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: `${t("Ended")} ${ended.length}` })),
|
||||||
|
h("div", { class: "tasks__list" }, ended.map((a) => row(a, 0, { room, act, now, ended: true }))),
|
||||||
|
)
|
||||||
|
fold.addEventListener("toggle", () => act.toggled?.(key, fold.open))
|
||||||
|
box.append(fold)
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(a, depth, { room, act, now, ended }) {
|
||||||
|
const key = `sub:${a.id}`
|
||||||
|
const main = !a.parent && (a.id === "lead" || a.name === "lead")
|
||||||
|
const pct = ended ? null : contextPercent(a.context)
|
||||||
|
const said = messagesOf(room, a)
|
||||||
|
const last = said[said.length - 1]
|
||||||
|
const mode = a.mode ? (a.request?.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode) : ""
|
||||||
|
const live = !ended && TREE_LIVE.includes(a.state)
|
||||||
|
const phase = !ended && a.progress ? progressText(a.progress, now) : ""
|
||||||
|
const stop =
|
||||||
|
live && !main && act.stop
|
||||||
|
? h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "agent__stop",
|
||||||
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
|
onclick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
act.stop(a)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
|
)
|
||||||
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: !!act.isOpen?.(key), dataset: { id: a.id } },
|
||||||
|
h(
|
||||||
|
"summary",
|
||||||
|
{ class: "agent__row" },
|
||||||
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "agent__what" },
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "subagent__who" },
|
||||||
|
h("bdi", { class: "agent__name", text: a.name || a.id }),
|
||||||
|
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
|
||||||
|
),
|
||||||
|
main ? h("span", { class: "agent__role", text: t("the main agent") }) : a.position ? h("span", { class: "agent__role", text: a.position }) : null,
|
||||||
|
// The phase alone where a row has no room for its clock (progress.md §3); ended: how.
|
||||||
|
h("span", { class: "subagent__phase", dataset: { phase: phase ? a.progress.phase : "" }, text: phase || treeState(a.state) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
|
||||||
|
stop,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agent__body" },
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{ class: "agent__facts" },
|
||||||
|
h("span", { class: "agent__state", text: treeState(a.state) }),
|
||||||
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
|
mode ? h("span", { text: mode }) : null,
|
||||||
|
h("span", { text: t("messages: %(n)s", { n: a.messages ?? said.length }) }),
|
||||||
|
a.findings ? h("span", { text: t("findings: %(n)s", { n: a.findings }) }) : null,
|
||||||
|
),
|
||||||
|
a.task && !main ? h("p", { class: "agent__brief", text: a.task }) : null,
|
||||||
|
phase ? h("p", { class: "agent__text subagent__line", dataset: { progress: JSON.stringify(a.progress) }, text: progressLine(a.progress, now) }) : null,
|
||||||
|
last ? h("p", { class: "agent__text", text: `${last.from} → ${(last.to || []).join(", ")}: ${firstLine(last.text)}` }) : null,
|
||||||
|
main
|
||||||
|
? null
|
||||||
|
: h(
|
||||||
|
"p",
|
||||||
|
{ class: "subagent__buttons" },
|
||||||
|
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => act.open(a) }) : null,
|
||||||
|
act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth))))
|
||||||
|
el.addEventListener("toggle", () => act.toggled?.(key, el.open))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree's phase lines move on by themselves between reads (their clock). */
|
||||||
|
export function tickTree(root, now = Date.now()) {
|
||||||
|
for (const el of root.querySelectorAll(".subagent__line[data-progress]")) {
|
||||||
|
try {
|
||||||
|
el.textContent = progressLine(JSON.parse(el.dataset.progress), now)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
|
||||||
|
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
|
||||||
|
// `models` scope (src-tauri/src/server.rs):
|
||||||
|
//
|
||||||
|
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
|
||||||
|
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
|
||||||
|
//
|
||||||
|
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
|
||||||
|
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
|
||||||
|
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
|
||||||
|
|
||||||
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, why } from "./bridge.js"
|
||||||
|
|
||||||
|
let me = null
|
||||||
|
|
||||||
|
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
|
||||||
|
/** Whether a message can be dictated here. */
|
||||||
|
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
|
||||||
|
/** Whether a reply can be read aloud here. */
|
||||||
|
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
|
||||||
|
|
||||||
|
function say(text) {
|
||||||
|
const box = $("thread-note")
|
||||||
|
if (!box) return
|
||||||
|
box.textContent = text
|
||||||
|
box.hidden = !text
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dictate --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let recorder = null // the recording in progress
|
||||||
|
let busy = false // a recording being turned into words
|
||||||
|
|
||||||
|
function drawMic() {
|
||||||
|
const b = $("dictate")
|
||||||
|
if (!b) return
|
||||||
|
b.hidden = !canDictate()
|
||||||
|
const on = !!recorder
|
||||||
|
b.classList.toggle("is-recording", on)
|
||||||
|
b.classList.toggle("is-busy", busy)
|
||||||
|
b.disabled = busy
|
||||||
|
b.setAttribute("aria-pressed", String(on))
|
||||||
|
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
|
||||||
|
b.setAttribute("aria-label", label)
|
||||||
|
b.title = label
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
say("")
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
await invoke("voice_arm")
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
} catch (err) {
|
||||||
|
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
|
||||||
|
let rec
|
||||||
|
try {
|
||||||
|
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
|
||||||
|
} catch (err) {
|
||||||
|
stream.getTracks().forEach((x) => x.stop())
|
||||||
|
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks = []
|
||||||
|
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
|
||||||
|
rec.addEventListener("stop", async () => {
|
||||||
|
stream.getTracks().forEach((x) => x.stop())
|
||||||
|
recorder = null
|
||||||
|
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
|
||||||
|
if (!blob.size) return drawMic()
|
||||||
|
busy = true
|
||||||
|
drawMic()
|
||||||
|
try {
|
||||||
|
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
|
||||||
|
intoComposer(text)
|
||||||
|
} catch (err) {
|
||||||
|
say(why(err))
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
drawMic()
|
||||||
|
})
|
||||||
|
recorder = rec
|
||||||
|
rec.start()
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words heard, where the cursor is in the message box, a space either side where needed. */
|
||||||
|
function intoComposer(text) {
|
||||||
|
text = String(text || "").trim()
|
||||||
|
if (!text) return
|
||||||
|
const box = $("composer-text")
|
||||||
|
const at = box.selectionStart ?? box.value.length
|
||||||
|
const end = box.selectionEnd ?? at
|
||||||
|
const before = box.value.slice(0, at)
|
||||||
|
const after = box.value.slice(end)
|
||||||
|
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
|
||||||
|
box.value = before + words + after
|
||||||
|
box.selectionStart = box.selectionEnd = before.length + words.length
|
||||||
|
box.dispatchEvent(new Event("input"))
|
||||||
|
box.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
|
||||||
|
export function stopDictating() {
|
||||||
|
if (recorder?.state === "recording") recorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Read aloud -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let playing = null // { id, audio, button }
|
||||||
|
|
||||||
|
/** A reply's words without the markdown around them, and without its code. */
|
||||||
|
export function plain(markdown) {
|
||||||
|
return String(markdown || "")
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/[*_`#>|~]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopReading() {
|
||||||
|
if (!playing) return
|
||||||
|
playing.audio?.pause()
|
||||||
|
playing.button.setAttribute("aria-pressed", "false")
|
||||||
|
playing.button.classList.remove("is-busy")
|
||||||
|
playing = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
|
||||||
|
export function readAloudButton(id, text) {
|
||||||
|
if (!canSpeak()) return null
|
||||||
|
const label = t("Read this aloud")
|
||||||
|
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
if (playing?.id === id) return stopReading()
|
||||||
|
stopReading()
|
||||||
|
const words = plain(text())
|
||||||
|
if (!words) return
|
||||||
|
const mine = { id, audio: null, button: b }
|
||||||
|
playing = mine
|
||||||
|
b.setAttribute("aria-pressed", "true")
|
||||||
|
b.classList.add("is-busy")
|
||||||
|
try {
|
||||||
|
const url = await invoke("speak", { text: words })
|
||||||
|
if (playing !== mine) return
|
||||||
|
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
|
||||||
|
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
|
||||||
|
mine.audio = new Audio(url)
|
||||||
|
b.classList.remove("is-busy")
|
||||||
|
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
|
||||||
|
await mine.audio.play()
|
||||||
|
} catch (err) {
|
||||||
|
if (playing === mine) stopReading()
|
||||||
|
say(why(err?.message ? { detail: err.message } : err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
|
||||||
|
export function voiceFor(account) {
|
||||||
|
me = account
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initVoice() {
|
||||||
|
const b = $("dictate")
|
||||||
|
b.replaceChildren(icon(ICONS.mic))
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
if (busy) return
|
||||||
|
if (recorder) recorder.stop()
|
||||||
|
else start()
|
||||||
|
})
|
||||||
|
drawMic()
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/* LLeMbas v2.0.0-beta.6 (a71e9b4bdb8a) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
/* LLeMbas v2.0.0-beta.9 (3b7da5fc7ac7) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||||
The font-face rules are left out; the app bundles no font. */
|
The font-face rules are left out; the app bundles no font. */
|
||||||
/*
|
/*
|
||||||
Design tokens.
|
Design tokens.
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
Vendored
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
|
||||||
|
//# sourceMappingURL=xterm.js.map
|
||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.6",
|
"tag": "v2.0.0-beta.9",
|
||||||
"commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
|
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
|
||||||
"files": {
|
"files": {
|
||||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
||||||
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
||||||
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
||||||
|
"xterm.js": {"server": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495", "copy": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495"},
|
||||||
|
"xterm.css": {"server": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6", "copy": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6"},
|
||||||
|
"xterm-addon-fit.js": {"server": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089", "copy": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089"},
|
||||||
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user