Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12ed7ac36
|
||
|
|
b8a4f1bc57
|
||
|
|
7c0c86ada1
|
||
|
|
de914f4b16
|
||
|
|
b6d69575f7
|
||
|
|
d04bc4b6bd
|
||
|
|
844684a152
|
||
|
|
917c1e2286
|
||
|
|
46b83c3809
|
||
|
|
2a51b4f6d0
|
||
|
|
7ee394b549
|
||
|
|
2958be0a23
|
||
|
|
3b0def78ec
|
No files matched your search
@@ -6,6 +6,87 @@ All notable changes to LLeMbas Desktop. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0-beta.3] — 2026-11-09
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.5. Needs a LLeMbas 2.0 server
|
||||
(2.0.0-beta.5 or later for the activity panel, the Thinking switch and team sizes).
|
||||
|
||||
### Added
|
||||
|
||||
- **Attachments.** A message can carry files: the attach button beside the composer (the
|
||||
system's file dialog), files dropped anywhere on the window, or an image pasted into the
|
||||
composer. Each is sent to the server at once and shown above the composer with its size, or
|
||||
with the server's reason when it refused it; one taken off before sending is deleted on the
|
||||
server. The app sends only files you chose, dropped or pasted -- the page never names a path.
|
||||
Agent chats get them too.
|
||||
- **Edit a message, regenerate a reply, copy either.** Under your messages, *Edit message*
|
||||
rewrites one in place and starts the reply again from there (everything after it is dropped,
|
||||
as the app says before you save; on the agent's device too); under the last reply,
|
||||
*Regenerate reply*. *Copy* puts a message, or a reply's text as Markdown, on the clipboard.
|
||||
- **The chat's own menu** (⋯ in its header, or a right click on it in the sidebar): rename it,
|
||||
open it in the web interface in your browser, archive it, delete it (asked first).
|
||||
- **Change a chat's model** from its header, and how hard it thinks as the model offers it: a
|
||||
model with one level shows nothing to choose; one that can switch thinking off and has one
|
||||
effort shows a **Think before answering** switch; more levels keep the selector. A new chat
|
||||
starts at the model's own start level, and the control follows the model you pick.
|
||||
- **Agents, fewest and most**, beside it where the chat can have a team (a chat on the server,
|
||||
on a model that serves at least two requests at once, with subagents on): how many agents the
|
||||
chat's team may have, its own agent included. Saved at once in the header; on a new chat,
|
||||
left empty, your default stands. A pair the server will not take is said in its words, and the
|
||||
fields go back.
|
||||
- **Model pictures**: a model the server has a picture for shows it in the pickers and under
|
||||
each reply; the others show their initial on a colour of their own -- the same colour the web
|
||||
interface gives them.
|
||||
- **The activity panel** beside a chat (the header's bolt button, the strip above the message
|
||||
box, or the palette): what is in the chat's context, by category, against the model's window;
|
||||
the files the chat and its agents wrote, with lines added and removed, each opening in the
|
||||
viewer; every background agent -- a team's members as a tree, helpers and commands -- with what
|
||||
it was asked, its state, how long it has run, its model, its latest step and what it is
|
||||
writing, its whole conversation to read and a button to stop it (a member after you confirm,
|
||||
for those under it stop with it); the team's room, who wrote to whom and which messages are
|
||||
reports; and the plan. It keeps up by itself while the chat is open. The strip above the
|
||||
message box says what the agents are doing ("2 agents working · 1 queued") and opens it. On a
|
||||
narrow window the panel covers the chat; at phone width it fills the window.
|
||||
- **Continue an agent chat in a terminal**: its menu and its header show the command that opens
|
||||
the agent's session in a terminal (`lembas -s …`, in the chat's folder), to copy. On this
|
||||
computer it names the LLeMbas CLI as it starts here -- the one on your PATH, else the app's own.
|
||||
- **A command palette** (Ctrl+K): new chats, settings, the agent and updates by name, and every
|
||||
chat of both kinds by its title (accents optional). Ctrl+N starts a new chat of the tab you are
|
||||
on; Ctrl+, opens the settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The agent's status is the CLI's own word.** The title bar and Settings no longer say
|
||||
*running* because a process runs: they say what the built-in LLeMbas CLI says of its link to
|
||||
the server -- connecting, running (linked), waiting and why (the server cannot be reached, the
|
||||
link dropped, a terminal on this computer holds the link), or refused and why. The same for a
|
||||
`lembas service` you run yourself. When the CLI says trying again will not help (the server
|
||||
refused this computer's token), the app no longer restarts it every second: it waits until you
|
||||
link the computer again. When the agent stops with an error, its last words are the reason
|
||||
shown, not just an exit status.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Czech sentence in the viewer was in Slovak; "Server files" and the composer's placeholder
|
||||
were not translated.
|
||||
|
||||
## [1.0.0-beta.2] — 2026-11-07
|
||||
|
||||
Carries LLeMbas CLI 2.0.0-beta.2 and the look of LLeMbas 2.0.0-beta.4.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The agent keeps what its model has read** (LLeMbas CLI 2.0.0-beta.2, harness spec 3.1.0): the
|
||||
system prompt is made once per session and sent unchanged, so a local model's server reuses the
|
||||
whole conversation instead of reading it again; the date and anything that changes during a
|
||||
session come as a short note on your next message; the tools stay the same for the session;
|
||||
compaction is the next turn of the same conversation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The editor highlights code again.** One of the editor's modules imported a file the app does
|
||||
not have, so every language that parses failed to load and files opened as plain text.
|
||||
|
||||
## [1.0.0-beta.1] — 2026-11-03
|
||||
|
||||
LLeMbas Desktop 1.0 becomes an app of its own: the agent on this computer by default, your
|
||||
|
||||
@@ -12,6 +12,11 @@ an app of its own.
|
||||
on the server.
|
||||
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
||||
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
|
||||
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
|
||||
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
|
||||
- **A command palette** (Ctrl+K) for commands and every chat by its title.
|
||||
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
||||
waiting and why -- not whether a process runs.
|
||||
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
|
||||
- **Its own window and tray**: close the window and the agent keeps running in the tray.
|
||||
|
||||
@@ -73,6 +78,8 @@ device.
|
||||
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
|
||||
holds the token.
|
||||
- The explorer reads only inside the agent's folders.
|
||||
- A file goes to the server as an attachment only when you choose it in the file dialog, drop it
|
||||
on the window or paste it: the page cannot name a path for the app to send.
|
||||
- HTML is shown in a sealed frame on an address of its own: scripts in it run, but it has no
|
||||
access to the app, no network and no way to navigate anywhere.
|
||||
- Links in a reply open in your browser, and only web and mail links.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.0.0-beta.1
|
||||
2.0.0-beta.3
|
||||
+4
-1
@@ -19,7 +19,10 @@ chmod 700 "$runtime"
|
||||
pids=()
|
||||
cleanup() {
|
||||
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
|
||||
rm -rf "$home" "$runtime"
|
||||
# The document portal, started on the session bus, may have mounted itself (FUSE) at
|
||||
# $XDG_RUNTIME_DIR/doc; rm cannot remove a mount point, and a cleanup must not fail the check.
|
||||
fusermount3 -u "$runtime/doc" 2>/dev/null || fusermount -u "$runtime/doc" 2>/dev/null || true
|
||||
rm -rf "$home" "$runtime" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
+12
-4
@@ -13,7 +13,10 @@
|
||||
# The one change made to a copy: the server loads these modules through an import map
|
||||
# ("@codemirror/view" -> its file), which the app's pages do not have -- an inline import map is a
|
||||
# script the content security policy would have to allow. So the bare names in `import`/`from`
|
||||
# are rewritten to the sibling files ("./cm-view.mjs"), and nothing else is touched.
|
||||
# are rewritten to the sibling files ("./cm-view.mjs"), and nothing else is touched. esm.sh's
|
||||
# Node polyfill ("/node/process.mjs", which @lezer/lr imports to read process.env.LOG) becomes
|
||||
# "./node-process.mjs", the server's own one-line stub (static/js/node-process.mjs), copied too:
|
||||
# an absolute path is a 404 in the app, and a failed @lezer/lr takes every editor language with it.
|
||||
#
|
||||
# vendor.lock.json records the ref, the commit it resolved to, and for every file the server's
|
||||
# SHA-256 and the copy's, which is what verify checks. Like fetch-tokens.sh: the file in the tree
|
||||
@@ -76,14 +79,19 @@ for name in $names; do
|
||||
[ -n "$pinned" ] || { echo "fetch-vendor: $name is not pinned in the server's $SERVER_LOCK" >&2; exit 1; }
|
||||
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
|
||||
# "@codemirror/view" -> "./cm-view.mjs", "@lezer/lr" -> "./lezer-lr.mjs", "codemirror" -> "./cm-codemirror.mjs".
|
||||
perl -pe 's#\b(from|import)(\s*)"\@codemirror/([a-z-]+)"#$1$2"./cm-$3.mjs"#g; s#\b(from|import)(\s*)"\@lezer/([a-z-]+)"#$1$2"./lezer-$3.mjs"#g; s#\b(from|import)(\s*)"codemirror"#$1$2"./cm-codemirror.mjs"#g' \
|
||||
perl -pe 's#\b(from|import)(\s*)"\@codemirror/([a-z-]+)"#$1$2"./cm-$3.mjs"#g; s#\b(from|import)(\s*)"\@lezer/([a-z-]+)"#$1$2"./lezer-$3.mjs"#g; s#\b(from|import)(\s*)"codemirror"#$1$2"./cm-codemirror.mjs"#g; s#\b(from|import)(\s*)"/node/process\.mjs"#$1$2"./node-process.mjs"#g' \
|
||||
"$work/$name" > "$work/out/$name"
|
||||
if grep -E -q '(from|import)\s*"(@codemirror|@lezer)/' "$work/out/$name"; then
|
||||
echo "fetch-vendor: a bare module name survived in $name; fix the script" >&2
|
||||
if grep -E -q '(from|import)\s*"((@codemirror|@lezer)/|/)' "$work/out/$name"; then
|
||||
echo "fetch-vendor: a bare module name or an absolute path survived in $name; fix the script" >&2
|
||||
exit 1
|
||||
fi
|
||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
||||
done
|
||||
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
|
||||
stub="src/lembas/web/static/js/node-process.mjs"
|
||||
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
||||
|| { echo "fetch-vendor: $stub is not in $SERVER at $REF" >&2; exit 1; }
|
||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' node-process.mjs "$(sha "$work/out/node-process.mjs")" "$(sha "$work/out/node-process.mjs")")")
|
||||
# Every module a copy imports must be one of the copies.
|
||||
for f in "$work"/out/*.mjs; do
|
||||
while read -r dep; do
|
||||
|
||||
+289
-9
@@ -156,7 +156,6 @@ KEYS = (
|
||||
"agent running (service)",
|
||||
"agent stopped",
|
||||
"agent: no folders",
|
||||
"Agent stopped: %(why)s",
|
||||
"update %(version)s",
|
||||
"update",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)",
|
||||
@@ -167,6 +166,103 @@ KEYS = (
|
||||
"Page",
|
||||
"Preview",
|
||||
"Source",
|
||||
# 1.0 for daily use: attachments, message and chat actions, the palette, the agent's word
|
||||
"This is a folder; attach the files in it.",
|
||||
"This file is too large to send.",
|
||||
"Sending…",
|
||||
"Saving starts the reply again; everything after this message is dropped.",
|
||||
"Rename…",
|
||||
"Open in the web interface",
|
||||
"Delete this chat?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.",
|
||||
"Wait until the files are sent.",
|
||||
"Type a command or a chat's title",
|
||||
"The agent is connecting to the server…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s",
|
||||
"agent connecting…",
|
||||
"agent waiting",
|
||||
"agent refused",
|
||||
"Agent: %(why)s",
|
||||
"Server files",
|
||||
"Drop the files to attach them",
|
||||
"Attach files",
|
||||
"Edit message",
|
||||
"Save",
|
||||
"Rename chat",
|
||||
"Rename",
|
||||
"Delete",
|
||||
"Search",
|
||||
"Nothing matches.",
|
||||
"Message",
|
||||
"Archive chat",
|
||||
"Regenerate reply",
|
||||
"Delete chat",
|
||||
"Reasoning effort",
|
||||
"Commands",
|
||||
# 1.0 second pass: effort and team size, model pictures, the activity panel, the terminal
|
||||
"off",
|
||||
"default",
|
||||
"Think before answering",
|
||||
"Fewest",
|
||||
"Most",
|
||||
"Agents",
|
||||
"Its own agent included. Above one, it can add members that do the work while it leads them.",
|
||||
"Continue in a terminal…",
|
||||
"Continue in a terminal",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.",
|
||||
"Copied",
|
||||
"Activity",
|
||||
"Back",
|
||||
"Open the activity panel",
|
||||
"Close the activity panel",
|
||||
"1 agent working",
|
||||
"%(n)s agents working",
|
||||
"%(n)s queued",
|
||||
"%(n)s waiting",
|
||||
"%(n)s finished",
|
||||
"queued",
|
||||
"working",
|
||||
"waiting",
|
||||
"done",
|
||||
"stopped",
|
||||
"failed",
|
||||
"Messages",
|
||||
"System prompt",
|
||||
"Memory and personal",
|
||||
"Instruction files",
|
||||
"Tools",
|
||||
"MCP tools",
|
||||
"Since the last reply",
|
||||
"Compaction buffer",
|
||||
"Free space",
|
||||
"Context",
|
||||
"Outputs",
|
||||
"Background tasks",
|
||||
"Plan",
|
||||
"Not measured.",
|
||||
"%(percent)s%% used",
|
||||
"%(tokens)s tokens",
|
||||
"window unknown",
|
||||
"%(percent)s%% of the context window used",
|
||||
"Counted by the model at the last reply; the categories are estimated.",
|
||||
"Estimated: no reply has been counted yet.",
|
||||
"%(n)s tools offered.",
|
||||
"No files written in this chat yet.",
|
||||
"Nothing runs in the background. Helpers, commands and a team's members show here while they work.",
|
||||
"Nothing running.",
|
||||
"Team: %(size)s of %(max)s agents",
|
||||
"Show %(n)s more",
|
||||
"Room",
|
||||
"report",
|
||||
"Nothing has been said in the room yet.",
|
||||
"Stop %(name)s",
|
||||
"%(steps)s steps, %(tools)s tools",
|
||||
"Open its conversation",
|
||||
"Stop %(name)s and the members under it? They leave the team.",
|
||||
"Nothing yet.",
|
||||
)
|
||||
|
||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||
@@ -265,7 +361,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agent běží (služba)",
|
||||
"agent stopped": "agent zastaven",
|
||||
"agent: no folders": "agent: žádné složky",
|
||||
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
|
||||
"update %(version)s": "aktualizace %(version)s",
|
||||
"update": "aktualizace",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
|
||||
@@ -273,6 +368,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Toto není textový soubor; nelze ho zde zobrazit.",
|
||||
"Page": "Stránka",
|
||||
"Source": "Zdroj",
|
||||
"This is a folder; attach the files in it.": "Toto je složka; přiložte soubory v ní.",
|
||||
"This file is too large to send.": "Tento soubor je příliš velký na odeslání.",
|
||||
"Sending…": "Odesílá se…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.",
|
||||
"Rename…": "Přejmenovat…",
|
||||
"Open in the web interface": "Otevřít ve webovém rozhraní",
|
||||
"Delete this chat?": "Smazat tuto konverzaci?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ se smaže na serveru i se svými zprávami a soubory. Nelze to vrátit.",
|
||||
"Wait until the files are sent.": "Počkejte, až se soubory odešlou.",
|
||||
"Type a command or a chat's title": "Napište příkaz nebo název konverzace",
|
||||
"The agent is connecting to the server…": "Agent se připojuje k serveru…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent není připojený: %(why)s. Zkouší to dál.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent se nepřipojí: %(why)s. Propojte tento počítač znovu.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI už tu běží (pid %(pid)s), ale není připojená: %(why)s",
|
||||
"agent connecting…": "agent se připojuje…",
|
||||
"agent waiting": "agent čeká",
|
||||
"agent refused": "agent odmítnut",
|
||||
"Agent: %(why)s": "Agent: %(why)s",
|
||||
"Server files": "Soubory na serveru",
|
||||
"Drop the files to attach them": "Pusťte soubory a přiloží se",
|
||||
"Continue in a terminal…": "Pokračovat v terminálu…",
|
||||
"Continue in a terminal": "Pokračovat v terminálu",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spusťte to v terminálu: relace agenta tam pokračuje, ve složce této konverzace, a konverzace si ji ponechá.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spusťte to v terminálu na počítači, na kterém tato konverzace pracuje: relace agenta tam pokračuje a konverzace si ji ponechá.",
|
||||
},
|
||||
"de": {
|
||||
"Menu": "Menü",
|
||||
@@ -366,7 +485,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "Agent läuft (Dienst)",
|
||||
"agent stopped": "Agent angehalten",
|
||||
"agent: no folders": "Agent: keine Ordner",
|
||||
"Agent stopped: %(why)s": "Agent angehalten: %(why)s",
|
||||
"update %(version)s": "Update %(version)s",
|
||||
"update": "Update",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
|
||||
@@ -374,6 +492,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Das ist keine Textdatei; sie kann hier nicht angezeigt werden.",
|
||||
"Page": "Seite",
|
||||
"Source": "Quelltext",
|
||||
"This is a folder; attach the files in it.": "Das ist ein Ordner; hängen Sie die Dateien darin an.",
|
||||
"This file is too large to send.": "Diese Datei ist zu groß zum Senden.",
|
||||
"Sending…": "Wird gesendet…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.",
|
||||
"Rename…": "Umbenennen…",
|
||||
"Open in the web interface": "In der Weboberfläche öffnen",
|
||||
"Delete this chat?": "Diesen Chat löschen?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ wird auf dem Server gelöscht, mit seinen Nachrichten und Dateien. Das lässt sich nicht rückgängig machen.",
|
||||
"Wait until the files are sent.": "Warten Sie, bis die Dateien gesendet sind.",
|
||||
"Type a command or a chat's title": "Befehl oder Chat-Titel eingeben",
|
||||
"The agent is connecting to the server…": "Der Agent verbindet sich mit dem Server…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "Der Agent ist nicht verbunden: %(why)s. Er versucht es weiter.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "Der Agent verbindet sich nicht: %(why)s. Verknüpfen Sie diesen Computer erneut.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s), ist aber nicht verbunden: %(why)s",
|
||||
"agent connecting…": "Agent verbindet…",
|
||||
"agent waiting": "Agent wartet",
|
||||
"agent refused": "Agent abgewiesen",
|
||||
"Agent: %(why)s": "Agent: %(why)s",
|
||||
"Server files": "Dateien auf dem Server",
|
||||
"Drop the files to attach them": "Dateien ablegen, um sie anzuhängen",
|
||||
"Continue in a terminal…": "Im Terminal fortsetzen…",
|
||||
"Continue in a terminal": "Im Terminal fortsetzen",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Führen Sie dies in einem Terminal aus: Die Sitzung des Agenten geht dort weiter, im Ordner dieses Chats, und der Chat behält sie.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Führen Sie dies in einem Terminal auf dem Computer aus, auf dem dieser Chat arbeitet: Die Sitzung des Agenten geht dort weiter, und der Chat behält sie.",
|
||||
},
|
||||
"es": {
|
||||
"Menu": "Menú",
|
||||
@@ -467,7 +609,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agente en marcha (servicio)",
|
||||
"agent stopped": "agente detenido",
|
||||
"agent: no folders": "agente: sin carpetas",
|
||||
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
|
||||
"update %(version)s": "actualizar a %(version)s",
|
||||
"update": "actualizar",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
|
||||
@@ -475,6 +616,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "No es un archivo de texto; no se puede mostrar aquí.",
|
||||
"Page": "Página",
|
||||
"Source": "Código fuente",
|
||||
"This is a folder; attach the files in it.": "Esto es una carpeta; adjunte los archivos que contiene.",
|
||||
"This file is too large to send.": "Este archivo es demasiado grande para enviarlo.",
|
||||
"Sending…": "Enviando…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.",
|
||||
"Rename…": "Renombrar…",
|
||||
"Open in the web interface": "Abrir en la interfaz web",
|
||||
"Delete this chat?": "¿Eliminar este chat?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» se elimina del servidor, con sus mensajes y archivos. No se puede deshacer.",
|
||||
"Wait until the files are sent.": "Espere a que se envíen los archivos.",
|
||||
"Type a command or a chat's title": "Escriba un comando o el título de un chat",
|
||||
"The agent is connecting to the server…": "El agente se está conectando al servidor…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "El agente no está conectado: %(why)s. Sigue intentándolo.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "El agente no se conectará: %(why)s. Vuelva a vincular este equipo.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ya hay un servicio de LLeMbas CLI aquí (pid %(pid)s), pero no está conectado: %(why)s",
|
||||
"agent connecting…": "agente conectando…",
|
||||
"agent waiting": "agente en espera",
|
||||
"agent refused": "agente rechazado",
|
||||
"Agent: %(why)s": "Agente: %(why)s",
|
||||
"Server files": "Archivos del servidor",
|
||||
"Drop the files to attach them": "Suelte los archivos para adjuntarlos",
|
||||
"Continue in a terminal…": "Continuar en un terminal…",
|
||||
"Continue in a terminal": "Continuar en un terminal",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Ejecútelo en un terminal: la sesión del agente continúa allí, en la carpeta de este chat, y el chat la conserva.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Ejecútelo en un terminal del equipo en el que trabaja este chat: la sesión del agente continúa allí y el chat la conserva.",
|
||||
},
|
||||
"fr": {
|
||||
"Menu": "Menu",
|
||||
@@ -568,7 +733,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agent actif (service)",
|
||||
"agent stopped": "agent interrompu",
|
||||
"agent: no folders": "agent : aucun dossier",
|
||||
"Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s",
|
||||
"update %(version)s": "mise à jour %(version)s",
|
||||
"update": "mise à jour",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
|
||||
@@ -576,6 +740,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Ce n'est pas un fichier texte ; il ne peut pas être affiché ici.",
|
||||
"Page": "Page",
|
||||
"Source": "Source",
|
||||
"This is a folder; attach the files in it.": "C'est un dossier ; joignez les fichiers qu'il contient.",
|
||||
"This file is too large to send.": "Ce fichier est trop volumineux pour être envoyé.",
|
||||
"Sending…": "Envoi…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.",
|
||||
"Rename…": "Renommer…",
|
||||
"Open in the web interface": "Ouvrir dans l'interface web",
|
||||
"Delete this chat?": "Supprimer cette conversation ?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "« %(title)s » est supprimée du serveur, avec ses messages et ses fichiers. C'est définitif.",
|
||||
"Wait until the files are sent.": "Attendez que les fichiers soient envoyés.",
|
||||
"Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation",
|
||||
"The agent is connecting to the server…": "L'agent se connecte au serveur…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "L'agent n'est pas connecté : %(why)s. Il réessaie.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "L'agent ne se connectera pas : %(why)s. Reliez à nouveau cet ordinateur.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s), mais n'est pas connecté : %(why)s",
|
||||
"agent connecting…": "agent en connexion…",
|
||||
"agent waiting": "agent en attente",
|
||||
"agent refused": "agent refusé",
|
||||
"Agent: %(why)s": "Agent : %(why)s",
|
||||
"Server files": "Fichiers du serveur",
|
||||
"Drop the files to attach them": "Déposez les fichiers pour les joindre",
|
||||
"Continue in a terminal…": "Continuer dans un terminal…",
|
||||
"Continue in a terminal": "Continuer dans un terminal",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Exécutez ceci dans un terminal : la session de l'agent s'y poursuit, dans le dossier de cette discussion, et la discussion la conserve.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Exécutez ceci dans un terminal de l'ordinateur sur lequel travaille cette discussion : la session de l'agent s'y poursuit, et la discussion la conserve.",
|
||||
},
|
||||
"hu": {
|
||||
"Menu": "Menü",
|
||||
@@ -669,7 +857,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "ügynök fut (szolgáltatás)",
|
||||
"agent stopped": "ügynök leállt",
|
||||
"agent: no folders": "ügynök: nincs mappa",
|
||||
"Agent stopped: %(why)s": "Az ügynök leállt: %(why)s",
|
||||
"update %(version)s": "frissítés: %(version)s",
|
||||
"update": "frissítés",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
|
||||
@@ -677,6 +864,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Ez nem szövegfájl; itt nem jeleníthető meg.",
|
||||
"Page": "Oldal",
|
||||
"Source": "Forrás",
|
||||
"This is a folder; attach the files in it.": "Ez egy mappa; a benne lévő fájlokat csatolja.",
|
||||
"This file is too large to send.": "Ez a fájl túl nagy a küldéshez.",
|
||||
"Sending…": "Küldés…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.",
|
||||
"Rename…": "Átnevezés…",
|
||||
"Open in the web interface": "Megnyitás a webes felületen",
|
||||
"Delete this chat?": "Törli ezt a csevegést?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” törlődik a szerverről, az üzeneteivel és fájljaival együtt. Ez nem vonható vissza.",
|
||||
"Wait until the files are sent.": "Várjon, amíg a fájlok elküldődnek.",
|
||||
"Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét",
|
||||
"The agent is connecting to the server…": "Az ügynök csatlakozik a szerverhez…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "Az ügynök nincs csatlakozva: %(why)s. Tovább próbálkozik.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "Az ügynök nem fog csatlakozni: %(why)s. Kapcsolja össze újra ezt a számítógépet.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Egy LLeMbas CLI szolgáltatás már fut itt (pid %(pid)s), de nincs csatlakozva: %(why)s",
|
||||
"agent connecting…": "ügynök csatlakozik…",
|
||||
"agent waiting": "ügynök vár",
|
||||
"agent refused": "ügynök elutasítva",
|
||||
"Agent: %(why)s": "Ügynök: %(why)s",
|
||||
"Server files": "Fájlok a szerveren",
|
||||
"Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz",
|
||||
"Continue in a terminal…": "Folytatás terminálban…",
|
||||
"Continue in a terminal": "Folytatás terminálban",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Futtassa ezt egy terminálban: az ügynök munkamenete ott folytatódik, ennek a csevegésnek a mappájában, és a csevegés megtartja.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Futtassa ezt egy terminálban azon a számítógépen, amelyen ez a csevegés dolgozik: az ügynök munkamenete ott folytatódik, és a csevegés megtartja.",
|
||||
},
|
||||
"it": {
|
||||
"Menu": "Menu",
|
||||
@@ -770,7 +981,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agente attivo (servizio)",
|
||||
"agent stopped": "agente fermato",
|
||||
"agent: no folders": "agente: nessuna cartella",
|
||||
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
|
||||
"update %(version)s": "aggiorna a %(version)s",
|
||||
"update": "aggiorna",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
|
||||
@@ -778,6 +988,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Questo non è un file di testo; non può essere mostrato qui.",
|
||||
"Page": "Pagina",
|
||||
"Source": "Sorgente",
|
||||
"This is a folder; attach the files in it.": "Questa è una cartella; alleghi i file che contiene.",
|
||||
"This file is too large to send.": "Questo file è troppo grande per essere inviato.",
|
||||
"Sending…": "Invio…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.",
|
||||
"Rename…": "Rinomina…",
|
||||
"Open in the web interface": "Apri nell'interfaccia web",
|
||||
"Delete this chat?": "Eliminare questa chat?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» viene eliminata dal server, con i suoi messaggi e file. Non si può annullare.",
|
||||
"Wait until the files are sent.": "Attenda che i file siano inviati.",
|
||||
"Type a command or a chat's title": "Digiti un comando o il titolo di una chat",
|
||||
"The agent is connecting to the server…": "L'agente si sta collegando al server…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "L'agente non è collegato: %(why)s. Continua a riprovare.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "L'agente non si collegherà: %(why)s. Colleghi di nuovo questo computer.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s), ma non è collegato: %(why)s",
|
||||
"agent connecting…": "agente in collegamento…",
|
||||
"agent waiting": "agente in attesa",
|
||||
"agent refused": "agente rifiutato",
|
||||
"Agent: %(why)s": "Agente: %(why)s",
|
||||
"Server files": "File sul server",
|
||||
"Drop the files to attach them": "Rilasci i file per allegarli",
|
||||
"Continue in a terminal…": "Continua in un terminale…",
|
||||
"Continue in a terminal": "Continua in un terminale",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Eseguilo in un terminale: la sessione dell'agente prosegue lì, nella cartella di questa chat, e la chat la conserva.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Eseguilo in un terminale sul computer su cui lavora questa chat: la sessione dell'agente prosegue lì e la chat la conserva.",
|
||||
},
|
||||
"pl": {
|
||||
"Menu": "Menu",
|
||||
@@ -871,7 +1105,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agent działa (usługa)",
|
||||
"agent stopped": "agent zatrzymany",
|
||||
"agent: no folders": "agent: brak folderów",
|
||||
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
|
||||
"update %(version)s": "aktualizacja %(version)s",
|
||||
"update": "aktualizacja",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
|
||||
@@ -879,6 +1112,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "To nie jest plik tekstowy; nie można go tu pokazać.",
|
||||
"Page": "Strona",
|
||||
"Source": "Źródło",
|
||||
"This is a folder; attach the files in it.": "To jest folder; dołącz pliki, które zawiera.",
|
||||
"This file is too large to send.": "Ten plik jest za duży, aby go wysłać.",
|
||||
"Sending…": "Wysyłanie…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.",
|
||||
"Rename…": "Zmień nazwę…",
|
||||
"Open in the web interface": "Otwórz w interfejsie WWW",
|
||||
"Delete this chat?": "Usunąć ten czat?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” zostanie usunięty z serwera wraz z wiadomościami i plikami. Tego nie można cofnąć.",
|
||||
"Wait until the files are sent.": "Poczekaj, aż pliki zostaną wysłane.",
|
||||
"Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu",
|
||||
"The agent is connecting to the server…": "Agent łączy się z serwerem…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie jest połączony: %(why)s. Próbuje dalej.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent się nie połączy: %(why)s. Połącz ten komputer ponownie.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Usługa LLeMbas CLI już tu działa (pid %(pid)s), ale nie jest połączona: %(why)s",
|
||||
"agent connecting…": "agent łączy się…",
|
||||
"agent waiting": "agent czeka",
|
||||
"agent refused": "agent odrzucony",
|
||||
"Agent: %(why)s": "Agent: %(why)s",
|
||||
"Server files": "Pliki na serwerze",
|
||||
"Drop the files to attach them": "Upuść pliki, aby je dołączyć",
|
||||
"Continue in a terminal…": "Kontynuuj w terminalu…",
|
||||
"Continue in a terminal": "Kontynuuj w terminalu",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Uruchom to w terminalu: sesja agenta będzie tam kontynuowana, w folderze tego czatu, a czat ją zachowa.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Uruchom to w terminalu na komputerze, na którym pracuje ten czat: sesja agenta będzie tam kontynuowana, a czat ją zachowa.",
|
||||
},
|
||||
"sk": {
|
||||
"Menu": "Ponuka",
|
||||
@@ -972,7 +1229,6 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"agent running (service)": "agent beží (služba)",
|
||||
"agent stopped": "agent zastavený",
|
||||
"agent: no folders": "agent: žiadne priečinky",
|
||||
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
|
||||
"update %(version)s": "aktualizácia %(version)s",
|
||||
"update": "aktualizácia",
|
||||
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
|
||||
@@ -980,6 +1236,30 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"This is not a text file; it cannot be shown here.": "Toto nie je textový súbor; nedá sa tu zobraziť.",
|
||||
"Page": "Stránka",
|
||||
"Source": "Zdroj",
|
||||
"This is a folder; attach the files in it.": "Toto je priečinok; priložte súbory v ňom.",
|
||||
"This file is too large to send.": "Tento súbor je príliš veľký na odoslanie.",
|
||||
"Sending…": "Odosiela sa…",
|
||||
"Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.",
|
||||
"Rename…": "Premenovať…",
|
||||
"Open in the web interface": "Otvoriť vo webovom rozhraní",
|
||||
"Delete this chat?": "Zmazať túto konverzáciu?",
|
||||
"“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ sa zmaže na serveri aj so svojimi správami a súbormi. Nedá sa to vrátiť.",
|
||||
"Wait until the files are sent.": "Počkajte, kým sa súbory odošlú.",
|
||||
"Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie",
|
||||
"The agent is connecting to the server…": "Agent sa pripája k serveru…",
|
||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie je pripojený: %(why)s. Skúša to ďalej.",
|
||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent sa nepripojí: %(why)s. Prepojte tento počítač znova.",
|
||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI tu už beží (pid %(pid)s), ale nie je pripojená: %(why)s",
|
||||
"agent connecting…": "agent sa pripája…",
|
||||
"agent waiting": "agent čaká",
|
||||
"agent refused": "agent odmietnutý",
|
||||
"Agent: %(why)s": "Agent: %(why)s",
|
||||
"Server files": "Súbory na serveri",
|
||||
"Drop the files to attach them": "Pustite súbory a priložia sa",
|
||||
"Continue in a terminal…": "Pokračovať v termináli…",
|
||||
"Continue in a terminal": "Pokračovať v termináli",
|
||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spustite to v termináli: relácia agenta tam pokračuje, v priečinku tejto konverzácie, a konverzácia si ju ponechá.",
|
||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spustite to v termináli na počítači, na ktorom táto konverzácia pracuje: relácia agenta tam pokračuje a konverzácia si ju ponechá.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2085,7 +2085,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "llembas-desktop"
|
||||
version = "1.0.0-beta.1"
|
||||
version = "1.0.0-beta.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "llembas-desktop"
|
||||
version = "1.0.0-beta.1"
|
||||
version = "1.0.0-beta.3"
|
||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||
authors = ["Jaroslav Beneš"]
|
||||
license = "MIT"
|
||||
|
||||
+228
-12
@@ -4,6 +4,12 @@
|
||||
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
|
||||
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
|
||||
//! progress ends cleanly) when the agent is switched off or the app quits.
|
||||
//!
|
||||
//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While
|
||||
//! it runs, the state is what the service writes to its `service.json` (connecting, linked,
|
||||
//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last
|
||||
//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every
|
||||
//! second: it waits until linking again starts it.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
@@ -20,6 +26,8 @@ use crate::cli;
|
||||
const RESTART_FOR_UPDATE: i32 = 75;
|
||||
const BACKOFF: Duration = Duration::from_secs(10);
|
||||
const STOP_GRACE: Duration = Duration::from_secs(15);
|
||||
/// How long to wait before trying again after the CLI said trying again will not help.
|
||||
const REFUSED_WAIT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||
@@ -28,25 +36,83 @@ pub enum State {
|
||||
Off,
|
||||
/// This computer is not signed in to the server yet.
|
||||
NotLinked,
|
||||
/// The process is started; the CLI has not said anything of its link yet.
|
||||
Starting,
|
||||
/// The CLI is dialling the server.
|
||||
Connecting,
|
||||
/// The CLI says it is linked: agent chats can use this computer.
|
||||
Running,
|
||||
/// Another `lembas service` on this computer holds the link (its pid).
|
||||
Elsewhere(u32),
|
||||
/// It ended with an error; trying again shortly.
|
||||
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||
/// the server cannot be reached, the link dropped).
|
||||
Waiting(String),
|
||||
/// The CLI will not try again by itself: the server refused this computer's token, or the
|
||||
/// login is missing. Linking again is the way out.
|
||||
Refused(String),
|
||||
/// Another `lembas service` on this computer holds the link.
|
||||
Elsewhere(Elsewhere),
|
||||
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A `lembas service` this app did not start, and what it says of its link.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Elsewhere {
|
||||
pub pid: u32,
|
||||
/// It says it is linked.
|
||||
pub linked: bool,
|
||||
/// Why not, when it is not (its own words; empty while it has said nothing).
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
State::Off => "Agent: off".into(),
|
||||
State::NotLinked => "Agent: this computer is not linked".into(),
|
||||
State::Starting => "Agent: starting…".into(),
|
||||
State::Connecting => "Agent: connecting…".into(),
|
||||
State::Running => "Agent: running".into(),
|
||||
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"),
|
||||
State::Waiting(why) => format!("Agent: waiting — {why}"),
|
||||
State::Refused(why) => format!("Agent: refused — {why}"),
|
||||
State::Elsewhere(e) if e.linked => {
|
||||
format!("Agent: running as a service (pid {})", e.pid)
|
||||
}
|
||||
State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid),
|
||||
State::Failed(why) => format!("Agent: stopped — {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a state the CLI's own status file decides, while the process runs.
|
||||
fn told_by_cli(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
State::Starting | State::Connecting | State::Running | State::Waiting(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
|
||||
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
|
||||
/// every try, `waiting` between them) instead of flickering between the two.
|
||||
pub fn from_link(link: &cli::Link, before: &State) -> State {
|
||||
let why = link.detail.clone().unwrap_or_default();
|
||||
match link.state.as_str() {
|
||||
"linked" => State::Running,
|
||||
"connecting" => match before {
|
||||
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
|
||||
_ => State::Connecting,
|
||||
},
|
||||
"waiting" => State::Waiting(why),
|
||||
"stopped" => State::Refused(why),
|
||||
_ => State::Starting,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a running process has said before its status file says anything: the one line the
|
||||
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||
fn from_output(line: &str) -> Option<State> {
|
||||
line.contains("holds the link")
|
||||
.then(|| State::Waiting(line.trim().to_string()))
|
||||
}
|
||||
|
||||
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
||||
@@ -124,15 +190,33 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
|
||||
if !self.set(generation, State::Elsewhere(pid)) {
|
||||
// Not ours, but this computer's agent all the same: say what it says.
|
||||
let link = cli::link_of(pid);
|
||||
let linked = link.as_ref().is_some_and(|l| l.state == "linked");
|
||||
let why = link
|
||||
.map(|l| match l.detail {
|
||||
Some(d) if !d.is_empty() => d,
|
||||
_ => l.state,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !self.set(
|
||||
generation,
|
||||
State::Elsewhere(Elsewhere {
|
||||
pid,
|
||||
linked,
|
||||
why: if linked { String::new() } else { why },
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
self.pause(generation, Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
if !self.set(generation, State::Starting) {
|
||||
return;
|
||||
}
|
||||
// Where this run's output starts in the log: its last line is its reason.
|
||||
let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0);
|
||||
let child = match self.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -146,6 +230,7 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pid = child.id();
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
@@ -155,23 +240,39 @@ impl Agent {
|
||||
}
|
||||
g.child = Some(child);
|
||||
}
|
||||
self.set(generation, State::Running);
|
||||
let code = self.wait_child(generation);
|
||||
let code = self.wait_child(generation, pid, from);
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return;
|
||||
}
|
||||
g.child = None;
|
||||
drop(g);
|
||||
let said = last_line(&self.log, from);
|
||||
match code {
|
||||
Some(RESTART_FOR_UPDATE) => continue,
|
||||
Some(0) => {
|
||||
// It ended by itself without an error: nothing to do but start it again.
|
||||
self.pause(generation, Duration::from_secs(1));
|
||||
// It ended by itself without an error: the CLI's word for "starting again
|
||||
// will not help" -- the server refused the token, or there is no login.
|
||||
// Waiting a long while (linking again starts it at once).
|
||||
let why = cli::link_of(pid)
|
||||
.filter(|l| l.state == "stopped")
|
||||
.and_then(|l| l.detail)
|
||||
.or_else(|| said.map(|s| strip_prefix(&s)));
|
||||
match why {
|
||||
Some(why) => {
|
||||
if !self.set(generation, State::Refused(why)) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, REFUSED_WAIT);
|
||||
}
|
||||
None => self.pause(generation, Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let why = other.map_or("ended by a signal".to_string(), |c| {
|
||||
let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
|
||||
other.map_or("ended by a signal".to_string(), |c| {
|
||||
format!("exit status {c}")
|
||||
})
|
||||
});
|
||||
if !self.set(generation, State::Failed(why)) {
|
||||
return;
|
||||
@@ -217,7 +318,11 @@ impl Agent {
|
||||
}
|
||||
|
||||
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
|
||||
fn wait_child(&self, generation: u64) -> Option<i32> {
|
||||
/// Meanwhile the state shown is what the CLI says of its link: its status file once it has
|
||||
/// written one, before that the line it printed (a terminal holding the link).
|
||||
fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option<i32> {
|
||||
// Every other poll (once a second) the status is read again.
|
||||
let mut read = true;
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
@@ -230,6 +335,21 @@ impl Agent {
|
||||
Some(Err(_)) | None => return None,
|
||||
}
|
||||
}
|
||||
if read {
|
||||
let now = self.state();
|
||||
if now.told_by_cli() {
|
||||
let next = match cli::link_of(pid) {
|
||||
Some(link) => from_link(&link, &now),
|
||||
None => last_line(&self.log, from)
|
||||
.and_then(|l| from_output(&l))
|
||||
.unwrap_or(State::Starting),
|
||||
};
|
||||
if !self.set(generation, next) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
read = !read;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -283,3 +403,99 @@ fn terminate(mut child: Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
|
||||
/// the file is read.
|
||||
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = File::open(log).ok()?;
|
||||
let len = f.metadata().ok()?.len();
|
||||
let start = from.max(len.saturating_sub(4096));
|
||||
if start >= len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(start)).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(len - start).read_to_end(&mut buf).ok()?;
|
||||
String::from_utf8_lossy(&buf)
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
|
||||
fn strip_prefix(line: &str) -> String {
|
||||
line.strip_prefix("lembas service: ")
|
||||
.unwrap_or(line)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||
cli::Link {
|
||||
state: state.into(),
|
||||
detail: detail.map(String::from),
|
||||
pid: 1,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_shown_is_the_clis_word_for_its_link() {
|
||||
let s = State::Starting;
|
||||
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
|
||||
assert_eq!(
|
||||
from_link(&link("connecting", None, 1), &s),
|
||||
State::Connecting
|
||||
);
|
||||
let waiting = from_link(
|
||||
&link("waiting", Some("could not reach the instance"), 1),
|
||||
&s,
|
||||
);
|
||||
assert_eq!(
|
||||
waiting,
|
||||
State::Waiting("could not reach the instance".into())
|
||||
);
|
||||
// The next dial keeps the reason it is waiting, rather than flicker.
|
||||
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
|
||||
assert_eq!(
|
||||
from_link(&link("stopped", Some("refused"), 2), &waiting),
|
||||
State::Refused("refused".into())
|
||||
);
|
||||
// Not running is never shown as running.
|
||||
for state in ["connecting", "waiting", "stopped", "?"] {
|
||||
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_holding_the_link_is_said() {
|
||||
assert_eq!(
|
||||
from_output("a terminal here holds the link (/remote); waiting for it to close"),
|
||||
Some(State::Waiting(
|
||||
"a terminal here holds the link (/remote); waiting for it to close".into()
|
||||
))
|
||||
);
|
||||
assert_eq!(from_output("linked"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_line_is_this_runs_own() {
|
||||
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
|
||||
std::fs::write(&p, "old run\nlinked\n").unwrap();
|
||||
let from = std::fs::metadata(&p).unwrap().len();
|
||||
assert_eq!(last_line(&p, from), None);
|
||||
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
|
||||
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
|
||||
.unwrap();
|
||||
let said = last_line(&p, from).unwrap();
|
||||
assert_eq!(said, "lembas service: not logged in");
|
||||
assert_eq!(strip_prefix(&said), "not logged in");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
|
||||
@@ -40,6 +42,37 @@ pub fn binary() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from(exe_name()))
|
||||
}
|
||||
|
||||
/// How a person starts the CLI in a terminal on this computer: `lembas` where one is on PATH
|
||||
/// (installed by hand: it shares the app's sessions, the directories are the same), else the
|
||||
/// bundled one by its full path, quoted for a POSIX shell. The bundled copy is never on PATH.
|
||||
pub fn terminal_word() -> String {
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
on_path(&path, exe_name()).map_or_else(
|
||||
|| shell_quote(&binary().to_string_lossy()),
|
||||
|_| exe_name().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn on_path(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
|
||||
std::env::split_paths(path)
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// A word for a POSIX shell: as it is when it is plainly safe, else in single quotes.
|
||||
pub fn shell_quote(word: &str) -> String {
|
||||
let plain = !word.is_empty()
|
||||
&& word.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '/' | '.' | '-' | '_' | '+' | ':' | '@' | '%' | '=')
|
||||
});
|
||||
if plain {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("'{}'", word.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
@@ -108,6 +141,32 @@ fn running_in(state: &Path) -> Option<u32> {
|
||||
(pid != 0 && alive(pid)).then_some(pid)
|
||||
}
|
||||
|
||||
/// What a running `lembas service run` says of its link to the server, as it writes it to
|
||||
/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting`
|
||||
/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again;
|
||||
/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the
|
||||
/// process that wrote it: a file left by an earlier run is not this one's word.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Link {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pid: u32,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
/// The link status the CLI's service wrote last, if it was `pid` that wrote it.
|
||||
pub fn link_of(pid: u32) -> Option<Link> {
|
||||
link_in(&state_dir()).filter(|l| l.pid == pid)
|
||||
}
|
||||
|
||||
fn link_in(state: &Path) -> Option<Link> {
|
||||
let text = std::fs::read_to_string(state.join("service.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// This computer's host name: what the CLI sent the server as `device_host` at login
|
||||
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
|
||||
#[cfg(unix)]
|
||||
@@ -151,6 +210,26 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_for_the_shell_is_quoted_only_when_it_must_be() {
|
||||
assert_eq!(
|
||||
shell_quote("/usr/lib/llembas-desktop/lembas"),
|
||||
"/usr/lib/llembas-desktop/lembas"
|
||||
);
|
||||
assert_eq!(shell_quote("/home/a b/lembas"), "'/home/a b/lembas'");
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cli_on_path_is_found_by_name() {
|
||||
let d = scratch("path");
|
||||
assert_eq!(on_path(d.as_os_str(), "lembas"), None);
|
||||
fs::write(d.join("lembas"), b"").unwrap();
|
||||
assert_eq!(on_path(d.as_os_str(), "lembas"), Some(d.join("lembas")));
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_reads_the_logins_the_cli_wrote() {
|
||||
let d = scratch("linked");
|
||||
@@ -166,6 +245,33 @@ mod tests {
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_status_is_read_as_the_service_writes_it() {
|
||||
let d = scratch("link");
|
||||
assert_eq!(link_in(&d), None);
|
||||
// As `lembas service run` writes it (src/service.ts): LinkStatus and its pid.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let l = link_in(&d).unwrap();
|
||||
assert_eq!(l.state, "waiting");
|
||||
assert_eq!(l.pid, 4242);
|
||||
assert_eq!(l.attempts, 3);
|
||||
assert!(l.detail.unwrap().starts_with("could not reach"));
|
||||
// `linked` carries no detail.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link_in(&d).unwrap().detail, None);
|
||||
fs::write(d.join("service.json"), "not json").unwrap();
|
||||
assert_eq!(link_in(&d), None);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_counts_only_while_its_process_lives() {
|
||||
|
||||
+228
-2
@@ -36,8 +36,8 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::window::Color;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, WebviewWindow,
|
||||
WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
@@ -114,6 +114,8 @@ struct App {
|
||||
next_stream: AtomicU64,
|
||||
sealed: sealed::Pages,
|
||||
watch: watch::Watch,
|
||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||
dropped: Mutex<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
struct TrayItems {
|
||||
@@ -423,6 +425,25 @@ async fn api_text(handle: AppHandle, path: String) -> Result<String, ApiError> {
|
||||
result
|
||||
}
|
||||
|
||||
/// A model's picture (`image` in `/api/v2/models`), as a `data:` URL: the page's policy allows
|
||||
/// images from `data:` and reaches the server only through the bridge.
|
||||
#[tauri::command]
|
||||
async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError> {
|
||||
let Some((app_url, token)) = handle.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || server::request_image(&app_url, &token, &path))
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
|
||||
/// first word of "continue in a terminal" for an agent chat on this computer.
|
||||
#[tauri::command]
|
||||
fn terminal_word() -> String {
|
||||
cli::terminal_word()
|
||||
}
|
||||
|
||||
/// A frame of a reply being written, as the page hears it.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Frame {
|
||||
@@ -481,6 +502,173 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Attachments ----------------------------------------------------------------------------------
|
||||
|
||||
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
||||
/// names the refusals made here, so the page can say them in its language; the server's own
|
||||
/// refusals come in the account's language already.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Uploaded {
|
||||
name: String,
|
||||
size: u64,
|
||||
attachment: Option<Value>,
|
||||
error: Option<String>,
|
||||
reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn read_for_upload(path: &std::path::Path) -> Result<Vec<u8>, (&'static str, String)> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| ("unreadable", e.to_string()))?;
|
||||
if meta.is_dir() {
|
||||
return Err(("folder", "This is a folder.".into()));
|
||||
}
|
||||
if meta.len() > server::UPLOAD_LIMIT {
|
||||
return Err(("too_large", "This file is too large to send.".into()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| ("unreadable", e.to_string()))
|
||||
}
|
||||
|
||||
/// Send these files, one by one, as attachments for `chat` ("" for a chat not made yet). Only
|
||||
/// ever called with paths the person chose: in the file dialog, or by dropping them on the window.
|
||||
fn upload_paths(
|
||||
handle: &AppHandle,
|
||||
paths: Vec<PathBuf>,
|
||||
chat: &str,
|
||||
) -> Result<Vec<Uploaded>, ApiError> {
|
||||
let Some((app_url, token)) = handle.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let mut out = 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(&app_url, &token, chat, &name, &bytes).map_err(|e| {
|
||||
if e.status == 401 {
|
||||
token_refused(handle);
|
||||
}
|
||||
(None, e.detail)
|
||||
}),
|
||||
Err((reason, message)) => Err((Some(reason), message)),
|
||||
};
|
||||
out.push(match sent {
|
||||
Ok(a) => Uploaded {
|
||||
name,
|
||||
size,
|
||||
attachment: Some(a),
|
||||
error: None,
|
||||
reason: None,
|
||||
},
|
||||
Err((reason, message)) => Uploaded {
|
||||
name,
|
||||
size,
|
||||
attachment: None,
|
||||
error: Some(message),
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Choose files with the system's dialog and send them as attachments for `chat`.
|
||||
#[tauri::command]
|
||||
async fn attach_pick(
|
||||
handle: AppHandle,
|
||||
chat: Option<String>,
|
||||
title: String,
|
||||
) -> Result<Vec<Uploaded>, 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();
|
||||
upload_paths(&h, paths, chat.as_deref().unwrap_or(""))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// Send the files just dropped on the window as attachments for `chat`. Each drop can be sent
|
||||
/// once.
|
||||
#[tauri::command]
|
||||
async fn attach_dropped(
|
||||
handle: AppHandle,
|
||||
chat: Option<String>,
|
||||
) -> Result<Vec<Uploaded>, ApiError> {
|
||||
let paths = std::mem::take(&mut *handle.state::<App>().dropped.lock().unwrap());
|
||||
let h = handle.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
upload_paths(&h, paths, chat.as_deref().unwrap_or(""))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?
|
||||
}
|
||||
|
||||
/// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it).
|
||||
fn percent_decode(text: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
if b[i] == b'%' && i + 2 < b.len() {
|
||||
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// A file pasted into the composer (an image from the clipboard): its bytes as the raw body of
|
||||
/// the call, its name in `x-name` and the chat in `x-chat`. The page has these bytes only because
|
||||
/// the person pasted them.
|
||||
#[tauri::command]
|
||||
async fn attach_bytes(
|
||||
handle: AppHandle,
|
||||
request: tauri::ipc::Request<'_>,
|
||||
) -> Result<Value, ApiError> {
|
||||
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
|
||||
return Err(ApiError::new(400, "No file."));
|
||||
};
|
||||
if bytes.len() as u64 > server::UPLOAD_LIMIT {
|
||||
return Err(ApiError::new(413, "This file is too large to send."));
|
||||
}
|
||||
let header = |k: &str| {
|
||||
request
|
||||
.headers()
|
||||
.get(k)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
let name = percent_decode(&header("x-name"));
|
||||
let chat = header("x-chat");
|
||||
let bytes = bytes.clone();
|
||||
let Some((app_url, token)) = handle.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
server::upload(&app_url, &token, &chat, &name, &bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||
if matches!(&result, Err(e) if e.status == 401) {
|
||||
token_refused(&handle);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// --- This computer's files ----------------------------------------------------------------------
|
||||
|
||||
/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder
|
||||
@@ -1254,12 +1442,17 @@ pub fn run() {
|
||||
sign_out,
|
||||
api,
|
||||
api_text,
|
||||
model_image,
|
||||
terminal_word,
|
||||
stream_open,
|
||||
stream_close,
|
||||
local_list,
|
||||
local_read,
|
||||
sealed_open,
|
||||
sealed_close,
|
||||
attach_pick,
|
||||
attach_dropped,
|
||||
attach_bytes,
|
||||
set_theme,
|
||||
set_notify,
|
||||
set_open_chat,
|
||||
@@ -1314,6 +1507,7 @@ pub fn run() {
|
||||
next_stream: AtomicU64::new(0),
|
||||
sealed: sealed::Pages::default(),
|
||||
watch: watch::Watch::default(),
|
||||
dropped: Mutex::new(Vec::new()),
|
||||
});
|
||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||
// starts: the title bar's menu has every item the tray has.
|
||||
@@ -1345,6 +1539,30 @@ pub fn run() {
|
||||
WindowEvent::Resized(_) if window.label() == MAIN => {
|
||||
let _ = window.emit("maximized", window.is_maximized().unwrap_or(false));
|
||||
}
|
||||
// Files dragged onto the window: the page shows where they go, and a drop is held
|
||||
// here for `attach_dropped` -- the page itself never names a path to send.
|
||||
WindowEvent::DragDrop(ev) if window.label() == MAIN => match ev {
|
||||
DragDropEvent::Enter { .. } => {
|
||||
let _ = window.emit("drag", true);
|
||||
}
|
||||
DragDropEvent::Leave => {
|
||||
let _ = window.emit("drag", false);
|
||||
}
|
||||
DragDropEvent::Drop { paths, .. } => {
|
||||
let names: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
*window.state::<App>().dropped.lock().unwrap() = paths.clone();
|
||||
let _ = window.emit("drag", false);
|
||||
let _ = window.emit("drop", names);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
})
|
||||
.build(tauri::generate_context!());
|
||||
@@ -1407,6 +1625,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_files_name_comes_back_whole() {
|
||||
assert_eq!(percent_decode("Sch%C3%B6n%20bild.png"), "Schön bild.png");
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
assert_eq!(percent_decode("a%zzb%4"), "a%zzb%4");
|
||||
assert_eq!(percent_decode("ž%41"), "žA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_lives_in_the_apps_own_directory() {
|
||||
let f = log::file().unwrap();
|
||||
|
||||
@@ -507,6 +507,161 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The most a model's picture may be: the server's own limit for one (2 MB), and a little room.
|
||||
pub const IMAGE_LIMIT: u64 = 2 * 1024 * 1024 + 4096;
|
||||
|
||||
/// The pictures a model may have, by the type the server serves them as: the four it accepts on
|
||||
/// upload. Nothing else is shown, whatever the server says it is.
|
||||
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
|
||||
/// A model's picture path as `/api/v2/models` names it (`/api/v2/models/images/<file>`), on the
|
||||
/// app's door: `/models/images/<file>`, or None for anything else. One plain file name, no more.
|
||||
pub fn image_path(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
||||
let name = rest.strip_prefix("/models/images/")?;
|
||||
let plain = !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
&& !name.starts_with('.');
|
||||
plain.then(|| format!("/models/images/{name}"))
|
||||
}
|
||||
|
||||
/// A model's picture, as a `data:` URL the page can show without a request of its own (its
|
||||
/// policy allows images from `data:` and nowhere else on the network). Read with the app's
|
||||
/// token, as the page reads nothing itself.
|
||||
pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
||||
let Some(path) = image_path(path) else {
|
||||
return Err(ApiError::new(400, format!("Not a model's picture: {path}")));
|
||||
};
|
||||
let res = agent(Duration::from_secs(30))
|
||||
.get(&format!("{app}{path}"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.call()?;
|
||||
let kind = res
|
||||
.content_type()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !IMAGE_TYPES.contains(&kind.as_str()) {
|
||||
return Err(ApiError::new(415, format!("Not a picture: {kind}")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
res.into_reader()
|
||||
.take(IMAGE_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 >= IMAGE_LIMIT {
|
||||
return Err(ApiError::new(413, "The picture is too large."));
|
||||
}
|
||||
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||
}
|
||||
|
||||
/// Standard base64 with padding (RFC 4648 §4): a picture's bytes in a `data:` URL.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
const ABC: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
out.push(ABC[((n >> 18) & 63) as usize] as char);
|
||||
out.push(ABC[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 {
|
||||
ABC[((n >> 6) & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
ABC[(n & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The most a file sent as an attachment may be here. The server has its own limit (20 MB by
|
||||
/// default, an administrator may change it) and says so in its words; this one only keeps a
|
||||
/// file far beyond any server's limit from being read into memory at all.
|
||||
pub const UPLOAD_LIMIT: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// A file's name as it may stand in a multipart header: no quotes, backslashes or line breaks.
|
||||
fn header_name(name: &str) -> String {
|
||||
let clean: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' | '\\' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
if clean.trim().is_empty() {
|
||||
"file".into()
|
||||
} else {
|
||||
clean
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of a `multipart/form-data` request with one file in the field `file`.
|
||||
fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(bytes.len() + 256);
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
header_name(name)
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// Send one file to `/api/v2/attachments` (for the chat `chat`, or a chat not made yet): stored
|
||||
/// now, bound to the message that names it when that is sent. The server's attachment, as JSON.
|
||||
pub fn upload(
|
||||
app: &str,
|
||||
token: &str,
|
||||
chat: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Value, ApiError> {
|
||||
if !chat
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ApiError::new(400, format!("Not a chat: {chat}")));
|
||||
}
|
||||
let path = if chat.is_empty() {
|
||||
"/attachments".to_string()
|
||||
} else {
|
||||
format!("/attachments?chat={chat}")
|
||||
};
|
||||
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!("{app}{path}"))
|
||||
.set("authorization", &format!("Bearer {token}"))
|
||||
.set("accept", "application/json")
|
||||
.set(
|
||||
"content-type",
|
||||
&format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.send_bytes(&multipart(&boundary, name, bytes))?;
|
||||
let v: Value = res
|
||||
.into_json()
|
||||
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
|
||||
v.get("attachment")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string()))
|
||||
}
|
||||
|
||||
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||
#[derive(Default)]
|
||||
pub struct Sse {
|
||||
@@ -709,6 +864,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_models_picture_is_one_file_on_the_app_door() {
|
||||
assert_eq!(
|
||||
image_path("/api/v2/models/images/0a1b2c3d4e5f.png").as_deref(),
|
||||
Some("/models/images/0a1b2c3d4e5f.png")
|
||||
);
|
||||
assert_eq!(
|
||||
image_path("/models/images/a-b_c.webp").as_deref(),
|
||||
Some("/models/images/a-b_c.webp")
|
||||
);
|
||||
for bad in [
|
||||
"/api/v2/models/images/",
|
||||
"/api/v2/models/images/../me",
|
||||
"/api/v2/models/images/a/b.png",
|
||||
"/api/v2/models/images/.hidden",
|
||||
"/api/v2/chats/x",
|
||||
"/uploads/models/a.png",
|
||||
"https://elsewhere/models/images/a.png",
|
||||
] {
|
||||
assert!(image_path(bad).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_is_the_standard_alphabet_with_padding() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_goes_as_one_multipart_field() {
|
||||
let body = multipart("XYZ", "a\"b\r\n.txt", b"hello");
|
||||
let text = String::from_utf8(body).unwrap();
|
||||
assert!(text.starts_with(
|
||||
"--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n"
|
||||
));
|
||||
assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n"));
|
||||
assert_eq!(header_name(" "), "file");
|
||||
assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_sent_events_come_out_whole() {
|
||||
let mut sse = Sse::default();
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"tag": "v2.0.0-beta.1",
|
||||
"commit": "a3b0e660cc4de3d25d75e560afd8441d0fa37a00",
|
||||
"sha256": "f66cc1ccde81269200a6ca1a17f6ced6e8ccdf77353c8c8e7f74196762507db3"
|
||||
"tag": "v2.0.0-beta.5",
|
||||
"commit": "02d0c874434e3b726193bf94fb9741b4f8d0a22c",
|
||||
"sha256": "9c40356cb6f89bf978b9034ef1ec0fb9b46c18790be263752024c1b1552d4ac4"
|
||||
}
|
||||
+455
-14
@@ -1,6 +1,7 @@
|
||||
/* LLeMbas Desktop's page. Every colour, size, space and font is a token from tokens.css (the
|
||||
server's, copied by scripts/fetch-tokens.sh and pinned); the only values of this page's own are
|
||||
the title bar's height and the setup card's width, named below. Media queries cannot read a
|
||||
the title bar's height, the setup card's width and the activity panel's number columns, named
|
||||
below. Media queries cannot read a
|
||||
custom property, so the two breakpoints are written out and named after the tokens they are:
|
||||
48rem is --bp-narrow (the sidebar becomes a drawer), 64rem is --bp-wide (the viewer goes over
|
||||
the chat instead of beside it).
|
||||
@@ -18,6 +19,10 @@
|
||||
--card-max: 40rem;
|
||||
/* The sidebar toggle's track in the title bar: none until the sidebar is a drawer. */
|
||||
--side-btn-w: 0px;
|
||||
/* The activity panel's number columns: a token count or a share ("180k", "63.0%"), and an
|
||||
elapsed time ("2h 03m"), in the panel's monospaced figures. */
|
||||
--num-col: 4.5em;
|
||||
--time-col: 5.5em;
|
||||
}
|
||||
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; }
|
||||
@@ -64,8 +69,10 @@ bdi { unicode-bidi: isolate; }
|
||||
.btn--primary { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
|
||||
.btn--primary:hover { background: var(--accent-hover); }
|
||||
.btn--sm { height: var(--control-h-sm); padding: 0 var(--sp-3); box-shadow: none; }
|
||||
.btn--danger { color: var(--ink-inverse); background: var(--danger); border-color: var(--danger); }
|
||||
.btn--danger:hover { background: var(--danger-hover); }
|
||||
|
||||
input[type="text"], select, textarea {
|
||||
input[type="text"], input[type="number"], select, textarea {
|
||||
min-width: 0;
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
color: var(--ink);
|
||||
@@ -73,7 +80,7 @@ input[type="text"], select, textarea {
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
input[type="text"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
|
||||
input[type="text"], input[type="number"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
|
||||
textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); }
|
||||
:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); }
|
||||
|
||||
@@ -180,8 +187,9 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
.bar__side { overflow: hidden; visibility: hidden; }
|
||||
.bar__agent { display: grid; grid-template-columns: var(--dot-size) auto; gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3) !important; font-weight: 400 !important; }
|
||||
.bar__agent[data-state="running"] .dot, .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
|
||||
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
|
||||
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="not_linked"] .dot, .bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
|
||||
.bar__agent[data-state="starting"] .dot, .bar__agent[data-state="connecting"] .dot { background: var(--accent); }
|
||||
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="not_linked"] .dot, .bar__agent[data-state="no_folders"] .dot,
|
||||
.bar__agent[data-state="waiting"] .dot, .bar__agent[data-state="refused"] .dot, .bar__agent[data-state="elsewhere_waiting"] .dot { background: var(--warning); }
|
||||
.bar__update { padding: 0 var(--sp-3) !important; color: var(--accent-ink) !important; background: var(--accent) !important; }
|
||||
.bar__update:hover { background: var(--accent-hover) !important; }
|
||||
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
|
||||
@@ -373,7 +381,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
|
||||
/* --- The chat ------------------------------------------------------------------------------ */
|
||||
|
||||
.main, .settings { grid-area: main; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; }
|
||||
.main, .settings { grid-area: main; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto auto; }
|
||||
.settings { grid-template-rows: auto minmax(0, 1fr); }
|
||||
|
||||
.head {
|
||||
@@ -417,6 +425,118 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
.msg__files { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: var(--sp-1); font-size: var(--text-xs); }
|
||||
.msg__note { font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
.msg__foot { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
|
||||
.msg__files .chip { max-width: var(--w-chip); overflow: hidden; text-overflow: ellipsis; }
|
||||
/* Under a message: small icon buttons, all one size. */
|
||||
.msg__actions { display: grid; grid-auto-flow: column; grid-auto-columns: var(--control-h-sm); justify-content: start; }
|
||||
.msg--user .msg__actions { justify-content: end; margin-top: calc(-1 * var(--sp-2)); }
|
||||
.msg__act { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); padding: 0; color: var(--ink-faint); background: none; border: 0; border-radius: var(--radius); cursor: pointer; }
|
||||
.msg__act:hover, .msg__act:focus-visible { color: var(--ink); background: var(--surface-hover); }
|
||||
/* Regenerate: on the last reply only, and not while it is being written. */
|
||||
.msg__regen { display: none; }
|
||||
.messages > .msg--assistant:last-child:not(.is-live) .msg__regen { display: grid; }
|
||||
.msg__editor { display: grid; gap: var(--sp-2); }
|
||||
.msg__edit { width: 100%; min-height: var(--control-h); font: var(--text-base) / var(--leading-normal) var(--font-body); resize: vertical; }
|
||||
.msg__editbar { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(100%, 6rem), auto); gap: var(--sp-2); justify-content: end; }
|
||||
.chip--model { display: inline-grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1); align-items: center; max-width: var(--w-chip); padding-left: var(--sp-1); }
|
||||
.chip--model > span:last-child { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* A model's avatar: its initial on its own colour. Only the hue is the model's (models.js, the
|
||||
server's stable_hue); how strong it is comes from the theme, by mixing it into the surface. */
|
||||
.avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font: 700 var(--text-xs) / 1 var(--font-ui);
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklab, hsl(var(--avatar-hue, 40) 100% 50%) 32%, var(--surface));
|
||||
border-radius: var(--radius-sm);
|
||||
flex: none;
|
||||
}
|
||||
.avatar--none { background: var(--surface-active); }
|
||||
/* A model's own picture fills the same square, cut to it: the avatar's size does not depend on it. */
|
||||
.avatar--image { overflow: hidden; background: var(--surface); }
|
||||
.avatar--image img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
/* The Thinking switch: a checkbox drawn as a switch, then its words, on two known tracks. The
|
||||
label is the tap target; the whole of it is a control's height. */
|
||||
.toggle {
|
||||
display: inline-grid;
|
||||
grid-template-columns: calc(2 * var(--check-size)) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
height: var(--control-h);
|
||||
color: var(--ink);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toggle--compact { height: var(--control-h-sm); }
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
width: calc(2 * var(--check-size));
|
||||
height: var(--check-size);
|
||||
margin: 0;
|
||||
background: var(--surface-active);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-1) var(--ease-out);
|
||||
}
|
||||
.toggle input::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: var(--border-w);
|
||||
left: var(--border-w);
|
||||
width: calc(var(--check-size) - 4 * var(--border-w));
|
||||
height: calc(var(--check-size) - 4 * var(--border-w));
|
||||
background: var(--ink-muted);
|
||||
border-radius: var(--radius-full);
|
||||
transition: transform var(--dur-1) var(--ease-out);
|
||||
}
|
||||
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
||||
.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
|
||||
its word before them -- fixed tracks, so nothing moves when a number grows a digit. */
|
||||
.agents {
|
||||
display: inline-grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--control-h);
|
||||
}
|
||||
.agents__dash { color: var(--ink-muted); }
|
||||
.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); }
|
||||
.agents--compact {
|
||||
grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2);
|
||||
width: auto;
|
||||
height: var(--control-h-sm);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
}
|
||||
.agents--compact .agents__input { height: var(--control-h-sm); }
|
||||
.agents__label { color: var(--ink-muted); white-space: nowrap; }
|
||||
/* The model picker: a button the height of a control, avatar · name · chevron. */
|
||||
.picker {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
height: var(--control-h);
|
||||
max-width: 100%;
|
||||
padding: 0 var(--control-px-sm);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
background: var(--bg);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.head__model { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
|
||||
.head__model .picker { height: var(--control-h-sm); max-width: var(--w-chip); }
|
||||
.new .picker { width: 100%; }
|
||||
.msg__metrics { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
|
||||
.msg__steps { display: grid; gap: var(--sp-3); min-width: 0; }
|
||||
.msg__status, .msg__thinking { font: var(--text-xs) var(--font-ui); }
|
||||
@@ -548,17 +668,57 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
.plan__phase { font-weight: 600; }
|
||||
.plan__task.is-done { text-decoration: line-through; color: var(--ink-muted); }
|
||||
|
||||
/* The composer: the text and its buttons, on two tracks; the buttons hold their own row. */
|
||||
/* The composer: the files it carries on a row of their own, then three tracks -- the attach
|
||||
button, the text, the send buttons. The buttons are one control high and sit at the foot of
|
||||
the row the text sets the height of: their size is known, the text's is not. */
|
||||
.composer {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: end;
|
||||
grid-template-columns: var(--control-h) minmax(0, 1fr) auto;
|
||||
grid-template-areas: "files files files" "attach text actions";
|
||||
/* No gap between the rows: the files' row has its own margin, and none while it is empty. */
|
||||
gap: 0 var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
.composer[data-attach="false"] { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "files files" "text actions"; }
|
||||
.composer > * { min-width: 0; }
|
||||
.composer textarea { width: 100%; min-height: var(--control-h); max-height: 40vh; font: var(--text-base) / var(--leading-normal) var(--font-body); }
|
||||
.composer__actions { display: grid; grid-auto-flow: column; gap: var(--sp-2); }
|
||||
.composer textarea { grid-area: text; width: 100%; min-height: var(--control-h); max-height: 40vh; font: var(--text-base) / var(--leading-normal) var(--font-body); }
|
||||
.composer__attach { grid-area: attach; align-self: end; width: var(--control-h); padding: 0; }
|
||||
.composer__actions { grid-area: actions; align-self: end; display: grid; grid-auto-flow: column; gap: var(--sp-2); }
|
||||
.composer__files { grid-area: files; margin: 0 0 var(--sp-2); padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--w-chip)), 1fr)); gap: var(--sp-2); }
|
||||
/* One file: name · size or state · remove, the same tracks on every row. */
|
||||
.file {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
height: var(--control-h-sm);
|
||||
padding-left: var(--sp-2);
|
||||
font: var(--text-xs) var(--font-ui);
|
||||
background: var(--surface);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.file__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
|
||||
.file__state { color: var(--ink-muted); max-width: var(--w-chip); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.file.is-failed { border-color: var(--warning); }
|
||||
.file.is-failed .file__state { color: var(--warning); }
|
||||
.file__remove { display: grid; place-items: center; height: 100%; padding: 0; color: var(--ink-muted); background: none; border: 0; border-left: var(--border-w) solid var(--border); cursor: pointer; }
|
||||
.file__remove:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
/* Files dragged over the window: where they will go, over the whole composer. */
|
||||
.composer__drop { display: none; }
|
||||
.composer[data-drag="true"] .composer__drop {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: absolute;
|
||||
inset: var(--sp-2) var(--sp-4);
|
||||
z-index: var(--z-raised);
|
||||
font: 600 var(--text-sm) var(--font-ui);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border: var(--border-w-thick) dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* --- Settings ------------------------------------------------------------------------------ */
|
||||
|
||||
@@ -625,12 +785,285 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
.viewer__note { padding: var(--sp-4); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
.viewer__note.is-error { color: var(--warning); }
|
||||
|
||||
/* --- The activity panel -------------------------------------------------------------------------
|
||||
Beside the chat, the inspector's width; while the viewer is open too, the viewer goes over the
|
||||
chat (it was opened from here, and three columns of text beside the sidebar do not fit). Over
|
||||
the chat from --bp-wide down, as the viewer is; at --bp-narrow that is a full-height sheet. */
|
||||
|
||||
.shell[data-activity="true"],
|
||||
.shell[data-activity="true"][data-viewer="true"] {
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--inspector-width);
|
||||
grid-template-areas: "side main activity";
|
||||
}
|
||||
.shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||
.activity {
|
||||
grid-area: activity;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-left: var(--border-w) solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* Title and close on two known tracks; with an agent's conversation open, Back before them. */
|
||||
.activity__head { grid-template-columns: minmax(0, 1fr) var(--control-h-sm); }
|
||||
.activity[data-view="agent"] .activity__head { grid-template-columns: var(--control-h-sm) minmax(0, 1fr) var(--control-h-sm); }
|
||||
.activity__body { overflow: auto; min-height: 0; display: grid; align-content: start; font-size: var(--text-sm); }
|
||||
.activity__section { padding: var(--sp-3); border-bottom: var(--border-w) solid var(--border); min-width: 0; }
|
||||
.activity__section:last-child { border-bottom: 0; }
|
||||
.activity__heading, .activity__subheading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity__heading::-webkit-details-marker { display: none; }
|
||||
.activity__section[open] > .activity__heading { margin-bottom: var(--sp-3); }
|
||||
.activity__subheading { margin: var(--sp-4) 0 var(--sp-2); cursor: default; }
|
||||
.activity__count { font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
|
||||
.activity__empty { margin: 0; padding: var(--sp-1) 0; color: var(--ink-faint); font-size: var(--text-sm); }
|
||||
.activity__empty.is-error { color: var(--warning); padding: var(--sp-3); }
|
||||
.activity__plan { min-width: 0; }
|
||||
|
||||
/* Context: the line, the bar, then one row per category on four fixed tracks -- swatch, the name
|
||||
that gives, and two number columns -- so the numbers stand in columns in any language. The
|
||||
categories' colours are tokens, one each. */
|
||||
.ctx { display: grid; gap: var(--sp-2); }
|
||||
.ctx__used { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); margin: 0; color: var(--ink); }
|
||||
.ctx__of { font-family: var(--font-mono); color: var(--ink-muted); }
|
||||
.ctx__bar { display: flex; height: var(--sp-2); overflow: hidden; background: var(--surface-active); border-radius: var(--radius-full); }
|
||||
.ctx__seg { flex: none; width: var(--share, 0); background: var(--ctx, var(--ink-faint)); }
|
||||
.ctx__rows { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.ctx__row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sp-3) minmax(0, 1fr) var(--num-col) var(--num-col);
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
min-height: var(--row-h);
|
||||
}
|
||||
.ctx__key { width: var(--sp-3); height: var(--sp-3); border-radius: var(--radius-sm); background: var(--ctx, var(--ink-faint)); }
|
||||
.ctx--free.ctx__key { background: transparent; border: var(--border-w) solid var(--border-strong); }
|
||||
.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__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
|
||||
.ctx--messages { --ctx: var(--accent); }
|
||||
.ctx--system { --ctx: var(--ink-muted); }
|
||||
.ctx--memory { --ctx: var(--warning); }
|
||||
.ctx--instructions { --ctx: var(--success); }
|
||||
.ctx--tools { --ctx: var(--ink); }
|
||||
.ctx--mcp_tools { --ctx: var(--danger); }
|
||||
.ctx--since { --ctx: var(--ink-faint); }
|
||||
.ctx--buffer { --ctx: var(--border-strong); }
|
||||
.ctx--free { --ctx: transparent; }
|
||||
|
||||
/* Outputs: icon, the name (cut, never wrapped), the lines added and removed. */
|
||||
.outs { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.outs__row { display: grid; grid-template-columns: var(--sp-4) minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; min-height: var(--row-h); color: var(--ink-muted); }
|
||||
.outs__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 0; font: var(--text-sm) var(--font-ui); color: var(--ink); text-align: left; background: none; border: 0; cursor: pointer; }
|
||||
.outs__name:hover { color: var(--accent); text-decoration: underline; }
|
||||
.outs__diff { display: grid; grid-auto-flow: column; gap: var(--sp-2); font: var(--text-xs) var(--font-mono); }
|
||||
.outs__add { color: var(--success); }
|
||||
.outs__del { color: var(--danger); }
|
||||
|
||||
/* Background tasks. An agent's row is one grid of known tracks: state dot, what it is (the one
|
||||
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. */
|
||||
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
|
||||
.tasks__summary, .tasks__team { margin: 0; color: var(--ink-muted); }
|
||||
.tasks__goal { margin: 0; color: var(--ink); }
|
||||
.tasks__list { display: grid; gap: var(--sp-1); }
|
||||
.tasks__more > summary { cursor: pointer; padding: var(--sp-1) 0; color: var(--ink-muted); }
|
||||
.tasks__more[open] > summary { margin-bottom: var(--sp-1); }
|
||||
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius); min-width: 0; }
|
||||
.agent__row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dot-size) minmax(0, 1fr) var(--time-col) var(--control-h-sm);
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 var(--sp-1) 0 var(--sp-3);
|
||||
cursor: pointer;
|
||||
list-style: 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.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__name, .agent__role { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.agent__name { color: var(--ink); }
|
||||
.agent__role { font-size: var(--text-xs); 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__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__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__state { color: var(--ink); }
|
||||
.agent__model { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||
.agent__brief { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); }
|
||||
.agent__step { display: grid; grid-template-columns: var(--dot-size) auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; margin: 0; }
|
||||
.agent__verb { color: var(--ink); white-space: nowrap; }
|
||||
.agent__title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
|
||||
.agent__text { margin: 0; max-height: calc(6 * var(--row-h)); overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
|
||||
/* The team's room: who to whom, a report marked, the words as markdown. */
|
||||
.room { display: grid; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; }
|
||||
.room__entry { display: grid; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius); min-width: 0; }
|
||||
.room__entry.is-report { border-left: var(--border-w-thick) solid var(--leaf); }
|
||||
.room__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||
.room__n { font-family: var(--font-mono); }
|
||||
.room__route { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.room__badge { color: var(--leaf); border-color: var(--leaf); }
|
||||
|
||||
/* One agent's conversation, read-only. */
|
||||
.convo__head { display: grid; gap: var(--sp-2); padding: var(--sp-3); border-bottom: var(--border-w) solid var(--border); }
|
||||
.convo__head > .btn { justify-self: start; }
|
||||
.convo { display: grid; gap: var(--sp-3); padding: var(--sp-3); min-width: 0; }
|
||||
.convo__turn { font-size: var(--text-sm); }
|
||||
.convo__tools { display: grid; gap: var(--sp-1); margin: var(--sp-2) 0 0; padding: 0; list-style: none; }
|
||||
.convo__tool { display: grid; grid-template-columns: var(--dot-size) auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; font-size: var(--text-xs); }
|
||||
|
||||
/* The strip above the message box: what the agents are doing, one line, and the way into the panel. */
|
||||
.strip { padding: var(--sp-2) var(--sp-4) 0; }
|
||||
.strip__btn {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dot-size) minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: var(--thread-max-width);
|
||||
min-height: var(--control-h);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--control-px-sm);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
color: var(--ink-muted);
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.strip__btn:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
.strip__text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* --- Menus, dialogs and the palette ----------------------------------------------------------- */
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 12rem;
|
||||
max-width: min(var(--w-menu), calc(100vw - 2 * var(--sp-2)));
|
||||
max-height: min(var(--h-list), calc(100vh - 2 * var(--sp-2)));
|
||||
overflow: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
background: var(--surface-raised);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* Every item: lead · label · check, on the same tracks, so labels line up with or without a lead. */
|
||||
.menu__item {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) 1rem;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--row-h);
|
||||
padding: 0 var(--sp-3);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* A menu where no item has a lead: no lead track either. */
|
||||
.menu--plain .menu__item { grid-template-columns: minmax(0, 1fr) 1rem; }
|
||||
.menu--plain .menu__lead { display: none; }
|
||||
.menu__item:hover, .menu__item:focus-visible { background: var(--surface-hover); outline: none; }
|
||||
.menu__item:disabled { opacity: var(--opacity-dim); cursor: default; }
|
||||
.menu__item.is-danger { color: var(--danger); }
|
||||
.menu__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.menu__check { color: var(--accent); }
|
||||
.menu__sep { height: var(--border-w); margin: var(--sp-1) 0; background: var(--border); }
|
||||
|
||||
.dialog {
|
||||
width: min(var(--w-dialog), calc(100vw - 2 * var(--sp-4)));
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
background: var(--surface-raised);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dialog::backdrop { background: var(--scrim); }
|
||||
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
|
||||
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
|
||||
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
|
||||
.dialog__code {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font: var(--text-sm) var(--font-mono);
|
||||
color: var(--ink);
|
||||
background: var(--code-bg);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
user-select: all;
|
||||
}
|
||||
.dialog__input { width: 100%; }
|
||||
.dialog__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); }
|
||||
|
||||
.palette { align-self: start; margin-top: 12vh; }
|
||||
.palette__line { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3); border-bottom: var(--border-w) solid var(--border); color: var(--ink-muted); }
|
||||
.palette__input { height: var(--control-h-lg) !important; border: 0 !important; background: none !important; outline: none; font-size: var(--text-base); }
|
||||
.palette__list { list-style: none; margin: 0; padding: var(--sp-1) 0; max-height: var(--h-list); overflow: auto; }
|
||||
/* A result: icon · label · hint, the same tracks on every row. */
|
||||
.palette__item {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem minmax(0, 1fr) auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-height: var(--row-h);
|
||||
padding: 0 var(--sp-3);
|
||||
font: var(--text-sm) var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
.palette__item[aria-selected="true"] { background: var(--surface-active); }
|
||||
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
||||
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
|
||||
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
|
||||
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||
|
||||
/* 64rem (--bp-wide): the viewer goes over the chat instead of beside it. */
|
||||
@media (max-width: 64rem) {
|
||||
.shell[data-viewer="true"] { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-areas: "side main"; }
|
||||
.viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||
.shell[data-activity="true"],
|
||||
.shell[data-activity="true"][data-viewer="true"] { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-areas: "side main"; }
|
||||
.activity { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||
}
|
||||
|
||||
/* The chat's header by the chat's own width, not the window's: with the activity panel or the
|
||||
viewer beside it, a wide window still leaves a narrow chat, and its controls get a row of their
|
||||
own there as on a narrow window. 48rem is --bp-narrow again. */
|
||||
.main { container: chat / inline-size; }
|
||||
@container chat (max-width: 48rem) {
|
||||
.head { padding: var(--sp-2) var(--sp-3) 0; grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.head__place { grid-column: 1 / -1; grid-row: 2; padding-bottom: var(--sp-2); }
|
||||
.head__place:empty { display: none; }
|
||||
.head__tools { grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; padding-bottom: var(--sp-2); }
|
||||
.head__place:empty + .head__tools { grid-row: 2; }
|
||||
.head__tools:empty { display: none; }
|
||||
}
|
||||
|
||||
/* 48rem (--bp-narrow): the sidebar is a drawer, opened from the title bar. */
|
||||
@@ -638,7 +1071,8 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
:root { --side-btn-w: var(--titlebar-h); }
|
||||
.bar__side { visibility: visible; }
|
||||
.bar__host { display: none; }
|
||||
.shell, .shell[data-viewer="true"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main"; }
|
||||
.shell, .shell[data-viewer="true"],
|
||||
.shell[data-activity="true"], .shell[data-activity="true"][data-viewer="true"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main"; }
|
||||
.side {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -651,13 +1085,20 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
||||
.shell[data-side="true"] .side { transform: none; }
|
||||
.shell[data-side="true"] .scrim { display: block; position: absolute; inset: 0; z-index: calc(var(--z-panel) - 1); background: var(--scrim); }
|
||||
.viewer { z-index: var(--z-panel); }
|
||||
.head { padding: 0 var(--sp-3); grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.head { padding: var(--sp-2) var(--sp-3) 0; grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.head__place { grid-column: 1 / -1; grid-row: 2; padding-bottom: var(--sp-2); }
|
||||
/* The chat's controls get a row of their own: beside the title they would squeeze it out. */
|
||||
.head__tools { grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; padding-bottom: var(--sp-2); }
|
||||
.head__tools:empty { display: none; }
|
||||
.thread { padding: var(--sp-3); }
|
||||
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
|
||||
.strip { padding: var(--sp-2) var(--sp-3) 0; }
|
||||
.cards { padding: var(--sp-2) var(--sp-3) 0; }
|
||||
.card { padding: var(--sp-4); gap: var(--sp-4); }
|
||||
.msg--user { max-width: 100%; }
|
||||
.palette__hint { display: none; }
|
||||
.palette__item { grid-template-columns: 1rem minmax(0, 1fr); }
|
||||
.head__model .picker { max-width: 8rem; }
|
||||
}
|
||||
|
||||
/* Very narrow: the agent's words go, its dot stays; the row's button goes under its field. */
|
||||
|
||||
+768
-16
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -87,8 +87,12 @@
|
||||
<div class="messages" id="messages"></div>
|
||||
</div>
|
||||
<div class="cards" id="cards" hidden></div>
|
||||
<form class="composer" id="composer" hidden data-running="false">
|
||||
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
||||
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
||||
<ul class="composer__files" id="composer-files" hidden></ul>
|
||||
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
||||
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
||||
<div class="composer__actions">
|
||||
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
|
||||
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
|
||||
@@ -105,6 +109,15 @@
|
||||
<div class="settings__body" id="settings-body"></div>
|
||||
</section>
|
||||
|
||||
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||
<header class="viewer__head activity__head">
|
||||
<button type="button" class="pane__btn" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||
<h2 class="viewer__name" id="activity-title"></h2>
|
||||
<button type="button" class="pane__btn" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
||||
</header>
|
||||
<div class="activity__body" id="activity-body"></div>
|
||||
</aside>
|
||||
|
||||
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
|
||||
<header class="viewer__head">
|
||||
<div class="viewer__id">
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
// The chat's activity panel, beside it: what is in its context, by category, against the model's
|
||||
// window; the files the chat and its agents wrote; every background agent -- a team's members as
|
||||
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
|
||||
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
|
||||
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
|
||||
// facts as the server's own panel (`services/activity.py`), from /api/v2:
|
||||
//
|
||||
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
||||
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
|
||||
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
||||
//
|
||||
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
|
||||
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
||||
// of a read; it is asked for when the panel opens, after a reply and at most every so often.
|
||||
//
|
||||
// Every name, role, brief, step, room message and conversation here is a model's words: set as
|
||||
// text, and a room message or a reply through the page's own markdown (markdown.js), which
|
||||
// builds elements and never parses HTML.
|
||||
|
||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||
import { get, post, follow, why } from "./bridge.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
import { ask } from "./popup.js"
|
||||
import { openServerFile } from "./explorer.js"
|
||||
|
||||
const ACTIVE = ["queued", "working", "waiting"]
|
||||
// How many live agents show before "Show N more", as on the server's panel.
|
||||
const SHOWN = 6
|
||||
// The context is read again on a change at most this often (ms); always on opening and after a reply.
|
||||
const CONTEXT_EVERY = 15000
|
||||
|
||||
let layout = () => {} // (on) -> the shell makes room for the panel
|
||||
let chat = null // the open chat: { id, kind, … }
|
||||
let stream = null // the events stream's handle
|
||||
let retry = 0 // the reconnect timer
|
||||
let failures = 0
|
||||
let summary = null
|
||||
let version = ""
|
||||
let data = null // the last `activity`
|
||||
let room = [] // the room so far: each read asks only for what came after `cursor`
|
||||
let cursor = 0
|
||||
let readAt = 0 // when `data` was read: the elapsed times count on from there
|
||||
let contextAt = 0
|
||||
let reading = null // the read in flight
|
||||
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
||||
let view = null // null: the panel; { kind, id, name }: one agent's conversation
|
||||
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
||||
let ticker = 0
|
||||
|
||||
// --- Words ----------------------------------------------------------------------------------------
|
||||
|
||||
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
|
||||
export function duration(seconds) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
if (total < 60) return `${total}s`
|
||||
const minutes = Math.floor(total / 60)
|
||||
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
|
||||
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
|
||||
}
|
||||
|
||||
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
|
||||
export function compact(value) {
|
||||
const n = Math.trunc(Number(value) || 0)
|
||||
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 100000) return `${trim(n / 1000, 1)}k`
|
||||
if (n < 1000000) return `${Math.round(n / 1000)}k`
|
||||
return `${trim(n / 1000000, 2)}M`
|
||||
}
|
||||
|
||||
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
|
||||
export function summaryText(counts) {
|
||||
if (!counts) return ""
|
||||
const parts = []
|
||||
if (counts.working === 1) parts.push(t("1 agent 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.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
function stateWord(state) {
|
||||
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
|
||||
}
|
||||
|
||||
function contextWord(key) {
|
||||
return (
|
||||
{
|
||||
messages: t("Messages"),
|
||||
system: t("System prompt"),
|
||||
memory: t("Memory and personal"),
|
||||
instructions: t("Instruction files"),
|
||||
tools: t("Tools"),
|
||||
mcp_tools: t("MCP tools"),
|
||||
since: t("Since the last reply"),
|
||||
buffer: t("Compaction buffer"),
|
||||
free: t("Free space"),
|
||||
}[key] || String(key)
|
||||
)
|
||||
}
|
||||
|
||||
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
|
||||
function percentSentence(text, fields) {
|
||||
return t(text, fields).replace(/%%/g, "%")
|
||||
}
|
||||
|
||||
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
|
||||
export function planList(plan) {
|
||||
return h(
|
||||
"ol",
|
||||
{ class: "plan" },
|
||||
(plan.phases || []).map((p) =>
|
||||
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Following a chat -----------------------------------------------------------------------------
|
||||
|
||||
const enc = encodeURIComponent
|
||||
|
||||
export function panelOpen() {
|
||||
return !$("activity").hidden
|
||||
}
|
||||
|
||||
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
|
||||
export function watchActivity(next) {
|
||||
const id = next?.id || null
|
||||
if (id && chat?.id === id) {
|
||||
chat = next
|
||||
return
|
||||
}
|
||||
stop()
|
||||
chat = id ? next : null
|
||||
summary = null
|
||||
version = ""
|
||||
data = null
|
||||
room = []
|
||||
cursor = 0
|
||||
readAt = contextAt = 0
|
||||
view = null
|
||||
openRows.clear()
|
||||
renderStrip()
|
||||
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
|
||||
if (!chat) {
|
||||
if (panelOpen()) showPanel(false)
|
||||
return
|
||||
}
|
||||
listenTo(chat.id)
|
||||
if (panelOpen()) {
|
||||
render()
|
||||
read(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
||||
export function activityChanged() {
|
||||
if (chat && panelOpen()) read(true)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimeout(retry)
|
||||
retry = 0
|
||||
failures = 0
|
||||
stream?.close()
|
||||
stream = null
|
||||
}
|
||||
|
||||
async function listenTo(id) {
|
||||
// Held while the stream opens, so nothing opens a second one meanwhile.
|
||||
const pending = { close() {} }
|
||||
stream = pending
|
||||
let ended = false
|
||||
let handleId = null
|
||||
try {
|
||||
const handle = await follow(`/chats/${enc(id)}/activity/events`, {
|
||||
onFrame: (event, frame) => {
|
||||
if (chat?.id !== id || event !== "activity" || !frame || typeof frame !== "object") return
|
||||
failures = 0
|
||||
heard(frame)
|
||||
},
|
||||
// The server closes the stream after a while (`close`): it is opened again at once. A broken
|
||||
// one is opened again later, more slowly each time -- polling, in effect, while it fails.
|
||||
onEnd: (err) => {
|
||||
ended = true
|
||||
if (chat?.id !== id) return
|
||||
if (stream === pending || stream?.id === handleId) stream = null
|
||||
// Not while the window is hidden (in the tray): it is opened again when it shows.
|
||||
if (document.visibilityState === "hidden") return
|
||||
failures = err ? failures + 1 : 0
|
||||
retry = setTimeout(() => {
|
||||
retry = 0
|
||||
if (chat?.id === id && !stream) {
|
||||
if (err && panelOpen()) read(false)
|
||||
listenTo(id)
|
||||
}
|
||||
}, err ? Math.min(60000, 5000 * failures) : 500)
|
||||
},
|
||||
})
|
||||
handleId = handle.id
|
||||
if (chat?.id !== id || stream !== pending) handle.close()
|
||||
else if (!ended) stream = handle
|
||||
} catch {
|
||||
if (stream === pending) stream = null
|
||||
if (chat?.id !== id) return
|
||||
failures += 1
|
||||
retry = setTimeout(() => {
|
||||
retry = 0
|
||||
if (chat?.id === id && !stream) listenTo(id)
|
||||
}, Math.min(60000, 5000 * failures))
|
||||
}
|
||||
}
|
||||
|
||||
function heard(frame) {
|
||||
summary = frame.summary || summary
|
||||
renderStrip()
|
||||
if (frame.version && frame.version !== version) {
|
||||
version = frame.version
|
||||
if (panelOpen()) read(Date.now() - contextAt > CONTEXT_EVERY)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the panel's facts; one read at a time, and one more after it if asked for meanwhile. */
|
||||
async function read(withContext) {
|
||||
if (!chat) return
|
||||
if (reading) {
|
||||
readAgain = withContext ? "context" : readAgain || "plain"
|
||||
return reading
|
||||
}
|
||||
const id = chat.id
|
||||
reading = (async () => {
|
||||
try {
|
||||
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
|
||||
if (chat?.id !== id || !a) return
|
||||
if (!withContext) a.context = data?.context ?? null
|
||||
else contextAt = Date.now()
|
||||
if (a.team) {
|
||||
// A cursor lower than ours is a room begun again (a new team): read from the start.
|
||||
if (a.team.cursor < cursor) {
|
||||
room = []
|
||||
cursor = 0
|
||||
readAgain = readAgain || "plain"
|
||||
}
|
||||
for (const m of a.team.room || []) if (m.n > cursor) room.push(m)
|
||||
cursor = Math.max(cursor, a.team.cursor || 0)
|
||||
} else {
|
||||
room = []
|
||||
cursor = 0
|
||||
}
|
||||
data = a
|
||||
version = a.version || version
|
||||
summary = a.summary || summary
|
||||
readAt = Date.now()
|
||||
renderStrip()
|
||||
if (view) await readAgent()
|
||||
else render()
|
||||
} catch (err) {
|
||||
if (chat?.id === id && !data) renderError(why(err))
|
||||
}
|
||||
})()
|
||||
await reading
|
||||
reading = null
|
||||
if (readAgain) {
|
||||
const ctxToo = readAgain === "context"
|
||||
readAgain = ""
|
||||
read(ctxToo)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The strip above the message box ---------------------------------------------------------------
|
||||
|
||||
function renderStrip() {
|
||||
const strip = $("activity-strip")
|
||||
// While something runs, as the server's strip: a chat whose helpers all finished long ago does
|
||||
// not carry "12 finished" above its message box for ever (the panel still lists them).
|
||||
const text = chat && summary?.active ? summaryText(summary) : ""
|
||||
strip.hidden = !text
|
||||
if (!text) return clear(strip)
|
||||
strip.replaceChildren(
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
|
||||
h("span", { class: "dot is-busy", "aria-hidden": "true" }),
|
||||
h("span", { class: "strip__text", text }),
|
||||
icon(ICONS.chevron),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The panel ------------------------------------------------------------------------------------------
|
||||
|
||||
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||
export function showPanel(on) {
|
||||
if (on && !chat) return
|
||||
$("activity").hidden = !on
|
||||
layout(on)
|
||||
$("activity-open")?.setAttribute("aria-expanded", String(on))
|
||||
clearInterval(ticker)
|
||||
ticker = 0
|
||||
if (!on) {
|
||||
view = null
|
||||
return
|
||||
}
|
||||
ticker = setInterval(tick, 1000)
|
||||
render()
|
||||
read(true)
|
||||
$("activity-close").focus()
|
||||
}
|
||||
|
||||
export function togglePanel() {
|
||||
showPanel(!panelOpen())
|
||||
}
|
||||
|
||||
/** The elapsed times of agents still at work count on between reads. */
|
||||
function tick() {
|
||||
if (document.visibilityState !== "visible") return
|
||||
const extra = (Date.now() - readAt) / 1000
|
||||
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
||||
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
||||
}
|
||||
}
|
||||
|
||||
function renderError(text) {
|
||||
clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text }))
|
||||
}
|
||||
|
||||
function section(name, title, count, ...body) {
|
||||
const el = h(
|
||||
"details",
|
||||
{ class: "activity__section", open: !openRows.has(`section:${name}`) },
|
||||
h("summary", { class: "activity__heading" }, h("span", { text: title }), count ? h("span", { class: "activity__count", text: String(count) }) : null),
|
||||
...body,
|
||||
)
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.delete(`section:${name}`) : openRows.add(`section:${name}`)))
|
||||
return el
|
||||
}
|
||||
|
||||
function render() {
|
||||
$("activity").dataset.view = "panel"
|
||||
const head = $("activity-title")
|
||||
$("activity-back").hidden = true
|
||||
head.textContent = t("Activity")
|
||||
const body = $("activity-body")
|
||||
if (!data) {
|
||||
clear(body).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||
return
|
||||
}
|
||||
// Kept where the reader was: the panel is drawn again on every change.
|
||||
const scroll = body.scrollTop
|
||||
body.replaceChildren(
|
||||
section("context", t("Context"), 0, contextPart(data.context)),
|
||||
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
||||
section("tasks", t("Background tasks"), 0, tasksPart()),
|
||||
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
||||
)
|
||||
body.scrollTop = scroll
|
||||
}
|
||||
|
||||
function contextPart(c) {
|
||||
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||
const known = c.percent !== null && c.percent !== undefined
|
||||
const used = h(
|
||||
"p",
|
||||
{ class: "ctx__used" },
|
||||
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
|
||||
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
|
||||
)
|
||||
const cats = c.categories || []
|
||||
const bar = c.limit
|
||||
? h(
|
||||
"div",
|
||||
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
|
||||
cats
|
||||
.filter((r) => r.key !== "free" && r.tokens)
|
||||
.map((r) => {
|
||||
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
|
||||
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
|
||||
return seg
|
||||
}),
|
||||
)
|
||||
: null
|
||||
const rows = h(
|
||||
"ul",
|
||||
{ class: "ctx__rows" },
|
||||
cats.map((r) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "ctx__row" },
|
||||
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "ctx__name", text: contextWord(r.key) }),
|
||||
h("span", { class: "ctx__num", text: compact(r.tokens) }),
|
||||
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const note = h(
|
||||
"p",
|
||||
{ class: "ctx__note" },
|
||||
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
||||
)
|
||||
return h("div", { class: "ctx" }, used, bar, rows, note)
|
||||
}
|
||||
|
||||
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||
function safeKey(key) {
|
||||
return String(key).replace(/[^a-z_]/gi, "") || "other"
|
||||
}
|
||||
|
||||
function outputsPart(outputs) {
|
||||
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
|
||||
return h(
|
||||
"ul",
|
||||
{ class: "outs" },
|
||||
outputs.map((o) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "outs__row" },
|
||||
icon(ICONS.file),
|
||||
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
|
||||
h(
|
||||
"span",
|
||||
{ class: "outs__diff" },
|
||||
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function tasksPart() {
|
||||
const entries = data.agents || []
|
||||
const team = data.team
|
||||
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
||||
if (!entries.length && !team) {
|
||||
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
|
||||
return box
|
||||
}
|
||||
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
|
||||
if (team) {
|
||||
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
|
||||
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||
}
|
||||
const live = entries.filter((e) => ACTIVE.includes(e.state))
|
||||
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
|
||||
if (live.length) {
|
||||
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
||||
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
||||
}
|
||||
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
|
||||
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
|
||||
}
|
||||
|
||||
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
|
||||
function fold(name, label, list) {
|
||||
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||
return el
|
||||
}
|
||||
|
||||
/** An agent and, under it, the agents it leads or started. */
|
||||
function agentRows(a) {
|
||||
return [agentRow(a), ...(a.children || []).map(agentRows)]
|
||||
}
|
||||
|
||||
function agentRow(a) {
|
||||
const key = `${a.kind}:${a.id}`
|
||||
const active = ACTIVE.includes(a.state)
|
||||
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
|
||||
if (active) {
|
||||
time.dataset.active = ""
|
||||
time.dataset.seconds = String(Number(a.seconds) || 0)
|
||||
}
|
||||
const stopBtn = a.stoppable
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "pane__btn agent__stop",
|
||||
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||
title: t("Stop %(name)s", { name: a.name }),
|
||||
onclick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stopAgent(a)
|
||||
},
|
||||
},
|
||||
icon(ICONS.stop),
|
||||
)
|
||||
: h("span", { class: "agent__stop", "aria-hidden": "true" })
|
||||
const facts = h(
|
||||
"p",
|
||||
{ class: "agent__facts" },
|
||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||
a.agent ? h("span", { text: a.agent }) : null,
|
||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||
)
|
||||
const step = a.step
|
||||
? h(
|
||||
"p",
|
||||
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
|
||||
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
|
||||
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
|
||||
)
|
||||
: null
|
||||
const el = h(
|
||||
"details",
|
||||
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
|
||||
h(
|
||||
"summary",
|
||||
{ class: "agent__row" },
|
||||
h("span", { class: "agent__dot", "aria-hidden": "true" }),
|
||||
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),
|
||||
time,
|
||||
stopBtn,
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "agent__body" },
|
||||
facts,
|
||||
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||
step,
|
||||
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||
a.transcript
|
||||
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
|
||||
: null,
|
||||
),
|
||||
)
|
||||
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
|
||||
return el
|
||||
}
|
||||
|
||||
async function stopAgent(a) {
|
||||
if (!chat) return
|
||||
if (a.kind === "member") {
|
||||
const yes = await ask({
|
||||
title: t("Stop %(name)s", { name: a.name }),
|
||||
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
|
||||
ok: t("Stop"),
|
||||
cancel: t("Cancel"),
|
||||
danger: true,
|
||||
})
|
||||
if (!yes) return
|
||||
}
|
||||
try {
|
||||
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
|
||||
} catch (err) {
|
||||
// 409: it had finished meanwhile; the read below shows that.
|
||||
if (err?.status !== 409) renderError(why(err))
|
||||
}
|
||||
read(false)
|
||||
}
|
||||
|
||||
// --- One agent's conversation, read-only ------------------------------------------------------------
|
||||
|
||||
function openAgent(a) {
|
||||
view = { kind: a.kind, id: a.id, name: a.name || a.id }
|
||||
$("activity").dataset.view = "agent"
|
||||
$("activity-title").textContent = view.name
|
||||
$("activity-back").hidden = false
|
||||
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||
readAgent()
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
view = null
|
||||
render()
|
||||
$("activity-close").focus()
|
||||
}
|
||||
|
||||
async function readAgent() {
|
||||
if (!chat || !view) return
|
||||
const { kind, id } = view
|
||||
let r
|
||||
try {
|
||||
r = await get(`/chats/${enc(chat.id)}/agents/${enc(kind)}/${enc(id)}`)
|
||||
} catch (err) {
|
||||
if (view?.id === id) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
|
||||
return
|
||||
}
|
||||
if (view?.kind !== kind || view?.id !== id) return
|
||||
const a = r.agent || {}
|
||||
const body = $("activity-body")
|
||||
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
|
||||
const head = h(
|
||||
"div",
|
||||
{ class: "convo__head" },
|
||||
h(
|
||||
"p",
|
||||
{ class: "agent__facts" },
|
||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||
h("span", { text: duration(a.seconds) }),
|
||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||
),
|
||||
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
||||
)
|
||||
const turns = (r.transcript || []).map((turn) => {
|
||||
if (turn.role === "user") {
|
||||
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
||||
}
|
||||
return h(
|
||||
"article",
|
||||
{ class: "msg msg--assistant convo__turn" },
|
||||
turn.text ? h("div", { class: "md" }, markdown(String(turn.text))) : null,
|
||||
turn.tools?.length
|
||||
? h(
|
||||
"ul",
|
||||
{ class: "convo__tools" },
|
||||
turn.tools.map((c) =>
|
||||
h(
|
||||
"li",
|
||||
{ class: "convo__tool" },
|
||||
h("span", { class: `dot${c.isError ? " is-error" : " is-ok"}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "agent__verb", text: String(c.name || "") }),
|
||||
c.title ? h("code", { class: "agent__title", text: String(c.title) }) : null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
turn.error ? h("p", { class: "msg__note is-error", text: String(turn.error) }) : null,
|
||||
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
||||
)
|
||||
})
|
||||
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 ------------------------------------------------------------------------------------------
|
||||
|
||||
export function initActivity({ onLayout }) {
|
||||
layout = onLayout || layout
|
||||
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||
$("activity-back").addEventListener("click", closeAgent)
|
||||
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
|
||||
// panel stays; one Escape closes one thing.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
|
||||
if (!$("viewer").hidden) return
|
||||
e.preventDefault()
|
||||
if (view) closeAgent()
|
||||
else showPanel(false)
|
||||
},
|
||||
true,
|
||||
)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible" || !chat) return
|
||||
if (!stream && !retry) listenTo(chat.id)
|
||||
if (panelOpen()) read(false)
|
||||
})
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// The files the next message carries. Three ways in, each the person's own act: the attach
|
||||
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
|
||||
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
|
||||
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
|
||||
// with the message that is sent next; one taken off before that is deleted on the server.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, listen, del, why } from "./bridge.js"
|
||||
|
||||
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error? }
|
||||
let seq = 0
|
||||
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||
|
||||
const REASONS = {
|
||||
folder: () => t("This is a folder; attach the files in it."),
|
||||
too_large: () => t("This file is too large to send."),
|
||||
}
|
||||
|
||||
/** The ids of the files sent and ready to go with the message. */
|
||||
export function readyIds() {
|
||||
return pending.filter((p) => p.state === "ready").map((p) => p.id)
|
||||
}
|
||||
|
||||
/** A file is still being sent. */
|
||||
export function sending() {
|
||||
return pending.some((p) => p.state === "sending")
|
||||
}
|
||||
|
||||
/** After a send: the files belong to the message now. */
|
||||
export function sent() {
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
/** Another chat opened, or a new one: files never sent are deleted on the server. */
|
||||
export function discard() {
|
||||
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
function remove(p) {
|
||||
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = pending.filter((x) => x !== p)
|
||||
render()
|
||||
}
|
||||
|
||||
/** One file as the composer shows it: the same three tracks for every row. */
|
||||
function row(p) {
|
||||
const status =
|
||||
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||
return h(
|
||||
"li",
|
||||
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
|
||||
h("span", { class: "file__state", text: status }),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "file__remove",
|
||||
"aria-label": t("Remove %(dir)s", { dir: p.name }),
|
||||
title: t("Remove"),
|
||||
disabled: p.state === "sending",
|
||||
onclick: () => remove(p),
|
||||
},
|
||||
icon(ICONS.close),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = clear($("composer-files"))
|
||||
list.hidden = !pending.length
|
||||
for (const p of pending) list.append(row(p))
|
||||
}
|
||||
|
||||
/** Show `names` as being sent while `call` sends them; then what became of each. */
|
||||
async function track(names, call) {
|
||||
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
|
||||
pending.push(...placeholders)
|
||||
render()
|
||||
let results
|
||||
try {
|
||||
results = await call()
|
||||
} catch (err) {
|
||||
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
|
||||
}
|
||||
pending = pending.filter((p) => !placeholders.includes(p))
|
||||
for (const r of results || []) {
|
||||
pending.push(
|
||||
r.attachment
|
||||
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
|
||||
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||
)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Choose files in the system's dialog. */
|
||||
async function pick() {
|
||||
const b = $("attach")
|
||||
b.disabled = true
|
||||
try {
|
||||
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
|
||||
} finally {
|
||||
b.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
|
||||
async function paste(e) {
|
||||
const files = [...(e.clipboardData?.files || [])]
|
||||
if (!files.length || !ctx.allowed()) return
|
||||
e.preventDefault()
|
||||
for (const f of files) {
|
||||
const name = f.name || "pasted"
|
||||
track([name], async () => {
|
||||
const data = new Uint8Array(await f.arrayBuffer())
|
||||
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||
return [{ name, size: f.size, attachment }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function initAttach(context) {
|
||||
ctx = context
|
||||
$("attach").replaceChildren(icon(ICONS.attach))
|
||||
$("attach").addEventListener("click", pick)
|
||||
$("composer-text").addEventListener("paste", paste)
|
||||
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
|
||||
listen("drag", (e) => {
|
||||
$("composer").dataset.drag = String(!!e.payload && usable())
|
||||
})
|
||||
listen("drop", (e) => {
|
||||
$("composer").dataset.drag = "false"
|
||||
if (!usable()) return
|
||||
const names = Array.isArray(e.payload) ? e.payload : []
|
||||
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether this account may attach files at all: the button shows only then. */
|
||||
export function allowAttach(on) {
|
||||
$("attach").hidden = !on
|
||||
$("composer").dataset.attach = String(on)
|
||||
}
|
||||
+358
-32
@@ -6,15 +6,19 @@
|
||||
// repository is the contract): a person's message is their text as typed; a reply is its steps --
|
||||
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
|
||||
|
||||
import { $, h, clear, append } from "./dom.js"
|
||||
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
|
||||
import { $, h, clear, append, icon, ICONS, bytes } from "./dom.js"
|
||||
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
|
||||
import { markdown } from "./markdown.js"
|
||||
import { openViewer, showKind } from "./viewer.js"
|
||||
import { readLocal } from "./explorer.js"
|
||||
import { menu, ask, showLine } from "./popup.js"
|
||||
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
|
||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||
let open = null // { chat, live, more, first }
|
||||
|
||||
export function openChatId() {
|
||||
@@ -37,14 +41,101 @@ function toBottom(force) {
|
||||
if (force || nearBottom()) th.scrollTop = th.scrollHeight
|
||||
}
|
||||
|
||||
/** A small icon button under a message. */
|
||||
function action(ico, label, onclick, cls = "") {
|
||||
return h("button", { type: "button", class: `msg__act ${cls}`, "aria-label": label, title: label, onclick }, icon(ico))
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard?.writeText(text).catch(() => {})
|
||||
}
|
||||
|
||||
function userMessage(m) {
|
||||
return h(
|
||||
const el = h(
|
||||
"article",
|
||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "msg__text", text: m.text || "" }),
|
||||
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
|
||||
m.attachments?.length
|
||||
? h(
|
||||
"ul",
|
||||
{ class: "msg__files" },
|
||||
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
|
||||
)
|
||||
: null,
|
||||
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
|
||||
)
|
||||
// A background job's report is not the person's to rewrite (the server refuses it too).
|
||||
if (!m.machine) {
|
||||
el.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "msg__actions" },
|
||||
action(ICONS.edit, t("Edit message"), () => startEdit(el, m)),
|
||||
action(ICONS.copy, t("Copy"), () => copyText(m.text || "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit one of the person's messages in place. Saving rewrites it, drops everything after it and
|
||||
* starts the reply again (on the device too, for an agent chat): the server's rule, said first.
|
||||
*/
|
||||
function startEdit(el, m) {
|
||||
if (!open?.chat?.id || el.querySelector(".msg__edit")) return
|
||||
const box = h("textarea", { class: "msg__edit", rows: "3", value: m.text || "", "aria-label": t("Edit message") })
|
||||
const note = h("p", { class: "hint", text: t("Saving starts the reply again; everything after this message is dropped.") })
|
||||
const said = h("p", { class: "msg__note is-error", hidden: true })
|
||||
const save = h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save") })
|
||||
const cancel = h("button", { type: "button", class: "btn btn--sm", text: t("Cancel") })
|
||||
const form = h("div", { class: "msg__editor" }, box, note, said, h("div", { class: "msg__editbar" }, cancel, save))
|
||||
const parts = [...el.children]
|
||||
for (const c of parts) c.hidden = true
|
||||
el.append(form)
|
||||
const close = () => {
|
||||
form.remove()
|
||||
for (const c of parts) c.hidden = false
|
||||
}
|
||||
cancel.addEventListener("click", close)
|
||||
box.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") close()
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) save.click()
|
||||
})
|
||||
save.addEventListener("click", async () => {
|
||||
const text = box.value.trim()
|
||||
if (!text && !m.attachments?.length) return box.focus()
|
||||
save.disabled = cancel.disabled = true
|
||||
try {
|
||||
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/edit`, { text })
|
||||
reload()
|
||||
} catch (err) {
|
||||
said.textContent = why(err)
|
||||
said.hidden = false
|
||||
save.disabled = cancel.disabled = false
|
||||
}
|
||||
})
|
||||
box.focus()
|
||||
box.setSelectionRange(box.value.length, box.value.length)
|
||||
}
|
||||
|
||||
/** The reply's words, as Markdown: what Copy puts on the clipboard. */
|
||||
function replyText(m) {
|
||||
return (m.steps || [])
|
||||
.filter((s) => s.kind === "text")
|
||||
.map((s) => s.markdown || "")
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
async function regenerate(m) {
|
||||
if (!open?.chat?.id) return
|
||||
alertIn("")
|
||||
try {
|
||||
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/regenerate`)
|
||||
reload()
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
function metricsLine(x) {
|
||||
@@ -65,9 +156,22 @@ function assistantMessage(m, chat) {
|
||||
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
|
||||
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
|
||||
const meta = metricsLine(m.metrics)
|
||||
if (meta || m.model) {
|
||||
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
|
||||
}
|
||||
const model = m.model ? ctx.models().find((x) => x.id === m.model) || { id: m.model, name: m.model } : null
|
||||
el.append(
|
||||
h(
|
||||
"footer",
|
||||
{ class: "msg__foot" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "msg__actions" },
|
||||
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
|
||||
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
||||
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
||||
),
|
||||
model ? h("span", { class: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
||||
meta,
|
||||
),
|
||||
)
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -266,7 +370,10 @@ class Live {
|
||||
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
|
||||
this.status.classList.add("is-error")
|
||||
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
|
||||
} else reload()
|
||||
} else {
|
||||
reload()
|
||||
activityChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,16 +497,6 @@ function cardOf(chat, card) {
|
||||
return el
|
||||
}
|
||||
|
||||
function planList(plan) {
|
||||
return h(
|
||||
"ol",
|
||||
{ class: "plan" },
|
||||
(plan.phases || []).map((p) =>
|
||||
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Opening a chat --------------------------------------------------------------------------------
|
||||
|
||||
function setTitle(title) {
|
||||
@@ -423,8 +520,203 @@ function renderHead(chat) {
|
||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
||||
)
|
||||
tools.append(mode)
|
||||
if (chat.device_session_id) {
|
||||
tools.append(
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "pane__btn", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
||||
icon(ICONS.terminal),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
const slot = h("span", { class: "head__model" })
|
||||
tools.append(slot)
|
||||
chatModels(chat).then((models) => {
|
||||
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
|
||||
})
|
||||
tools.append(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "pane__btn",
|
||||
id: "activity-open",
|
||||
"aria-label": t("Activity"),
|
||||
title: t("Activity"),
|
||||
"aria-controls": "activity",
|
||||
"aria-expanded": String(panelOpen()),
|
||||
onclick: togglePanel,
|
||||
},
|
||||
icon(ICONS.bolt),
|
||||
),
|
||||
h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)),
|
||||
)
|
||||
}
|
||||
|
||||
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
||||
async function chatModels(chat) {
|
||||
try {
|
||||
return (await get(`/models?chat=${encodeURIComponent(chat.id)}`)).models || []
|
||||
} catch {
|
||||
return ctx.models()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
|
||||
async function teamOffered(chat) {
|
||||
if (chat.kind !== "chat") return false
|
||||
try {
|
||||
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the
|
||||
* Thinking switch, or the selector); and the team's size where a team is offered.
|
||||
*/
|
||||
async function headModel(slot, chat, models) {
|
||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||
const parts = []
|
||||
if (mayPick && models.length > 1) {
|
||||
parts.push(
|
||||
modelPicker({
|
||||
models,
|
||||
value: chat.model,
|
||||
label: t("Model"),
|
||||
onChange: async (id) => {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = r.chat
|
||||
headModel(slot, r.chat, models)
|
||||
activityChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
headModel(slot, chat, models)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else if (chat.model) {
|
||||
place.append(h("span", { class: "chip", text: chat.model }))
|
||||
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
||||
}
|
||||
const level = effortControl({
|
||||
model: current,
|
||||
value: chat.effort || levelsOf(current).start,
|
||||
compact: true,
|
||||
onChange: async (value) => {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value })
|
||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
level.value = open?.chat?.effort || levelsOf(current).start
|
||||
}
|
||||
},
|
||||
})
|
||||
if (level) parts.push(level)
|
||||
slot.replaceChildren(...parts)
|
||||
if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return
|
||||
const team = agentsControl({
|
||||
value: open.chat.agents || chat.agents,
|
||||
compact: true,
|
||||
onChange: async (wanted) => {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted })
|
||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
||||
alertIn("")
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
team.value = open?.chat?.agents || chat.agents
|
||||
}
|
||||
},
|
||||
})
|
||||
slot.append(team)
|
||||
}
|
||||
|
||||
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
||||
export function chatMenu(anchor, chat) {
|
||||
const base = ctx.status()?.server
|
||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
|
||||
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
|
||||
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
|
||||
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
||||
menu(anchor, items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an agent chat in a terminal: the CLI's own session on the device it works on
|
||||
* (`device_session_id`), opened with `lembas -s <id>` in the chat's folder. Shown to copy rather
|
||||
* than run: a terminal is not something every desktop opens the same way, and the command is the
|
||||
* person's to run where they want it. On this computer the first word is the CLI as it is
|
||||
* started here (`lembas` on PATH, else the app's own copy by its path).
|
||||
*/
|
||||
export async function continueInTerminal(chat) {
|
||||
const session = String(chat.device_session_id || "")
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(session)) return
|
||||
const here = onThisComputer(chat)
|
||||
let word = "lembas"
|
||||
if (here) word = await invoke("terminal_word").catch(() => "lembas")
|
||||
const dir = chat.device?.directory || ""
|
||||
const quoted = /^[A-Za-z0-9/._+:@%=-]+$/.test(dir) ? dir : `'${dir.replace(/'/g, "'\\''")}'`
|
||||
const code = `${dir ? `cd ${quoted} && ` : ""}${word} -s ${session}`
|
||||
await showLine({
|
||||
title: t("Continue in a terminal"),
|
||||
text: here
|
||||
? t("Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.")
|
||||
: t("Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it."),
|
||||
code,
|
||||
copy: t("Copy"),
|
||||
copied: t("Copied"),
|
||||
close: t("Close"),
|
||||
})
|
||||
}
|
||||
|
||||
async function renameChat(chat) {
|
||||
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
|
||||
if (!title || title === chat.title) return
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { title })
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = r.chat
|
||||
setTitle(r.chat.title)
|
||||
}
|
||||
ctx.changed(r.chat)
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveChat(chat) {
|
||||
try {
|
||||
await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: true })
|
||||
ctx.gone(chat)
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(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)}`)
|
||||
ctx.gone(chat)
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +732,7 @@ function alertIn(text) {
|
||||
|
||||
/** Open a chat by id: its newest page, and the reply being written, if one is. */
|
||||
export async function openChat(id) {
|
||||
if (open?.chat?.id !== id) discard()
|
||||
if (open?.live) open.live.stop()
|
||||
open = { chat: { id }, live: null, more: false }
|
||||
invoke("set_open_chat", { chat: id })
|
||||
@@ -460,6 +753,7 @@ export async function openChat(id) {
|
||||
if (open?.chat?.id !== id) return
|
||||
open.chat = r.chat
|
||||
open.more = r.more
|
||||
watchActivity(r.chat)
|
||||
renderHead(r.chat)
|
||||
clear(list)
|
||||
if (r.more) list.append(earlier())
|
||||
@@ -507,16 +801,18 @@ function reload() {
|
||||
export function closeChat() {
|
||||
if (open?.live) open.live.stop()
|
||||
open = null
|
||||
watchActivity(null)
|
||||
invoke("set_open_chat", { chat: null })
|
||||
}
|
||||
|
||||
// --- A new chat ------------------------------------------------------------------------------------
|
||||
|
||||
let draft = null // { kind }
|
||||
let draft = null // { kind, level (the effort control), agents (the team's size) }
|
||||
|
||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
||||
export function newChat(kind) {
|
||||
closeChat()
|
||||
discard()
|
||||
draft = { kind }
|
||||
clear($("messages"))
|
||||
renderCards({ id: "" }, [])
|
||||
@@ -528,15 +824,30 @@ export function newChat(kind) {
|
||||
form.hidden = false
|
||||
const s = ctx.status()
|
||||
const models = ctx.models()
|
||||
const start = (models.find((m) => m.default) || models[0])?.id || ""
|
||||
// How hard it thinks, and the team's size: drawn for the chosen model, again when it changes.
|
||||
// Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown.
|
||||
const effortField = field(t("Reasoning effort"), h("span"), "")
|
||||
const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them."))
|
||||
const showLevels = (id) => {
|
||||
const model = models.find((m) => m.id === id)
|
||||
const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" })
|
||||
effortField.children[0].htmlFor = "new-effort"
|
||||
effortField.children[1].replaceWith(control || h("span", { id: "new-effort" }))
|
||||
effortField.hidden = !control
|
||||
draft.level = control
|
||||
const team = kind === "chat" && teamPossible(model)
|
||||
draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null
|
||||
agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" }))
|
||||
agentsField.children[0].htmlFor = "new-agents"
|
||||
agentsField.hidden = !team
|
||||
}
|
||||
const modelField = field(
|
||||
t("Model"),
|
||||
h(
|
||||
"select",
|
||||
{ id: "new-model" },
|
||||
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
|
||||
),
|
||||
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }),
|
||||
models.length ? "" : t("No model is available to this account."),
|
||||
)
|
||||
showLevels(start)
|
||||
if (kind === "agent") {
|
||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
||||
const devices = ctx.thisDevices()
|
||||
@@ -567,10 +878,11 @@ export function newChat(kind) {
|
||||
t("A chat asking for more than the folder allows is lowered to it."),
|
||||
),
|
||||
modelField,
|
||||
effortField,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
form.append(h("div", { class: "fields" }, modelField))
|
||||
form.append(h("div", { class: "fields" }, modelField, effortField, agentsField))
|
||||
}
|
||||
showComposer(true)
|
||||
setRunning(false)
|
||||
@@ -585,10 +897,15 @@ function field(label, control, hint) {
|
||||
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
|
||||
}
|
||||
|
||||
async function createChat(text) {
|
||||
async function createChat(text, attachments) {
|
||||
const body = { text }
|
||||
if (attachments.length) body.attachments = attachments
|
||||
const model = $("new-model")?.value
|
||||
if (model) body.model = model
|
||||
const effort = draft.level?.value
|
||||
if (typeof effort === "string" && effort) body.effort = effort
|
||||
const agents = draft.agents?.value
|
||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||
if (draft.kind === "agent") {
|
||||
const device = ctx.thisDevices()[0]
|
||||
const dir = $("new-dir")?.value
|
||||
@@ -601,6 +918,7 @@ async function createChat(text) {
|
||||
$("new-chat").hidden = true
|
||||
open = { chat: r.chat, live: null, more: false }
|
||||
invoke("set_open_chat", { chat: r.chat.id })
|
||||
watchActivity(r.chat)
|
||||
renderHead(r.chat)
|
||||
clear($("messages"))
|
||||
if (r.message) $("messages").append(userMessage(r.message))
|
||||
@@ -618,16 +936,24 @@ function showComposer(on) {
|
||||
async function submit() {
|
||||
const box = $("composer-text")
|
||||
const text = box.value.trim()
|
||||
if (!text) return
|
||||
if (sending()) {
|
||||
alertIn(t("Wait until the files are sent."))
|
||||
return
|
||||
}
|
||||
const files = readyIds()
|
||||
if (!text && !files.length) return
|
||||
$("send").disabled = true
|
||||
alertIn("")
|
||||
try {
|
||||
if (draft) await createChat(text)
|
||||
if (draft) await createChat(text, files)
|
||||
else if (open?.chat?.id) {
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
|
||||
const body = { text }
|
||||
if (files.length) body.attachments = files
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
|
||||
if (r.message) $("messages").append(userMessage(r.message))
|
||||
if (r.reply) follower(open.chat, r.reply.id)
|
||||
}
|
||||
} else return
|
||||
sent()
|
||||
box.value = ""
|
||||
grow()
|
||||
toBottom(true)
|
||||
|
||||
@@ -74,6 +74,16 @@ export const ICONS = {
|
||||
min: "M4 10h8v1.5H4z",
|
||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||
stop: "M4.5 4.5h7v7h-7z",
|
||||
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||
}
|
||||
|
||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||
|
||||
+10
-7
@@ -130,13 +130,18 @@ export function renderServer() {
|
||||
tree(box, {
|
||||
rootPath: "/",
|
||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||
open: async (e) => {
|
||||
open: (e) => openServerFile(e.path, e.name),
|
||||
})
|
||||
}
|
||||
|
||||
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
|
||||
export async function openServerFile(path, name) {
|
||||
const where = t("Server")
|
||||
try {
|
||||
const f = await readServer(e.path)
|
||||
const f = await readServer(path)
|
||||
openViewer({
|
||||
name: f.name || e.name,
|
||||
path: f.path || e.path,
|
||||
name: f.name || name,
|
||||
path: f.path || path,
|
||||
where,
|
||||
text: f.text,
|
||||
// The server's own kind and language: its table and the viewer's are the same one.
|
||||
@@ -146,10 +151,8 @@ export function renderServer() {
|
||||
size: f.size,
|
||||
})
|
||||
} catch (err) {
|
||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
||||
openViewer({ name, path, where, error: why(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function initExplorer() {
|
||||
|
||||
+6
-1
@@ -5,7 +5,7 @@
|
||||
import { $, h, clear, when, append } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current() }
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
|
||||
let rows = []
|
||||
let more = false
|
||||
|
||||
@@ -49,6 +49,11 @@ function row(c) {
|
||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
||||
)
|
||||
b.addEventListener("click", () => ctx.open(c.id))
|
||||
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault()
|
||||
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, c)
|
||||
})
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
|
||||
+73
-14
@@ -11,7 +11,10 @@ import { renderSettings, initSettings } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
|
||||
import { initAttach, allowAttach } from "./attach.js"
|
||||
import { initPalette, openPalette } from "./palette.js"
|
||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
@@ -90,6 +93,7 @@ async function startApp() {
|
||||
}
|
||||
await Promise.all([loadModels(), loadDevices()])
|
||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
||||
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||
setKind(kind)
|
||||
renderServer()
|
||||
invoke("remote_refresh")
|
||||
@@ -142,6 +146,41 @@ function localPane(chat) {
|
||||
else showLocal({ roots, root: "", fixed: false })
|
||||
}
|
||||
|
||||
/** A new chat of `k`'s kind, in the main view. */
|
||||
function startNew(k) {
|
||||
showSettings(false)
|
||||
if (k !== kind) setKind(k)
|
||||
currentChat = null
|
||||
if (narrow()) sideOpen(false)
|
||||
newChat(k)
|
||||
markCurrent()
|
||||
localPane()
|
||||
}
|
||||
|
||||
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
|
||||
function showChat(id, k) {
|
||||
showSettings(false)
|
||||
if (k && k !== kind) setKind(k)
|
||||
if (narrow()) sideOpen(false)
|
||||
openChat(id).then(markCurrent)
|
||||
}
|
||||
|
||||
/** The palette's commands: what the menus and buttons do, by name. */
|
||||
function commands() {
|
||||
const out = [
|
||||
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||
{ label: t("New chat"), run: () => startNew("chat") },
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||
out.push(
|
||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
|
||||
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
function narrow() {
|
||||
return window.matchMedia("(max-width: 48rem)").matches
|
||||
}
|
||||
@@ -154,6 +193,7 @@ function sideOpen(on) {
|
||||
// --- Settings -----------------------------------------------------------------------------------
|
||||
|
||||
function showSettings(on) {
|
||||
if (on) showPanel(false)
|
||||
$("settings").hidden = !on
|
||||
$("main").hidden = on
|
||||
if (on) {
|
||||
@@ -175,11 +215,8 @@ async function boot() {
|
||||
kind: () => kind,
|
||||
thisDevices,
|
||||
current: () => openChatId(),
|
||||
open: (id) => {
|
||||
showSettings(false)
|
||||
if (narrow()) sideOpen(false)
|
||||
openChat(id).then(markCurrent)
|
||||
},
|
||||
open: (id) => showChat(id),
|
||||
menu: (anchor, c) => chatMenu(anchor, c),
|
||||
})
|
||||
initChat({
|
||||
status: () => status,
|
||||
@@ -192,21 +229,30 @@ async function boot() {
|
||||
localPane(chat)
|
||||
markCurrent()
|
||||
},
|
||||
// Archived or deleted: the list without it, and a new chat where it was open.
|
||||
gone: (chat) => {
|
||||
if (!openChatId() || openChatId() === chat.id) {
|
||||
currentChat = null
|
||||
newChat(kind)
|
||||
localPane()
|
||||
}
|
||||
refreshList()
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
initActivity({
|
||||
onLayout: (on) => {
|
||||
$("shell").dataset.activity = String(on)
|
||||
},
|
||||
})
|
||||
|
||||
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
|
||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||
$("new-chat-btn").addEventListener("click", () => {
|
||||
showSettings(false)
|
||||
currentChat = null
|
||||
if (narrow()) sideOpen(false)
|
||||
newChat(kind)
|
||||
markCurrent()
|
||||
localPane()
|
||||
})
|
||||
$("new-chat-btn").addEventListener("click", () => startNew(kind))
|
||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
@@ -215,6 +261,19 @@ async function boot() {
|
||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
||||
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
openPalette()
|
||||
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
startNew(kind)
|
||||
} else if (mod && e.key === ",") {
|
||||
e.preventDefault()
|
||||
showSettings(true)
|
||||
}
|
||||
})
|
||||
|
||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
// Models as the person picks them: each with its avatar. A model with a picture (`image` in
|
||||
// /api/v2/models) shows it, read through the bridge with the app's token and handed to the page
|
||||
// as a `data:` URL; until it arrives, and for a model without one, the avatar is what the
|
||||
// server's web interface draws then -- its initial on a colour of its own, the hue worked out
|
||||
// from the model's id exactly as the server does it (`stable_hue`: the first two bytes of the
|
||||
// id's MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
|
||||
// lightness are the theme's (app.css), only the hue is the model's.
|
||||
|
||||
import { h, icon, ICONS } from "./dom.js"
|
||||
import { invoke } from "./bridge.js"
|
||||
import { menu } from "./popup.js"
|
||||
|
||||
// Each picture once per run: its path -> a promise of its `data:` URL (null when it cannot be had,
|
||||
// so a missing picture is not asked for again with every redraw).
|
||||
const pictures = new Map()
|
||||
|
||||
function picture(path) {
|
||||
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
||||
return pictures.get(path)
|
||||
}
|
||||
|
||||
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
|
||||
function md5(text) {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
|
||||
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
|
||||
const len = bytes.length
|
||||
const words = (((len + 8) >>> 6) + 1) * 16
|
||||
const w = new Uint32Array(words)
|
||||
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
|
||||
w[len >> 2] |= 0x80 << ((len % 4) * 8)
|
||||
w[words - 2] = (len * 8) >>> 0
|
||||
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
|
||||
let a0 = 0x67452301
|
||||
let b0 = 0xefcdab89
|
||||
let c0 = 0x98badcfe
|
||||
let d0 = 0x10325476
|
||||
for (let off = 0; off < words; off += 16) {
|
||||
let [A, B, C, D] = [a0, b0, c0, d0]
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F
|
||||
let g
|
||||
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
|
||||
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
|
||||
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
|
||||
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
|
||||
F = (F + A + K[i] + w[off + g]) >>> 0
|
||||
const s = S[(i >> 4) * 4 + (i % 4)]
|
||||
A = D
|
||||
D = C
|
||||
C = B
|
||||
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
|
||||
}
|
||||
a0 = (a0 + A) >>> 0
|
||||
b0 = (b0 + B) >>> 0
|
||||
c0 = (c0 + C) >>> 0
|
||||
d0 = (d0 + D) >>> 0
|
||||
}
|
||||
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
|
||||
}
|
||||
|
||||
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
|
||||
export function stableHue(id) {
|
||||
const d = md5(String(id))
|
||||
return ((d[0] << 8) | d[1]) % 360
|
||||
}
|
||||
|
||||
/** A model's avatar: its picture, or its initial on its own colour while there is none. */
|
||||
export function avatar(model) {
|
||||
const label = String(model?.name || model?.id || "?").trim() || "?"
|
||||
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
|
||||
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
|
||||
if (typeof model?.image === "string" && model.image) {
|
||||
picture(model.image).then((url) => {
|
||||
if (!url || !url.startsWith("data:image/")) return
|
||||
el.classList.add("avatar--image")
|
||||
el.replaceChildren(h("img", { src: url, alt: "", draggable: "false" }))
|
||||
})
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
|
||||
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
|
||||
*/
|
||||
export function modelPicker({ id, models, value, onChange, label }) {
|
||||
let current = value
|
||||
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
|
||||
const draw = () => {
|
||||
const m = models.find((x) => x.id === current)
|
||||
btn.replaceChildren(
|
||||
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
|
||||
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
|
||||
icon(ICONS.chevronDown),
|
||||
)
|
||||
btn.title = m ? `${m.name} (${m.id})` : current || ""
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
menu(
|
||||
btn,
|
||||
models.map((m) => ({
|
||||
label: m.name,
|
||||
lead: avatar(m),
|
||||
checked: m.id === current,
|
||||
run: () => {
|
||||
if (m.id === current) return
|
||||
current = m.id
|
||||
draw()
|
||||
onChange?.(m.id)
|
||||
},
|
||||
})),
|
||||
)
|
||||
})
|
||||
Object.defineProperty(btn, "value", {
|
||||
get: () => current,
|
||||
set: (v) => {
|
||||
current = v
|
||||
draw()
|
||||
},
|
||||
})
|
||||
draw()
|
||||
return btn
|
||||
}
|
||||
|
||||
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
|
||||
//
|
||||
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||
// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws
|
||||
// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or
|
||||
// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`.
|
||||
|
||||
/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */
|
||||
export function levelsOf(model) {
|
||||
const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null
|
||||
const levels = given || (model?.efforts?.length ? model.efforts : ["default"])
|
||||
const control =
|
||||
["none", "switch", "slider"].includes(model?.control) ? model.control
|
||||
: levels.length < 2 ? "none"
|
||||
: levels.length === 2 && levels.includes("off") ? "switch"
|
||||
: "slider"
|
||||
const start = levels.includes(model?.effort) ? model.effort : levels[0]
|
||||
return { levels, start, control }
|
||||
}
|
||||
|
||||
/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */
|
||||
export function levelWord(level) {
|
||||
return level === "off" ? t("off") : level === "default" ? t("default") : String(level)
|
||||
}
|
||||
|
||||
/**
|
||||
* The control for a model's levels, or null where it has one level: the Thinking switch (a real
|
||||
* checkbox with the switch role, the label its tap target) or the selector. `value` is the level
|
||||
* in force; `onChange(level)` when the person changes it. The element's `value` is the level.
|
||||
*/
|
||||
export function effortControl({ model, value, onChange, id, compact }) {
|
||||
const { levels, start, control } = levelsOf(model)
|
||||
if (control === "none") return null
|
||||
let current = levels.includes(value) ? value : start
|
||||
if (control === "switch") {
|
||||
const on = levels.find((l) => l !== "off") || start
|
||||
const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" })
|
||||
const el = h(
|
||||
"label",
|
||||
{ class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") },
|
||||
box,
|
||||
h("span", { class: "toggle__label", text: t("Think before answering") }),
|
||||
)
|
||||
box.addEventListener("change", () => {
|
||||
current = box.checked ? on : "off"
|
||||
onChange?.(current)
|
||||
})
|
||||
Object.defineProperty(el, "value", {
|
||||
get: () => current,
|
||||
set: (v) => {
|
||||
current = v
|
||||
box.checked = v !== "off"
|
||||
},
|
||||
})
|
||||
return el
|
||||
}
|
||||
const el = h(
|
||||
"select",
|
||||
{ id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") },
|
||||
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })),
|
||||
)
|
||||
el.addEventListener("change", () => {
|
||||
current = el.value
|
||||
onChange?.(current)
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs
|
||||
* on its device), 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 fields away
|
||||
* where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
|
||||
*/
|
||||
export function teamPossible(model) {
|
||||
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* The team's size: the fewest and the most agents, the chat's own agent included, as two number
|
||||
* fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's
|
||||
* default for new chats then stands); `onChange({ min, max })` once a field is changed, with the
|
||||
* empty one left out. The server checks the pair and says the bounds when it refuses one.
|
||||
*/
|
||||
export function agentsControl({ value, onChange, id, compact }) {
|
||||
const field = (which, v, label) =>
|
||||
h("input", {
|
||||
type: "number",
|
||||
class: "agents__input",
|
||||
id: which === "min" ? id : null,
|
||||
min: "1",
|
||||
step: "1",
|
||||
inputmode: "numeric",
|
||||
value: v === undefined || v === null ? "" : String(v),
|
||||
placeholder: compact ? null : t("default"),
|
||||
"aria-label": label,
|
||||
title: label,
|
||||
})
|
||||
const low = field("min", value?.min, t("Fewest"))
|
||||
const high = field("max", value?.max, t("Most"))
|
||||
const bounds = () => {
|
||||
high.min = low.value || "1"
|
||||
if (high.value) low.max = high.value
|
||||
else low.removeAttribute("max")
|
||||
}
|
||||
bounds()
|
||||
const read = () => {
|
||||
const out = {}
|
||||
if (low.value) out.min = Number(low.value)
|
||||
if (high.value) out.max = Number(high.value)
|
||||
return out
|
||||
}
|
||||
for (const input of [low, high]) {
|
||||
input.addEventListener("change", () => {
|
||||
bounds()
|
||||
onChange?.(read())
|
||||
})
|
||||
}
|
||||
const el = h(
|
||||
"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.") },
|
||||
compact ? h("span", { class: "agents__label", text: t("Agents") }) : null,
|
||||
low,
|
||||
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
|
||||
high,
|
||||
)
|
||||
Object.defineProperty(el, "value", {
|
||||
get: read,
|
||||
set: (v) => {
|
||||
low.value = v?.min ?? ""
|
||||
high.value = v?.max ?? ""
|
||||
bounds()
|
||||
},
|
||||
})
|
||||
return el
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
||||
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
|
||||
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
|
||||
// is there.
|
||||
|
||||
import { h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get } from "./bridge.js"
|
||||
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
|
||||
let dlg = null
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
function fold(text) {
|
||||
return String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}+/gu, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function matches(text, words) {
|
||||
const f = fold(text)
|
||||
return words.every((w) => f.includes(w))
|
||||
}
|
||||
|
||||
export async function openPalette() {
|
||||
if (dlg) return
|
||||
const input = h("input", { type: "text", class: "palette__input", placeholder: t("Type a command or a chat's title"), "aria-label": t("Search"), role: "combobox", "aria-expanded": "true", "aria-controls": "palette-list", autocomplete: "off" })
|
||||
const list = h("ul", { class: "palette__list", id: "palette-list", role: "listbox" })
|
||||
dlg = h("dialog", { class: "dialog palette", "aria-label": t("Commands") }, h("div", { class: "palette__line" }, icon(ICONS.search), input), list)
|
||||
document.body.append(dlg)
|
||||
let chats = []
|
||||
let shown = []
|
||||
let at = 0
|
||||
const close = () => {
|
||||
if (!dlg) return
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
dlg = null
|
||||
}
|
||||
const choose = (item) => {
|
||||
close()
|
||||
item?.run()
|
||||
}
|
||||
const draw = () => {
|
||||
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
||||
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
||||
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
||||
shown = [
|
||||
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
|
||||
...found.map((c) => ({
|
||||
kind: c.kind,
|
||||
label: c.title || t("New chat"),
|
||||
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
|
||||
run: () => ctx.open(c),
|
||||
})),
|
||||
]
|
||||
at = Math.min(at, Math.max(0, shown.length - 1))
|
||||
clear(list)
|
||||
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
|
||||
shown.forEach((item, i) => {
|
||||
list.append(
|
||||
h(
|
||||
"li",
|
||||
{
|
||||
id: `palette-${i}`,
|
||||
role: "option",
|
||||
class: "palette__item",
|
||||
"aria-selected": String(i === at),
|
||||
onclick: () => choose(item),
|
||||
onpointermove: () => {
|
||||
if (at !== i) {
|
||||
at = i
|
||||
mark()
|
||||
}
|
||||
},
|
||||
},
|
||||
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||
h("span", { class: "palette__label", text: item.label }),
|
||||
h("span", { class: "palette__hint", text: item.hint }),
|
||||
),
|
||||
)
|
||||
})
|
||||
mark()
|
||||
}
|
||||
const mark = () => {
|
||||
list.querySelectorAll(".palette__item").forEach((el, i) => el.setAttribute("aria-selected", String(i === at)))
|
||||
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
||||
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
at = 0
|
||||
draw()
|
||||
})
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
if (!shown.length) return
|
||||
at = (at + (e.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length
|
||||
mark()
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
choose(shown[at])
|
||||
}
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
close()
|
||||
})
|
||||
dlg.addEventListener("click", (e) => {
|
||||
if (e.target === dlg) close()
|
||||
})
|
||||
dlg.showModal()
|
||||
input.focus()
|
||||
draw()
|
||||
try {
|
||||
const r = await get("/chats?limit=100")
|
||||
chats = (r.chats || []).filter((c) => !c.archived && !c.temporary)
|
||||
if (dlg) draw()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function initPalette(context) {
|
||||
ctx = context
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
|
||||
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
|
||||
// click outside close them; the focus goes back where it came from.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
|
||||
let openMenu = null
|
||||
|
||||
/** Close the menu that is open, if any. */
|
||||
export function closeMenu() {
|
||||
if (!openMenu) return
|
||||
const { el, back } = openMenu
|
||||
openMenu = null
|
||||
el.remove()
|
||||
document.removeEventListener("pointerdown", outside, true)
|
||||
back?.focus?.()
|
||||
}
|
||||
|
||||
function outside(e) {
|
||||
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||
*/
|
||||
export function menu(anchor, items) {
|
||||
closeMenu()
|
||||
const plain = !items.some((i) => i?.lead)
|
||||
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
el.append(h("div", { class: "menu__sep", role: "separator" }))
|
||||
continue
|
||||
}
|
||||
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
|
||||
// labels line up whether or not an item has a lead.
|
||||
el.append(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: item.checked === undefined ? "menuitem" : "menuitemradio",
|
||||
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
|
||||
class: `menu__item${item.danger ? " is-danger" : ""}`,
|
||||
disabled: !!item.disabled,
|
||||
onclick: () => {
|
||||
closeMenu()
|
||||
item.run()
|
||||
},
|
||||
},
|
||||
h("span", { class: "menu__lead" }, item.lead || null),
|
||||
h("span", { class: "menu__label", text: item.label }),
|
||||
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
|
||||
const i = all.indexOf(document.activeElement)
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeMenu()
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
all[(i + 1) % all.length]?.focus()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
all[(i - 1 + all.length) % all.length]?.focus()
|
||||
}
|
||||
})
|
||||
document.body.append(el)
|
||||
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
|
||||
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
|
||||
const r = el.getBoundingClientRect()
|
||||
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
||||
el.style.width = `${Math.ceil(r.width)}px`
|
||||
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
||||
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||
document.addEventListener("pointerdown", outside, true)
|
||||
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
|
||||
* buttons. Resolves with the line (or true) on OK, null on cancel.
|
||||
*/
|
||||
export function ask({ title, text, value, ok, cancel, danger }) {
|
||||
return new Promise((resolve) => {
|
||||
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
|
||||
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "dialog__body", method: "dialog" },
|
||||
h("h2", { class: "dialog__title", text: title }),
|
||||
text ? h("p", { class: "muted", text }) : null,
|
||||
input,
|
||||
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
|
||||
)
|
||||
const dlg = h("dialog", { class: "dialog" }, form)
|
||||
let settled = false
|
||||
const done = (v) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
resolve(v)
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault()
|
||||
if (input && !input.value.trim()) return input.focus()
|
||||
done(input ? input.value.trim() : true)
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
done(null)
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
} else okBtn.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
|
||||
* it is and selectable), and Copy beside Close. Resolves when it is closed.
|
||||
*/
|
||||
export function showLine({ title, text, code, copy, copied, close }) {
|
||||
return new Promise((resolve) => {
|
||||
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
|
||||
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
|
||||
const closeBtn = h("button", { type: "button", class: "btn", text: close })
|
||||
const dlg = h(
|
||||
"dialog",
|
||||
{ class: "dialog" },
|
||||
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
|
||||
)
|
||||
const done = () => {
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
resolve()
|
||||
}
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard
|
||||
?.writeText(code)
|
||||
.then(() => (copyBtn.textContent = copied))
|
||||
.catch(() => {
|
||||
// No clipboard: the line is selected, for Ctrl+C.
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(line)
|
||||
getSelection()?.removeAllRanges()
|
||||
getSelection()?.addRange(range)
|
||||
})
|
||||
})
|
||||
closeBtn.addEventListener("click", done)
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
done()
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
copyBtn.focus()
|
||||
})
|
||||
}
|
||||
+13
-3
@@ -9,8 +9,10 @@ const AGENT = {
|
||||
off: () => [t("The agent is off."), ""],
|
||||
not_linked: () => [t("This computer is not linked to the server yet."), ""],
|
||||
starting: () => [t("The agent is starting…"), ""],
|
||||
connecting: () => [t("The agent is connecting to the server…"), ""],
|
||||
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
||||
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
|
||||
waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
|
||||
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
|
||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
||||
}
|
||||
|
||||
@@ -18,10 +20,18 @@ let folderError = ""
|
||||
let linking = null // { user_code, uri } while the computer is being linked
|
||||
let linkError = ""
|
||||
|
||||
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
|
||||
export function agentWords(s) {
|
||||
const a = s.agent
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
||||
if (a.state === "elsewhere") {
|
||||
const pid = a.detail?.pid
|
||||
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
|
||||
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
|
||||
}
|
||||
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
|
||||
}
|
||||
|
||||
function section(id, title, ...body) {
|
||||
|
||||
+26
-4
@@ -9,12 +9,35 @@ const WORDS = {
|
||||
off: () => t("agent off"),
|
||||
not_linked: () => t("not linked"),
|
||||
starting: () => t("agent starting…"),
|
||||
connecting: () => t("agent connecting…"),
|
||||
running: () => t("agent running"),
|
||||
waiting: () => t("agent waiting"),
|
||||
refused: () => t("agent refused"),
|
||||
elsewhere: () => t("agent running (service)"),
|
||||
elsewhere_waiting: () => t("agent waiting"),
|
||||
failed: () => t("agent stopped"),
|
||||
no_folders: () => t("agent: no folders"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
||||
* into no folder is said too -- a green light would say it works when it cannot.
|
||||
*/
|
||||
export function pillState(s) {
|
||||
const a = s.agent
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||
if (linked && noFolders) return "no_folders"
|
||||
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
|
||||
return a.state
|
||||
}
|
||||
|
||||
/** Why the agent is not working, in the CLI's words, when it said. */
|
||||
export function agentWhy(a) {
|
||||
if (a.state === "elsewhere") return a.detail?.why || ""
|
||||
return typeof a.detail === "string" ? a.detail : ""
|
||||
}
|
||||
|
||||
export function renderBar(s) {
|
||||
let host = ""
|
||||
try {
|
||||
@@ -22,12 +45,11 @@ export function renderBar(s) {
|
||||
} catch {}
|
||||
$("host").textContent = host
|
||||
$("host").title = s.server || ""
|
||||
// Connected but allowed into no folder: a green light would say it works when it cannot.
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
|
||||
const state = pillState(s)
|
||||
const reason = agentWhy(s.agent)
|
||||
$("agent").dataset.state = state
|
||||
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
|
||||
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
|
||||
$("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent")
|
||||
$("update").hidden = !s.update
|
||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
||||
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
/* LLeMbas v2.0.0-beta.1 (a3b0e660cc4d) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||
/* LLeMbas v2.0.0-beta.5 (02d0c874434e) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||
The font-face rules are left out; the app bundles no font. */
|
||||
/*
|
||||
Design tokens.
|
||||
@@ -109,6 +109,7 @@
|
||||
one that names what it is: a theme can then widen every menu at once, and
|
||||
two popovers cannot drift a rem apart by accident. Control heights are
|
||||
`--control-h`; these are the boxes around them. */
|
||||
--w-num: 4.5rem; /* a column of figures: tokens, a share, an elapsed time */
|
||||
--w-field-xs: 8rem; /* a short input: a count, a duration */
|
||||
--w-field-sm: 10rem; /* a short select's floor */
|
||||
--w-field: 14rem; /* a capped field, a label column, a small menu */
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* esm.sh - @lezer/lr@1.4.11 */
|
||||
import __Process$ from "/node/process.mjs";
|
||||
import __Process$ from "./node-process.mjs";
|
||||
import{Parser as q,NodeProp as O,NodeSet as J,NodeType as K,DefaultBufferLength as Q,Tree as w,IterMode as V}from"./lezer-common.mjs";var R=class a{constructor(t,e,s,i,h,r,n,o,l,u=0,f){this.p=t,this.stack=e,this.state=s,this.reducePos=i,this.pos=h,this.score=r,this.buffer=n,this.bufferBase=o,this.curContext=l,this.lookAhead=u,this.parent=f}toString(){return`[${this.stack.filter((t,e)=>e%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(t,e,s=0){let i=t.parser.context;return new a(t,[],e,s,s,0,[],0,i?new S(i,i.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(t,e){this.stack.push(this.state,e,this.bufferBase+this.buffer.length),this.state=t}reduce(t){var e;let s=t>>19,i=t&65535,{parser:h}=this.p,r=this.reducePos<this.pos-25&&this.setLookAhead(this.pos),n=h.dynamicPrecedence(i);if(n&&(this.score+=n),s==0){i<h.minRepeatTerm&&this.reducePos<this.pos&&(this.reducePos=this.pos),this.pushState(h.getGoto(this.state,i,!0),this.reducePos),i<h.minRepeatTerm&&this.storeNode(i,this.reducePos,this.reducePos,r?8:4,!0),this.reduceContext(i,this.reducePos);return}let o=this.stack.length-(s-1)*3-(t&262144?6:0),l=o?this.stack[o-2]:this.p.ranges[0].from;i<h.minRepeatTerm&&l==this.reducePos&&this.reducePos<this.pos&&(this.reducePos=this.pos);let u=this.reducePos-l;u>=2e3&&!(!((e=this.p.parser.nodeSet.types[i])===null||e===void 0)&&e.isAnonymous)&&(l==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=u):this.p.lastBigReductionSize<u&&(this.p.bigReductionCount=1,this.p.lastBigReductionStart=l,this.p.lastBigReductionSize=u));let f=o?this.stack[o-1]:0,d=this.bufferBase+this.buffer.length-f;if(i<h.minRepeatTerm||t&131072){let c=h.stateFlag(this.state,1)?this.pos:this.reducePos;this.storeNode(i,l,c,d+4,!0)}if(t&262144)this.state=this.stack[o];else{let c=this.stack[o-3];this.state=h.getGoto(c,i,!0)}for(;this.stack.length>o;)this.stack.pop();this.reduceContext(i,l)}storeNode(t,e,s,i=4,h=!1){if(t==0&&(!this.stack.length||this.stack[this.stack.length-1]<this.buffer.length+this.bufferBase)){let r=this.buffer.length;if(r>0&&this.buffer[r-4]==0&&this.buffer[r-1]>-1){if(e==s)return;if(this.buffer[r-2]>=e){this.buffer[r-2]=s;return}}}if(!h||this.pos==s)this.buffer.push(t,e,s,i);else{let r=this.buffer.length;if(r>0&&(this.buffer[r-4]!=0||this.buffer[r-1]<0)){let n=!1;for(let o=r;o>0&&this.buffer[o-2]>s;o-=4)if(this.buffer[o-1]>=0){n=!0;break}if(n)for(;r>0&&this.buffer[r-2]>s;)this.buffer[r]=this.buffer[r-4],this.buffer[r+1]=this.buffer[r-3],this.buffer[r+2]=this.buffer[r-2],this.buffer[r+3]=this.buffer[r-1],r-=4,i>4&&(i-=4)}this.buffer[r]=t,this.buffer[r+1]=e,this.buffer[r+2]=s,this.buffer[r+3]=i}}shift(t,e,s,i){if(t&131072)this.pushState(t&65535,this.pos);else if((t&262144)==0){let h=t,{parser:r}=this.p;this.pos=i;let n=r.stateFlag(h,1);!n&&(i>s||e<=r.maxNode)&&(this.reducePos=i),this.pushState(h,n?s:Math.min(s,this.reducePos)),this.shiftContext(e,s),e<=r.maxNode&&this.buffer.push(e,s,i,4)}else this.pos=i,this.shiftContext(e,s),e<=this.p.parser.maxNode&&this.buffer.push(e,s,i,4)}apply(t,e,s,i){t&65536?this.reduce(t):this.shift(t,e,s,i)}useNode(t,e,s){let i=this.p.reused.length-1;(i<0||this.p.reused[i]!=t)&&(this.p.reused.push(t),i++);let h=this.reducePos=this.pos=s+t.length;this.pushState(e,s),this.buffer.push(i,s,h,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,t,this,this.p.stream.reset(this.pos-t.length)))}split(){let t=this,e=t.buffer.length;for(e&&t.buffer[e-4]==0&&(e-=4);e>0&&t.buffer[e-2]>t.reducePos;)e-=4;let s=t.buffer.slice(e),i=t.bufferBase+e;for(;t&&i==t.bufferBase;)t=t.parent;return new a(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,s,i,this.curContext,this.lookAhead,t)}recoverByDelete(t,e){let s=t<=this.p.parser.maxNode;s&&this.storeNode(t,this.pos,e,4),this.storeNode(0,this.pos,e,s?8:4),this.pos=this.reducePos=e,this.score-=190}canShift(t){for(let e=new y(this);;){let s=this.p.parser.stateSlot(e.state,4)||this.p.parser.hasAction(e.state,t);if(s==0)return!1;if((s&65536)==0)return!0;e.reduce(s)}}recoverByInsert(t){if(this.stack.length>=300)return[];let e=this.p.parser.nextStates(this.state);if(e.length>8||this.stack.length>=120){let i=[];for(let h=0,r;h<e.length;h+=2)(r=e[h+1])!=this.state&&this.p.parser.hasAction(r,t)&&i.push(e[h],r);if(this.stack.length<120)for(let h=0;i.length<8&&h<e.length;h+=2){let r=e[h+1];i.some((n,o)=>o&1&&n==r)||i.push(e[h],r)}e=i}let s=[];for(let i=0;i<e.length&&s.length<4;i+=2){let h=e[i+1];if(h==this.state)continue;let r=this.split();r.pushState(h,this.pos),r.storeNode(0,r.pos,r.pos,4,!0),r.shiftContext(e[i],this.pos),r.reducePos=this.pos,r.score-=200,s.push(r)}return s}forceReduce(){let{parser:t}=this.p,e=t.stateSlot(this.state,5);if((e&65536)==0)return!1;if(!t.validAction(this.state,e)){let s=e>>19,i=e&65535,h=this.stack.length-s*3;if(h<0||t.getGoto(this.stack[h],i,!1)<0){let r=this.findForcedReduction();if(r==nuLine truncated
|
||||
//# sourceMappingURL=lr.bundle.mjs.map
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
The `process` that esm.sh's builds import as "/node/process.mjs" (its Node
|
||||
polyfill). @lezer/lr reads `process.env.LOG` to decide whether to log its
|
||||
parse; nothing else here asks. Without this the absolute path is a 404 on
|
||||
this server, @lezer/lr fails to load, and with it every editor language
|
||||
that parses: the editor then opens with no highlighting at all.
|
||||
Mapped by the import map in files/edit.html.
|
||||
*/
|
||||
export default { env: {} };
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"tag": "v2.0.0-beta.1",
|
||||
"commit": "a3b0e660cc4de3d25d75e560afd8441d0fa37a00",
|
||||
"tag": "v2.0.0-beta.5",
|
||||
"commit": "02d0c874434e3b726193bf94fb9741b4f8d0a22c",
|
||||
"files": {
|
||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||
@@ -18,6 +18,7 @@
|
||||
"cm-view.mjs": {"server": "ae7c45442292a6cb1bff0c82b0493924489911ff4f239eb2067293180c0f1d5e", "copy": "7ecdf6913bff51de827d31d56641ccb82f7dfb3801dde0e40c32eadc98815226"},
|
||||
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
||||
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
||||
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "78a97314ddeaa4ae5c251292dcccf0d731ebdca093a59c15793b52131cd50ab4"}
|
||||
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
||||
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user