Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c82a3c7b
|
||
|
|
3510582c8f
|
||
|
|
ede91887f7
|
||
|
|
6bad293b29
|
||
|
|
4058b4b018
|
||
|
|
8590792b50
|
||
|
|
5ce5a62dff
|
||
|
|
9ddde6c870
|
||
|
|
a13e437823
|
||
|
|
89806982a9
|
||
|
|
6a095cb102
|
||
|
|
ad36932c9f
|
||
|
|
af14f4a229
|
||
|
|
5b00822551
|
||
|
|
1749d2369e
|
||
|
|
a12ed7ac36
|
||
|
|
b8a4f1bc57
|
||
|
|
7c0c86ada1
|
||
|
|
de914f4b16
|
||
|
|
b6d69575f7
|
||
|
|
d04bc4b6bd
|
||
|
|
844684a152
|
||
|
|
917c1e2286
|
||
|
|
46b83c3809
|
||
|
|
2a51b4f6d0
|
||
|
|
7ee394b549
|
No files matched your search
+157
@@ -6,6 +6,163 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0-beta.5] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
|
||||||
|
setting and the panel's compaction need.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subagents**, by that name everywhere. The chip under the message box says **Subagents** with the
|
||||||
|
least and the most agents a chat runs ("Subagents 1–4"), and opens one slider with two handles,
|
||||||
|
**Min** and **Max**, from 1 to the server's cap -- Min never passes Max, and both move with the
|
||||||
|
arrow keys as well. The numbers count the chat's own agent: **1 is the agent alone**, with no
|
||||||
|
subagents; Max 4 is the agent and up to 3 subagents. The chip shows where the
|
||||||
|
server offers subagents for the chat (for a new chat, for its model), and the most is the
|
||||||
|
server's cap. Where the chat's model serves one request at a time, it says that subagents need
|
||||||
|
another model, on another server, named in the chat. Subagents work on the chat's own model
|
||||||
|
unless you name another one in the chat -- there is no setting for a subagent's model. A
|
||||||
|
scheduled task's field is **Subagents** too. Needs LLeMbas 2.0.0-beta.7 for the server's word on
|
||||||
|
all of this; with an older one the app guesses as before.
|
||||||
|
- **The activity panel, reshaped.** A button in the chat's header opens it at any time, and the
|
||||||
|
line above the message box shows only while something runs ("3 running · 1 queued · 2
|
||||||
|
finished"). An agent chat's **Environment** (its folder, and where it is) comes first. **Context**
|
||||||
|
says how far it is to the automatic compaction ("80.6k until auto-compact") beside a **Compact**
|
||||||
|
button that summarises the earlier turns now (greyed while there is nothing to summarise). **Background tasks** lists subagents, a team's
|
||||||
|
members and background commands under **Running**, each with how long it has run and a stop
|
||||||
|
square, unfolding to what it is, its latest step, what it is writing and its conversation; the
|
||||||
|
tasks under one fold beneath it ("Tasks: 2"), and the finished ones fold away under **Finished**.
|
||||||
|
The team's **Room** and the **Plan** have sections of their own.
|
||||||
|
|
||||||
|
## [1.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.6. Scheduled tasks need LLeMbas
|
||||||
|
2.0.0-beta.6 or later.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An agent that could not link now gets its link.** Signing in asked the server for the agent's
|
||||||
|
token only when the built-in LLeMbas CLI had no login for that server on file -- so a computer
|
||||||
|
with an old login the server no longer takes got the app's own token alone, and the agent stayed
|
||||||
|
refused. The sign-in now asks for the agent's token unless the agent's link works right now, and
|
||||||
|
the CLI's new login replaces the old one. **Link this computer** (and **Link again**) is now its
|
||||||
|
own approval for the agent's token, handed to the CLI the same way; before, it ran the CLI's own
|
||||||
|
login, which for a server it was logged in to only refreshed the login it had.
|
||||||
|
- **"Refused" means the server refused**, and says the server's reason. A service that stopped for
|
||||||
|
want of a login says *not linked*; any other reason, *stopped* with the reason.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A new look, the shape of an AI desktop app.** A slim sidebar: **New chat**, a search line,
|
||||||
|
**Scheduled**, **Files**, then your chats of both kinds -- agent chats and chats on the server --
|
||||||
|
under *Today* and *Earlier*, an agent chat with its folder's name; **Settings** with your account
|
||||||
|
at the foot. The sidebar folds away (its button in the title bar, or Ctrl+B) and is a drawer on a
|
||||||
|
narrow window.
|
||||||
|
- **A new chat is a chat page.** A greeting, the choice of **Agent** (on this computer) or **Chat**
|
||||||
|
(on the server), and the message box ready -- no form. What the chat starts with sits under the
|
||||||
|
text as small chips, each opening a menu: the folder and the mode for an agent chat, the model
|
||||||
|
with its picture, how hard it thinks (as the model offers it), and the team's size where a team
|
||||||
|
is offered. An open chat has the same chips, and changing one changes the chat.
|
||||||
|
- **The conversation** in a centred column: your messages in bubbles on the right, with attached
|
||||||
|
pictures as thumbnails and other files as tiles; replies full width; thinking and each tool call
|
||||||
|
a compact row that unfolds. **Send** is a round button that **Stop** replaces while a reply is
|
||||||
|
written.
|
||||||
|
- **The activity panel** opens from a chip above the message box, on every open chat; while
|
||||||
|
agents work, the chip says what they are doing.
|
||||||
|
- **The agent's status** is a chip in the title bar that opens what the agent says and the one
|
||||||
|
thing to do about it. When this computer is not linked -- or the server refused its link -- a
|
||||||
|
new agent chat says so right above the message box, with **Link this computer** (or **Link
|
||||||
|
again**) there, not only in Settings; while the approval waits, the code to compare is shown
|
||||||
|
with Cancel.
|
||||||
|
- **Files** is a page of its own, with this computer's folders and your files on the server as
|
||||||
|
two tabs. **Settings** is grouped -- Account, Agent, Project folders, Notifications, About --
|
||||||
|
with the groups listed beside them.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Scheduled tasks** (with a server that has them, LLeMbas 2.0.0-beta.6 or later): the list
|
||||||
|
with each task's next run, how its last run went and a switch to pause it, kept up to date as
|
||||||
|
the server runs them; a task's page with what it does, when (and in which time zone), its
|
||||||
|
model, effort, team and how it tells you, the next runs, and its history -- each run's status,
|
||||||
|
time, whether it was late, caught up or run by hand, the model it ran on (and that it switched
|
||||||
|
from a busy one), and its result to unfold in place, with its chat to open; earlier runs on
|
||||||
|
request. **Run now**, **Edit**, pause and resume, and delete. A new task is said in your own
|
||||||
|
words: the server reads them into a rule, a title and an instruction and shows the next five
|
||||||
|
runs before you save; the title and instruction can be changed, and where the words give no
|
||||||
|
time the days and the time can be set by hand. Each task's settings: when to tell you
|
||||||
|
(*when it matters*, *always*, *never*), a new chat for each run or one that grows, its model
|
||||||
|
(or the first free one of the scheduling models), its effort and its team. **Models** on the
|
||||||
|
list chooses which models scheduled tasks may run on, each shown free or busy.
|
||||||
|
- **A scheduled task's result comes as a system notification** while the app runs, by the
|
||||||
|
task's own setting: a result, never a run with nothing to report unless the task says
|
||||||
|
*always*, nothing for *never*; a failure the first time, and on the third in a row the
|
||||||
|
question whether to pause it. The app follows the server's event stream for this rather than
|
||||||
|
asking every few seconds.
|
||||||
|
|
||||||
|
## [1.0.0-beta.3] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.5. Needs a LLeMbas 2.0 server
|
||||||
|
(2.0.0-beta.5 or later for the activity panel, the Thinking switch and team sizes).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Attachments.** A message can carry files: the attach button beside the composer (the
|
||||||
|
system's file dialog), files dropped anywhere on the window, or an image pasted into the
|
||||||
|
composer. Each is sent to the server at once and shown above the composer with its size, or
|
||||||
|
with the server's reason when it refused it; one taken off before sending is deleted on the
|
||||||
|
server. The app sends only files you chose, dropped or pasted -- the page never names a path.
|
||||||
|
Agent chats get them too.
|
||||||
|
- **Edit a message, regenerate a reply, copy either.** Under your messages, *Edit message*
|
||||||
|
rewrites one in place and starts the reply again from there (everything after it is dropped,
|
||||||
|
as the app says before you save; on the agent's device too); under the last reply,
|
||||||
|
*Regenerate reply*. *Copy* puts a message, or a reply's text as Markdown, on the clipboard.
|
||||||
|
- **The chat's own menu** (⋯ in its header, or a right click on it in the sidebar): rename it,
|
||||||
|
open it in the web interface in your browser, archive it, delete it (asked first).
|
||||||
|
- **Change a chat's model** from its header, and how hard it thinks as the model offers it: a
|
||||||
|
model with one level shows nothing to choose; one that can switch thinking off and has one
|
||||||
|
effort shows a **Think before answering** switch; more levels keep the selector. A new chat
|
||||||
|
starts at the model's own start level, and the control follows the model you pick.
|
||||||
|
- **Agents, fewest and most**, beside it where the chat can have a team (a chat on the server,
|
||||||
|
on a model that serves at least two requests at once, with subagents on): how many agents the
|
||||||
|
chat's team may have, its own agent included. Saved at once in the header; on a new chat,
|
||||||
|
left empty, your default stands. A pair the server will not take is said in its words, and the
|
||||||
|
fields go back.
|
||||||
|
- **Model pictures**: a model the server has a picture for shows it in the pickers and under
|
||||||
|
each reply; the others show their initial on a colour of their own -- the same colour the web
|
||||||
|
interface gives them.
|
||||||
|
- **The activity panel** beside a chat (the header's bolt button, the strip above the message
|
||||||
|
box, or the palette): what is in the chat's context, by category, against the model's window;
|
||||||
|
the files the chat and its agents wrote, with lines added and removed, each opening in the
|
||||||
|
viewer; every background agent -- a team's members as a tree, helpers and commands -- with what
|
||||||
|
it was asked, its state, how long it has run, its model, its latest step and what it is
|
||||||
|
writing, its whole conversation to read and a button to stop it (a member after you confirm,
|
||||||
|
for those under it stop with it); the team's room, who wrote to whom and which messages are
|
||||||
|
reports; and the plan. It keeps up by itself while the chat is open. The strip above the
|
||||||
|
message box says what the agents are doing ("2 agents working · 1 queued") and opens it. On a
|
||||||
|
narrow window the panel covers the chat; at phone width it fills the window.
|
||||||
|
- **Continue an agent chat in a terminal**: its menu and its header show the command that opens
|
||||||
|
the agent's session in a terminal (`lembas -s …`, in the chat's folder), to copy. On this
|
||||||
|
computer it names the LLeMbas CLI as it starts here -- the one on your PATH, else the app's own.
|
||||||
|
- **A command palette** (Ctrl+K): new chats, settings, the agent and updates by name, and every
|
||||||
|
chat of both kinds by its title (accents optional). Ctrl+N starts a new chat of the tab you are
|
||||||
|
on; Ctrl+, opens the settings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The agent's status is the CLI's own word.** The title bar and Settings no longer say
|
||||||
|
*running* because a process runs: they say what the built-in LLeMbas CLI says of its link to
|
||||||
|
the server -- connecting, running (linked), waiting and why (the server cannot be reached, the
|
||||||
|
link dropped, a terminal on this computer holds the link), or refused and why. The same for a
|
||||||
|
`lembas service` you run yourself. When the CLI says trying again will not help (the server
|
||||||
|
refused this computer's token), the app no longer restarts it every second: it waits until you
|
||||||
|
link the computer again. When the agent stops with an error, its last words are the reason
|
||||||
|
shown, not just an exit status.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Czech sentence in the viewer was in Slovak; "Server files" and the composer's placeholder
|
||||||
|
were not translated.
|
||||||
|
|
||||||
## [1.0.0-beta.2] — 2026-11-07
|
## [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.
|
Carries LLeMbas CLI 2.0.0-beta.2 and the look of LLeMbas 2.0.0-beta.4.
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ an app of its own.
|
|||||||
computer's device. Agent chats work in your project folders here, without a terminal -- start
|
computer's device. Agent chats work in your project folders here, without a terminal -- start
|
||||||
them, follow them and answer them in the app.
|
them, follow them and answer them in the app.
|
||||||
- **Your server's chats** beside them: the same account, models and permissions as in the browser.
|
- **Your server's chats** beside them: the same account, models and permissions as in the browser.
|
||||||
- **Two explorers, kept apart**: the agent's working directory on this computer, and your files
|
- **A chat app's shape**: a slim sidebar with your chats of both kinds by day, a new chat that is
|
||||||
on the server.
|
a chat page, and the chat's folder, mode, model, effort and team size as chips in the message
|
||||||
|
box.
|
||||||
|
- **Scheduled tasks** (with a server that has them): the list, each task's history and *Run now*,
|
||||||
|
and a new one said in your own words, with its next runs shown before you save.
|
||||||
|
- **Files**, two trees kept apart: the agent's folders on this computer, and your files on the
|
||||||
|
server.
|
||||||
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
- **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.
|
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
|
||||||
|
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
|
||||||
|
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
|
||||||
|
- **A command palette** (Ctrl+K) for commands and every chat by its title.
|
||||||
|
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
||||||
|
waiting and why, refused with the server's reason -- not whether a process runs; and, when this
|
||||||
|
computer is not linked, **Link this computer** right where you start an agent chat.
|
||||||
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
|
- **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.
|
- **Its own window and tray**: close the window and the agent keeps running in the tray.
|
||||||
|
|
||||||
@@ -52,12 +63,13 @@ built into the app before anything is installed. Betas are not offered as update
|
|||||||
browser, where you are signed in already; approve the code shown in the app. This one approval
|
browser, where you are signed in already; approve the code shown in the app. This one approval
|
||||||
lets the app read and write your chats, as the web interface does, and links this computer as
|
lets the app read and write your chats, as the web interface does, and links this computer as
|
||||||
the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
|
the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
|
||||||
in its own configuration. The agent starts. (A computer linked already keeps its link; on a
|
in its own configuration. The agent starts. (A computer whose link works keeps it; on a server
|
||||||
server that cannot give both from one approval, **Link this computer** under **Settings** is a
|
that cannot give both from one approval, **Link this computer** -- above the message box of a
|
||||||
second one.)
|
new agent chat, in the agent's panel in the title bar, or in **Settings** -- is a second one.)
|
||||||
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
|
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
|
||||||
until you do.
|
until you do.
|
||||||
3. **New agent chat**: pick one of those folders, a mode and a model, and write.
|
3. **New chat**, **Agent**: pick one of those folders, a mode and a model in the chips under the
|
||||||
|
message box, and write.
|
||||||
|
|
||||||
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
|
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
|
||||||
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
||||||
@@ -73,6 +85,8 @@ device.
|
|||||||
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
|
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
|
||||||
holds the token.
|
holds the token.
|
||||||
- The explorer reads only inside the agent's folders.
|
- 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
|
- 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.
|
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.
|
- Links in a reply open in your browser, and only web and mail links.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.0-beta.2
|
2.0.0-beta.5
|
||||||
+4
-1
@@ -19,7 +19,10 @@ chmod 700 "$runtime"
|
|||||||
pids=()
|
pids=()
|
||||||
cleanup() {
|
cleanup() {
|
||||||
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
|
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
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
|||||||
+1249
-82
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -2085,7 +2085,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.2"
|
version = "1.0.0-beta.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.2"
|
version = "1.0.0-beta.5"
|
||||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||||
authors = ["Jaroslav Beneš"]
|
authors = ["Jaroslav Beneš"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+278
-13
@@ -4,6 +4,12 @@
|
|||||||
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
|
//! 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
|
//! 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.
|
//! 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::fs::{File, OpenOptions};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -20,6 +26,8 @@ use crate::cli;
|
|||||||
const RESTART_FOR_UPDATE: i32 = 75;
|
const RESTART_FOR_UPDATE: i32 = 75;
|
||||||
const BACKOFF: Duration = Duration::from_secs(10);
|
const BACKOFF: Duration = Duration::from_secs(10);
|
||||||
const STOP_GRACE: Duration = Duration::from_secs(15);
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||||
@@ -28,25 +36,110 @@ pub enum State {
|
|||||||
Off,
|
Off,
|
||||||
/// This computer is not signed in to the server yet.
|
/// This computer is not signed in to the server yet.
|
||||||
NotLinked,
|
NotLinked,
|
||||||
|
/// The process is started; the CLI has not said anything of its link yet.
|
||||||
Starting,
|
Starting,
|
||||||
|
/// The CLI is dialling the server.
|
||||||
|
Connecting,
|
||||||
|
/// The CLI says it is linked: agent chats can use this computer.
|
||||||
Running,
|
Running,
|
||||||
/// Another `lembas service` on this computer holds the link (its pid).
|
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||||
Elsewhere(u32),
|
/// the server cannot be reached, the link dropped).
|
||||||
/// It ended with an error; trying again shortly.
|
Waiting(String),
|
||||||
|
/// The server refused this computer's link, in its words (a token it no longer takes, or a
|
||||||
|
/// protocol it does not speak). The CLI will not try again by itself; linking again is the
|
||||||
|
/// way out. Only the server's refusal: a missing login is `NotLinked`.
|
||||||
|
Refused(String),
|
||||||
|
/// Another `lembas service` on this computer holds the link.
|
||||||
|
Elsewhere(Elsewhere),
|
||||||
|
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
|
||||||
Failed(String),
|
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 {
|
impl State {
|
||||||
pub fn label(&self) -> String {
|
pub fn label(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
State::Off => "Agent: off".into(),
|
State::Off => "Agent: off".into(),
|
||||||
State::NotLinked => "Agent: this computer is not linked".into(),
|
State::NotLinked => "Agent: this computer is not linked".into(),
|
||||||
State::Starting => "Agent: starting…".into(),
|
State::Starting => "Agent: starting…".into(),
|
||||||
|
State::Connecting => "Agent: connecting…".into(),
|
||||||
State::Running => "Agent: running".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}"),
|
State::Failed(why) => format!("Agent: stopped — {why}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this is a state the CLI's own status file decides, while the process runs.
|
||||||
|
fn told_by_cli(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
State::Starting | State::Connecting | State::Running | State::Waiting(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
|
||||||
|
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
|
||||||
|
/// every try, `waiting` between them) instead of flickering between the two.
|
||||||
|
pub fn from_link(link: &cli::Link, before: &State) -> State {
|
||||||
|
let why = link.detail.clone().unwrap_or_default();
|
||||||
|
match link.state.as_str() {
|
||||||
|
"linked" => State::Running,
|
||||||
|
"connecting" => match before {
|
||||||
|
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
|
||||||
|
_ => State::Connecting,
|
||||||
|
},
|
||||||
|
"waiting" => State::Waiting(why),
|
||||||
|
"stopped" => State::Refused(why),
|
||||||
|
_ => State::Starting,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status
|
||||||
|
/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any
|
||||||
|
/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which
|
||||||
|
/// is the server's word only. None when it said nothing.
|
||||||
|
fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option<State> {
|
||||||
|
if let Some(link) = stopped {
|
||||||
|
let why = link.detail.clone().unwrap_or_default();
|
||||||
|
// The CLI's advice to its terminal user ("sign in again: lembas login") is the app's
|
||||||
|
// Link button here.
|
||||||
|
let why = why
|
||||||
|
.split(" — sign in again")
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !why.is_empty() && why != "stopped" {
|
||||||
|
return Some(State::Refused(why));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let said = strip_prefix(said?);
|
||||||
|
if said.starts_with("not logged in") {
|
||||||
|
return Some(State::NotLinked);
|
||||||
|
}
|
||||||
|
Some(State::Failed(said))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a running process has said before its status file says anything: the one line the
|
||||||
|
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||||
|
fn from_output(line: &str) -> Option<State> {
|
||||||
|
line.contains("holds the link")
|
||||||
|
.then(|| State::Waiting(line.trim().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
||||||
@@ -124,15 +217,33 @@ impl Agent {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
self.pause(generation, BACKOFF);
|
self.pause(generation, Duration::from_secs(3));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !self.set(generation, State::Starting) {
|
if !self.set(generation, State::Starting) {
|
||||||
return;
|
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() {
|
let child = match self.spawn() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -146,6 +257,7 @@ impl Agent {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let pid = child.id();
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock().unwrap();
|
||||||
if g.generation != generation {
|
if g.generation != generation {
|
||||||
@@ -155,23 +267,36 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
g.child = Some(child);
|
g.child = Some(child);
|
||||||
}
|
}
|
||||||
self.set(generation, State::Running);
|
let code = self.wait_child(generation, pid, from);
|
||||||
let code = self.wait_child(generation);
|
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock().unwrap();
|
||||||
if g.generation != generation {
|
if g.generation != generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
g.child = None;
|
g.child = None;
|
||||||
drop(g);
|
drop(g);
|
||||||
|
let said = last_line(&self.log, from);
|
||||||
match code {
|
match code {
|
||||||
Some(RESTART_FOR_UPDATE) => continue,
|
Some(RESTART_FOR_UPDATE) => continue,
|
||||||
Some(0) => {
|
Some(0) => {
|
||||||
// It ended by itself without an error: nothing to do but start it again.
|
// It ended by itself without an error: the CLI's word for "starting again
|
||||||
self.pause(generation, Duration::from_secs(1));
|
// will not help". Waiting a long while (linking again starts it at once).
|
||||||
|
let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
|
||||||
|
let next = ended_cleanly(stopped.as_ref(), said.as_deref());
|
||||||
|
match next {
|
||||||
|
Some(state) => {
|
||||||
|
if !self.set(generation, state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pause(generation, REFUSED_WAIT);
|
||||||
|
}
|
||||||
|
None => self.pause(generation, Duration::from_secs(1)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
let why = other.map_or("ended by a signal".to_string(), |c| {
|
let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
|
||||||
format!("exit status {c}")
|
other.map_or("ended by a signal".to_string(), |c| {
|
||||||
|
format!("exit status {c}")
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if !self.set(generation, State::Failed(why)) {
|
if !self.set(generation, State::Failed(why)) {
|
||||||
return;
|
return;
|
||||||
@@ -217,7 +342,11 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
|
/// 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 {
|
loop {
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock().unwrap();
|
||||||
@@ -230,6 +359,21 @@ impl Agent {
|
|||||||
Some(Err(_)) | None => return None,
|
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));
|
thread::sleep(Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,3 +427,124 @@ fn terminate(mut child: Child) {
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
|
||||||
|
/// the file is read.
|
||||||
|
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
let mut f = File::open(log).ok()?;
|
||||||
|
let len = f.metadata().ok()?.len();
|
||||||
|
let start = from.max(len.saturating_sub(4096));
|
||||||
|
if start >= len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
f.seek(SeekFrom::Start(start)).ok()?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
f.take(len - start).read_to_end(&mut buf).ok()?;
|
||||||
|
String::from_utf8_lossy(&buf)
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|l| !l.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
|
||||||
|
fn strip_prefix(line: &str) -> String {
|
||||||
|
line.strip_prefix("lembas service: ")
|
||||||
|
.unwrap_or(line)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_is_only_the_servers_word() {
|
||||||
|
let refused = link(
|
||||||
|
"stopped",
|
||||||
|
Some("not allowed to link — sign in again: lembas login"),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(Some(&refused), None),
|
||||||
|
Some(State::Refused("not allowed to link".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(
|
||||||
|
None,
|
||||||
|
Some("lembas service: not logged in to any LLeMbas instance — lembas login <address>")
|
||||||
|
),
|
||||||
|
Some(State::NotLinked)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(None, Some("lembas service: logged in to several instances")),
|
||||||
|
Some(State::Failed("logged in to several instances".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(ended_cleanly(None, None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||||
|
cli::Link {
|
||||||
|
state: state.into(),
|
||||||
|
detail: detail.map(String::from),
|
||||||
|
pid: 1,
|
||||||
|
attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_state_shown_is_the_clis_word_for_its_link() {
|
||||||
|
let s = State::Starting;
|
||||||
|
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
|
||||||
|
assert_eq!(
|
||||||
|
from_link(&link("connecting", None, 1), &s),
|
||||||
|
State::Connecting
|
||||||
|
);
|
||||||
|
let waiting = from_link(
|
||||||
|
&link("waiting", Some("could not reach the instance"), 1),
|
||||||
|
&s,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
waiting,
|
||||||
|
State::Waiting("could not reach the instance".into())
|
||||||
|
);
|
||||||
|
// The next dial keeps the reason it is waiting, rather than flicker.
|
||||||
|
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
|
||||||
|
assert_eq!(
|
||||||
|
from_link(&link("stopped", Some("refused"), 2), &waiting),
|
||||||
|
State::Refused("refused".into())
|
||||||
|
);
|
||||||
|
// Not running is never shown as running.
|
||||||
|
for state in ["connecting", "waiting", "stopped", "?"] {
|
||||||
|
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_holding_the_link_is_said() {
|
||||||
|
assert_eq!(
|
||||||
|
from_output("a terminal here holds the link (/remote); waiting for it to close"),
|
||||||
|
Some(State::Waiting(
|
||||||
|
"a terminal here holds the link (/remote); waiting for it to close".into()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(from_output("linked"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_line_is_this_runs_own() {
|
||||||
|
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
|
||||||
|
std::fs::write(&p, "old run\nlinked\n").unwrap();
|
||||||
|
let from = std::fs::metadata(&p).unwrap().len();
|
||||||
|
assert_eq!(last_line(&p, from), None);
|
||||||
|
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
|
||||||
|
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
|
||||||
|
.unwrap();
|
||||||
|
let said = last_line(&p, from).unwrap();
|
||||||
|
assert_eq!(said, "lembas service: not logged in");
|
||||||
|
assert_eq!(strip_prefix(&said), "not logged in");
|
||||||
|
std::fs::remove_file(&p).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
|
/// 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()))
|
.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 {
|
fn home() -> PathBuf {
|
||||||
std::env::var_os("HOME")
|
std::env::var_os("HOME")
|
||||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
.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)
|
(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
|
/// 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.
|
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -151,6 +210,26 @@ mod tests {
|
|||||||
d
|
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]
|
#[test]
|
||||||
fn linked_reads_the_logins_the_cli_wrote() {
|
fn linked_reads_the_logins_the_cli_wrote() {
|
||||||
let d = scratch("linked");
|
let d = scratch("linked");
|
||||||
@@ -166,6 +245,33 @@ mod tests {
|
|||||||
fs::remove_dir_all(&d).unwrap();
|
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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_lock_counts_only_while_its_process_lives() {
|
fn a_lock_counts_only_while_its_process_lives() {
|
||||||
|
|||||||
+373
-30
@@ -9,9 +9,10 @@
|
|||||||
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
||||||
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
||||||
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
|
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
|
||||||
//! approval, and `lembas login --token-stdin` takes it (a server without that: `lembas login
|
//! approval, and `lembas login --token-stdin` takes it; *Link this computer* is a login for the
|
||||||
//! --json`, a second approval); `lembas service run` is the device (`agent.rs`). The app reads
|
//! agent's token alone, handed over the same way. `lembas service run` is the device
|
||||||
//! the CLI's state and runs its commands; it never writes the CLI's files.
|
//! (`agent.rs`). The app reads the CLI's state and runs its commands; it never writes the
|
||||||
|
//! CLI's files.
|
||||||
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
@@ -36,8 +37,8 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
|||||||
use tauri::tray::TrayIconBuilder;
|
use tauri::tray::TrayIconBuilder;
|
||||||
use tauri::window::Color;
|
use tauri::window::Color;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, WebviewWindow,
|
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
|
||||||
WebviewWindowBuilder, Window, WindowEvent, Wry,
|
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
@@ -97,6 +98,8 @@ struct App {
|
|||||||
agent: Agent,
|
agent: Agent,
|
||||||
tray: Mutex<Option<TrayItems>>,
|
tray: Mutex<Option<TrayItems>>,
|
||||||
linking: Mutex<bool>,
|
linking: Mutex<bool>,
|
||||||
|
/// Set to stop waiting for the approval of a link in progress.
|
||||||
|
link_cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
/// The newer release the last check found, if any.
|
/// The newer release the last check found, if any.
|
||||||
update: Mutex<Option<String>>,
|
update: Mutex<Option<String>>,
|
||||||
/// The agent's folders, as `lembas remote` last answered.
|
/// The agent's folders, as `lembas remote` last answered.
|
||||||
@@ -114,6 +117,8 @@ struct App {
|
|||||||
next_stream: AtomicU64,
|
next_stream: AtomicU64,
|
||||||
sealed: sealed::Pages,
|
sealed: sealed::Pages,
|
||||||
watch: watch::Watch,
|
watch: watch::Watch,
|
||||||
|
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||||
|
dropped: Mutex<Vec<PathBuf>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrayItems {
|
struct TrayItems {
|
||||||
@@ -284,9 +289,12 @@ enum SignIn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
|
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
|
||||||
/// Where the server can (`one_approval`) and this computer is not linked to it yet, the same
|
/// Where the server can (`one_approval`), the same approval asks for the agent's token too, unless
|
||||||
/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its
|
/// the agent's link works right now (the CLI says it is linked). Whether the CLI has a login for
|
||||||
/// link; "Link again" is the way to replace it.
|
/// this server on file is not asked: a login the server refuses is a file all the same, and
|
||||||
|
/// asking that left a computer with a refused link and no way to a new one from the sign-in.
|
||||||
|
/// After the approval the agent's token goes to the CLI -- whose `--token-stdin` login replaces
|
||||||
|
/// the one it had for this server -- or, when the link came up meanwhile, is given up.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
@@ -304,7 +312,7 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
announce(&handle);
|
announce(&handle);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let host = cli::hostname().unwrap_or_default();
|
let host = cli::hostname().unwrap_or_default();
|
||||||
let with_agent = d.one_approval && !cli::linked_to(&d.base);
|
let with_agent = d.one_approval && !link_works(&handle.state::<App>());
|
||||||
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
||||||
let _ = handle
|
let _ = handle
|
||||||
.opener()
|
.opener()
|
||||||
@@ -344,7 +352,10 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
let _ = handle.emit("signin", outcome);
|
let _ = handle.emit("signin", outcome);
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
if let Some(token) = agent_token {
|
if let Some(token) = agent_token {
|
||||||
if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
if link_works(&app) {
|
||||||
|
// Linked meanwhile (a service started by hand, say): this token is nobody's.
|
||||||
|
server::sign_out(&d.base, &token);
|
||||||
|
} else if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
||||||
let _ = handle.emit("linking", Linking::Failed { message });
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
}
|
}
|
||||||
@@ -423,6 +434,25 @@ async fn api_text(handle: AppHandle, path: String) -> Result<String, ApiError> {
|
|||||||
result
|
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.
|
/// A frame of a reply being written, as the page hears it.
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct Frame {
|
struct Frame {
|
||||||
@@ -481,6 +511,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 ----------------------------------------------------------------------
|
// --- This computer's files ----------------------------------------------------------------------
|
||||||
|
|
||||||
/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder
|
/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder
|
||||||
@@ -591,6 +788,24 @@ fn link_computer(handle: AppHandle) -> Result<(), String> {
|
|||||||
start_link(&handle)
|
start_link(&handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop waiting for the approval of a link in progress.
|
||||||
|
#[tauri::command]
|
||||||
|
fn link_cancel(handle: AppHandle) {
|
||||||
|
if let Some(c) = handle.state::<App>().link_cancel.lock().unwrap().take() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the agent's link works right now: the CLI says it is linked -- the app's own service,
|
||||||
|
/// or one started by hand. A login on file is not a link that works.
|
||||||
|
fn link_works(app: &App) -> bool {
|
||||||
|
match app.agent.state() {
|
||||||
|
AgentState::Running => true,
|
||||||
|
AgentState::Elsewhere(e) => e.linked,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
|
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
let s = app.update(|s| s.agent = on)?;
|
let s = app.update(|s| s.agent = on)?;
|
||||||
@@ -697,31 +912,49 @@ enum Linking {
|
|||||||
Failed { message: String },
|
Failed { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's
|
/// Link this computer (again): a device login for the agent's scopes alone, the approval opened
|
||||||
/// browser (where the person is signed in to the server), then the agent started. The way to
|
/// in the system's browser (where the person is signed in to the server), the token handed to
|
||||||
/// link again, and the only way on a server that gives no agent token with the app's sign-in.
|
/// the CLI (`lembas login --token-stdin`, which replaces a login it had for this server), then the
|
||||||
|
/// agent started. Not `lembas login <server>`: for a server the CLI is logged in to already, that
|
||||||
|
/// only refreshes the login it has, so a token the server refused would stay refused.
|
||||||
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
let Some(server) = app.settings().server else {
|
let Some(d) = app.server.lock().unwrap().clone() else {
|
||||||
return Err("Choose a server first.".into());
|
return Err("Choose a LLeMbas 2.0 server first.".into());
|
||||||
};
|
};
|
||||||
claim_linking(&app)?;
|
claim_linking(&app)?;
|
||||||
let child = match Command::new(cli::binary())
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
.arg("login")
|
*app.link_cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
.arg(&server)
|
let handle = handle.clone();
|
||||||
.arg("--json")
|
std::thread::spawn(move || {
|
||||||
.stdin(Stdio::null())
|
let host = cli::hostname().unwrap_or_default();
|
||||||
.stdout(Stdio::piped())
|
let result = server::start_link(&d, &host).and_then(|code| {
|
||||||
.stderr(Stdio::piped())
|
let _ = handle
|
||||||
.spawn()
|
.opener()
|
||||||
{
|
.open_url(&code.verification_uri_complete, None::<&str>);
|
||||||
Ok(c) => c,
|
let _ = handle.emit(
|
||||||
Err(e) => {
|
"linking",
|
||||||
*app.linking.lock().unwrap() = false;
|
Linking::Code {
|
||||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
user_code: code.user_code.clone(),
|
||||||
|
uri: code.verification_uri_complete.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
server::await_sign_in(&d, &code, &cancel)
|
||||||
|
});
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
app.link_cancel.lock().unwrap().take();
|
||||||
|
let given = match result {
|
||||||
|
Ok(granted) => give_to_cli(&handle, &d.base, granted.account.token),
|
||||||
|
Err(message) => {
|
||||||
|
*app.linking.lock().unwrap() = false;
|
||||||
|
Err(message)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(message) = given {
|
||||||
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
}
|
}
|
||||||
};
|
announce(&handle);
|
||||||
follow_login(handle, child, None);
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,6 +969,12 @@ fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), Stri
|
|||||||
server::sign_out(base, &token);
|
server::sign_out(base, &token);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
give_to_cli(handle, base, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hand_to_cli` once linking is claimed: the CLI takes the token, or it is given up.
|
||||||
|
fn give_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
let spawned = Command::new(cli::binary())
|
let spawned = Command::new(cli::binary())
|
||||||
.args(["login", "--token-stdin", "--server", base, "--json"])
|
.args(["login", "--token-stdin", "--server", base, "--json"])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@@ -1173,6 +1412,69 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long to wait before following the event stream again: after it closed or broke off, and
|
||||||
|
/// (longer) on a server that has none.
|
||||||
|
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const EVENTS_NONE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Follow the server's event stream for the signed-in person (`GET /events`): each `schedules`
|
||||||
|
/// frame goes to the page as it is (the Scheduled page redraws from it), and a scheduled task's
|
||||||
|
/// newly finished run is told by its notify decision (`watch::decide`). Said even with the window
|
||||||
|
/// in front: it ran on its own. Signing out or another server ends the stream (`Watch::reset`).
|
||||||
|
fn watch_events(handle: AppHandle) {
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let Some((app_url, token)) = app.door() else {
|
||||||
|
std::thread::sleep(EVENTS_AGAIN);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let stop = app.watch.stop_flag();
|
||||||
|
let result = server::stream(&app_url, &token, "/events", &stop, |event, data| {
|
||||||
|
if event != "schedules" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(list) = serde_json::from_str::<Value>(data) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let ran = app.watch.look_schedules(&list);
|
||||||
|
let _ = handle.emit("schedules", &list);
|
||||||
|
if ran.is_empty() || !app.settings().notify {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let words = app.watch.words.lock().unwrap().clone();
|
||||||
|
for r in ran {
|
||||||
|
let title = if r.title.is_empty() {
|
||||||
|
"LLeMbas".to_string()
|
||||||
|
} else {
|
||||||
|
r.title
|
||||||
|
};
|
||||||
|
let body = match r.told {
|
||||||
|
watch::Told::Result => &words.scheduled,
|
||||||
|
watch::Told::Failed => &words.scheduled_failed,
|
||||||
|
watch::Told::Pause => &words.scheduled_pause,
|
||||||
|
};
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Err(e) if e.status == 401 => {
|
||||||
|
token_refused(&handle);
|
||||||
|
std::thread::sleep(EVENTS_AGAIN);
|
||||||
|
}
|
||||||
|
// A server without the stream: look again now and then (it may be updated).
|
||||||
|
Err(e) if e.status == 404 || e.status == 405 => std::thread::sleep(EVENTS_NONE),
|
||||||
|
_ => std::thread::sleep(EVENTS_AGAIN),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the app writes what it could not show: a start that fails before the window is up
|
/// Where the app writes what it could not show: a start that fails before the window is up
|
||||||
/// leaves its reason here, not only on a terminal nobody sees.
|
/// leaves its reason here, not only on a terminal nobody sees.
|
||||||
mod log {
|
mod log {
|
||||||
@@ -1254,12 +1556,17 @@ pub fn run() {
|
|||||||
sign_out,
|
sign_out,
|
||||||
api,
|
api,
|
||||||
api_text,
|
api_text,
|
||||||
|
model_image,
|
||||||
|
terminal_word,
|
||||||
stream_open,
|
stream_open,
|
||||||
stream_close,
|
stream_close,
|
||||||
local_list,
|
local_list,
|
||||||
local_read,
|
local_read,
|
||||||
sealed_open,
|
sealed_open,
|
||||||
sealed_close,
|
sealed_close,
|
||||||
|
attach_pick,
|
||||||
|
attach_dropped,
|
||||||
|
attach_bytes,
|
||||||
set_theme,
|
set_theme,
|
||||||
set_notify,
|
set_notify,
|
||||||
set_open_chat,
|
set_open_chat,
|
||||||
@@ -1267,6 +1574,7 @@ pub fn run() {
|
|||||||
open_external,
|
open_external,
|
||||||
set_agent,
|
set_agent,
|
||||||
link_computer,
|
link_computer,
|
||||||
|
link_cancel,
|
||||||
titlebar_menu,
|
titlebar_menu,
|
||||||
update_app,
|
update_app,
|
||||||
remote_refresh,
|
remote_refresh,
|
||||||
@@ -1303,6 +1611,7 @@ pub fn run() {
|
|||||||
agent,
|
agent,
|
||||||
tray: Mutex::new(None),
|
tray: Mutex::new(None),
|
||||||
linking: Mutex::new(false),
|
linking: Mutex::new(false),
|
||||||
|
link_cancel: Mutex::new(None),
|
||||||
update: Mutex::new(None),
|
update: Mutex::new(None),
|
||||||
remote: Mutex::new(None),
|
remote: Mutex::new(None),
|
||||||
theme: Mutex::new(theme),
|
theme: Mutex::new(theme),
|
||||||
@@ -1314,6 +1623,7 @@ pub fn run() {
|
|||||||
next_stream: AtomicU64::new(0),
|
next_stream: AtomicU64::new(0),
|
||||||
sealed: sealed::Pages::default(),
|
sealed: sealed::Pages::default(),
|
||||||
watch: watch::Watch::default(),
|
watch: watch::Watch::default(),
|
||||||
|
dropped: Mutex::new(Vec::new()),
|
||||||
});
|
});
|
||||||
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
|
||||||
// starts: the title bar's menu has every item the tray has.
|
// starts: the title bar's menu has every item the tray has.
|
||||||
@@ -1324,6 +1634,7 @@ pub fn run() {
|
|||||||
build_main(&handle)?;
|
build_main(&handle)?;
|
||||||
watch_updates(handle.clone());
|
watch_updates(handle.clone());
|
||||||
watch_chats(handle.clone());
|
watch_chats(handle.clone());
|
||||||
|
watch_events(handle.clone());
|
||||||
{
|
{
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -1345,6 +1656,30 @@ pub fn run() {
|
|||||||
WindowEvent::Resized(_) if window.label() == MAIN => {
|
WindowEvent::Resized(_) if window.label() == MAIN => {
|
||||||
let _ = window.emit("maximized", window.is_maximized().unwrap_or(false));
|
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!());
|
.build(tauri::generate_context!());
|
||||||
@@ -1407,6 +1742,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]
|
#[test]
|
||||||
fn the_log_lives_in_the_apps_own_directory() {
|
fn the_log_lives_in_the_apps_own_directory() {
|
||||||
let f = log::file().unwrap();
|
let f = log::file().unwrap();
|
||||||
|
|||||||
+262
-2
@@ -271,6 +271,18 @@ pub struct Code {
|
|||||||
|
|
||||||
/// What a device login asks the server for, as the form it posts.
|
/// What a device login asks the server for, as the form it posts.
|
||||||
fn code_request(host: &str, with_agent: bool) -> Value {
|
fn code_request(host: &str, with_agent: bool) -> Value {
|
||||||
|
code_request_for(host, Ask::App { with_agent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which login: the app's own (and the agent's beside it, in the same approval), or the agent's
|
||||||
|
/// alone -- linking this computer again, which needs no second token for the app.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Ask {
|
||||||
|
App { with_agent: bool },
|
||||||
|
Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_request_for(host: &str, ask: Ask) -> Value {
|
||||||
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
||||||
let device_name = if host.is_empty() {
|
let device_name = if host.is_empty() {
|
||||||
"LLeMbas Desktop".to_string()
|
"LLeMbas Desktop".to_string()
|
||||||
@@ -283,7 +295,13 @@ fn code_request(host: &str, with_agent: bool) -> Value {
|
|||||||
"device_platform": platform,
|
"device_platform": platform,
|
||||||
"scope": APP_SCOPE,
|
"scope": APP_SCOPE,
|
||||||
});
|
});
|
||||||
if with_agent {
|
if ask == Ask::Agent {
|
||||||
|
// The agent's token alone: the device an agent chat runs on, named by its host.
|
||||||
|
body["scope"] = Value::String(AGENT_SCOPE.to_string());
|
||||||
|
body["device_host"] = Value::String(host.to_string());
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
if matches!(ask, Ask::App { with_agent: true }) {
|
||||||
// The agent's token is the device an agent chat runs on: it carries this computer's
|
// The agent's token is the device an agent chat runs on: it carries this computer's
|
||||||
// host, as `lembas login` sends it, so the app knows which device is this one. The
|
// host, as `lembas login` sends it, so the app knows which device is this one. The
|
||||||
// app's own token alone names no host: it is not a device.
|
// app's own token alone names no host: it is not a device.
|
||||||
@@ -296,10 +314,22 @@ fn code_request(host: &str, with_agent: bool) -> Value {
|
|||||||
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
|
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
|
||||||
/// discovery offered it).
|
/// discovery offered it).
|
||||||
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
|
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
|
||||||
|
start_code(d, code_request(host, with_agent && d.one_approval))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a device login for the agent's scopes alone: linking this computer (again), with the
|
||||||
|
/// token handed to the CLI as the app's own approval hands it. A login the CLI made itself
|
||||||
|
/// (`lembas login <server>`) would not do here: for a server it is logged in to already, that
|
||||||
|
/// only refreshes the login it has -- a token the server refuses stays refused.
|
||||||
|
pub fn start_link(d: &Discovery, host: &str) -> Result<Code, String> {
|
||||||
|
start_code(d, code_request_for(host, Ask::Agent))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_code(d: &Discovery, form: Value) -> Result<Code, String> {
|
||||||
let res = agent(Duration::from_secs(15))
|
let res = agent(Duration::from_secs(15))
|
||||||
.post(&d.device_code)
|
.post(&d.device_code)
|
||||||
.set("accept", "application/json")
|
.set("accept", "application/json")
|
||||||
.send_json(code_request(host, with_agent && d.one_approval))
|
.send_json(form)
|
||||||
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
||||||
let v: Value = res
|
let v: Value = res
|
||||||
.into_json()
|
.into_json()
|
||||||
@@ -507,6 +537,176 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
|
|||||||
Ok(text)
|
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 most an attached picture shown as a thumbnail may be: a screenshot fits; a larger one
|
||||||
|
/// shows as a file.
|
||||||
|
pub const ATTACHMENT_IMAGE_LIMIT: u64 = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 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 picture's path on the app's door, or None for anything else: a model's picture as
|
||||||
|
/// `/api/v2/models` names it (`/models/images/<file>`), or a message's attached file
|
||||||
|
/// (`/attachments/<id>`, shown as a thumbnail when the server serves it as a picture). One plain
|
||||||
|
/// name after the prefix, no more.
|
||||||
|
pub fn image_path(path: &str) -> Option<String> {
|
||||||
|
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
||||||
|
let plain = |name: &str| {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||||
|
&& !name.starts_with('.')
|
||||||
|
};
|
||||||
|
if let Some(name) = rest.strip_prefix("/models/images/") {
|
||||||
|
return plain(name).then(|| format!("/models/images/{name}"));
|
||||||
|
}
|
||||||
|
let id = rest.strip_prefix("/attachments/")?;
|
||||||
|
(plain(id) && !id.contains('.')).then(|| format!("/attachments/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 picture's path: {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 limit = if path.starts_with("/attachments/") {
|
||||||
|
ATTACHMENT_IMAGE_LIMIT
|
||||||
|
} else {
|
||||||
|
IMAGE_LIMIT
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
res.into_reader()
|
||||||
|
.take(limit)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||||
|
if bytes.len() as u64 >= limit {
|
||||||
|
return Err(ApiError::new(413, "The 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.
|
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Sse {
|
pub struct Sse {
|
||||||
@@ -638,6 +838,14 @@ mod tests {
|
|||||||
assert!(app_only.get("device_host").is_none());
|
assert!(app_only.get("device_host").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linking_again_asks_for_the_agents_token_alone_and_names_the_host() {
|
||||||
|
let link = code_request_for("box", Ask::Agent);
|
||||||
|
assert_eq!(link["scope"], "models memory link");
|
||||||
|
assert_eq!(link["device_host"], "box");
|
||||||
|
assert!(link.get("agent_scope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
|
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
@@ -709,6 +917,58 @@ 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")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
image_path("/api/v2/attachments/att_01x").as_deref(),
|
||||||
|
Some("/attachments/att_01x")
|
||||||
|
);
|
||||||
|
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",
|
||||||
|
"/api/v2/attachments/",
|
||||||
|
"/api/v2/attachments/a/b",
|
||||||
|
"/api/v2/attachments/..",
|
||||||
|
"/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]
|
#[test]
|
||||||
fn server_sent_events_come_out_whole() {
|
fn server_sent_events_come_out_whole() {
|
||||||
let mut sse = Sse::default();
|
let mut sse = Sse::default();
|
||||||
|
|||||||
+234
-2
@@ -1,14 +1,26 @@
|
|||||||
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
|
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
|
||||||
//! looking at it. The app's own, so the server's Web Push can stay off.
|
//! looking at it; and a scheduled task has a result the server decided to tell of. The app's own,
|
||||||
|
//! so the server's Web Push can stay off.
|
||||||
//!
|
//!
|
||||||
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
|
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
|
||||||
//! being answered; a chat that was and no longer is has finished. The notification is shown
|
//! being answered; a chat that was and no longer is has finished. The notification is shown
|
||||||
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
|
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
|
||||||
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
|
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
|
||||||
//! put on screen outside the app.
|
//! put on screen outside the app.
|
||||||
|
//!
|
||||||
|
//! Scheduled tasks: the app follows the server's own event stream (`GET /api/v2/events`), whose
|
||||||
|
//! `schedules` frame carries the whole list (`{"version", "schedules": [schedule]}`) whenever it
|
||||||
|
//! changes -- a run starting, waiting, finishing. A schedule's last run that has newly finished is
|
||||||
|
//! told by the server's notify decision for it: `notified` when the server already told a device,
|
||||||
|
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
||||||
|
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
||||||
|
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
||||||
|
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
|
||||||
|
//! the stream answers 404, and nothing is said.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -23,6 +35,27 @@ pub struct Words {
|
|||||||
pub reply: String,
|
pub reply: String,
|
||||||
/// An agent chat has finished its work.
|
/// An agent chat has finished its work.
|
||||||
pub agent: String,
|
pub agent: String,
|
||||||
|
/// A scheduled task has a result.
|
||||||
|
#[serde(default = "scheduled_word")]
|
||||||
|
pub scheduled: String,
|
||||||
|
/// A scheduled task's run failed.
|
||||||
|
#[serde(default = "scheduled_failed_word")]
|
||||||
|
pub scheduled_failed: String,
|
||||||
|
/// A scheduled task failed three times in a row: pause it?
|
||||||
|
#[serde(default = "scheduled_pause_word")]
|
||||||
|
pub scheduled_pause: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_word() -> String {
|
||||||
|
"A scheduled task has a result.".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_failed_word() -> String {
|
||||||
|
"A scheduled task failed.".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_pause_word() -> String {
|
||||||
|
"A scheduled task failed 3 times in a row. Pause it?".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Words {
|
impl Default for Words {
|
||||||
@@ -30,6 +63,9 @@ impl Default for Words {
|
|||||||
Words {
|
Words {
|
||||||
reply: "The reply is ready.".into(),
|
reply: "The reply is ready.".into(),
|
||||||
agent: "The agent has finished.".into(),
|
agent: "The agent has finished.".into(),
|
||||||
|
scheduled: scheduled_word(),
|
||||||
|
scheduled_failed: scheduled_failed_word(),
|
||||||
|
scheduled_pause: scheduled_pause_word(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +85,56 @@ pub struct Watch {
|
|||||||
pub words: Mutex<Words>,
|
pub words: Mutex<Words>,
|
||||||
/// Which chats were being answered at the last look (None before the first look).
|
/// Which chats were being answered at the last look (None before the first look).
|
||||||
running: Mutex<Option<HashMap<String, bool>>>,
|
running: Mutex<Option<HashMap<String, bool>>>,
|
||||||
|
/// Each schedule's last run at the last look, and whether it had finished (None before the
|
||||||
|
/// first look).
|
||||||
|
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
||||||
|
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
||||||
|
stop: Mutex<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a scheduled task's run is told.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Told {
|
||||||
|
/// It has a result.
|
||||||
|
Result,
|
||||||
|
/// It failed.
|
||||||
|
Failed,
|
||||||
|
/// It failed for the third time in a row: pause it?
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scheduled task's newly finished run that is worth telling of.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Ran {
|
||||||
|
pub title: String,
|
||||||
|
pub told: Told,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
||||||
|
const FINISHED: [&str; 5] = ["ok", "silent", "failed", "skipped", "missed"];
|
||||||
|
/// The failure in a row that asks whether to pause (`notify.PAUSE_AFTER`).
|
||||||
|
const PAUSE_AFTER: i64 = 3;
|
||||||
|
|
||||||
|
/// Whether a finished run is told, and how: the server's word when it already told (`notified`),
|
||||||
|
/// else its notify policy for the schedule as the server applies it.
|
||||||
|
pub fn decide(policy: &str, status: &str, notified: bool, streak: i64) -> Option<Told> {
|
||||||
|
let failed = matches!(status, "failed" | "skipped");
|
||||||
|
let kind = if failed && streak == PAUSE_AFTER {
|
||||||
|
Told::Pause
|
||||||
|
} else if failed {
|
||||||
|
Told::Failed
|
||||||
|
} else {
|
||||||
|
Told::Result
|
||||||
|
};
|
||||||
|
if notified {
|
||||||
|
return Some(kind);
|
||||||
|
}
|
||||||
|
match (policy, status) {
|
||||||
|
("never", _) => None,
|
||||||
|
(_, "ok") | ("always", "silent") => Some(Told::Result),
|
||||||
|
_ if failed && (streak <= 1 || streak == PAUSE_AFTER) => Some(kind),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Watch {
|
impl Watch {
|
||||||
@@ -89,9 +175,71 @@ impl Watch {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note the schedules as the event stream gave them (`{"schedules": [...]}`); the runs that
|
||||||
|
/// finished since the last look and are told. The first look only notes, as with the chats; a
|
||||||
|
/// run is told once, however often the list comes again.
|
||||||
|
pub fn look_schedules(&self, list: &Value) -> Vec<Ran> {
|
||||||
|
let rows = list
|
||||||
|
.get("schedules")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut seen = self.runs.lock().unwrap();
|
||||||
|
let first = seen.is_none();
|
||||||
|
let mut now = HashMap::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in &rows {
|
||||||
|
let Some(id) = row.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(run) = row.get("last_run").filter(|r| r.is_object()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(run_id) = run.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let status = run.get("status").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let finished = FINISHED.contains(&status);
|
||||||
|
let before = seen.as_ref().and_then(|s| s.get(id));
|
||||||
|
let known = before.is_some_and(|(r, done)| r == run_id && *done);
|
||||||
|
if !first && finished && !known {
|
||||||
|
let told = decide(
|
||||||
|
row.get("notify").and_then(Value::as_str).unwrap_or(""),
|
||||||
|
status,
|
||||||
|
run.get("notified").and_then(Value::as_bool) == Some(true),
|
||||||
|
row.get("failure_streak")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
if let Some(told) = told {
|
||||||
|
out.push(Ran {
|
||||||
|
title: row
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
told,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now.insert(id.to_string(), (run_id.to_string(), finished));
|
||||||
|
}
|
||||||
|
*seen = Some(now);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flag that ends the event stream followed now.
|
||||||
|
pub fn stop_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
self.stop.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Start again from nothing: another server, or signed out.
|
/// Start again from nothing: another server, or signed out.
|
||||||
pub fn reset(&self) {
|
pub fn reset(&self) {
|
||||||
*self.running.lock().unwrap() = None;
|
*self.running.lock().unwrap() = None;
|
||||||
|
*self.runs.lock().unwrap() = None;
|
||||||
|
let mut stop = self.stop.lock().unwrap();
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
*stop = Arc::new(AtomicBool::new(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +248,90 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A schedule in a test: its id, notify policy, failure streak, and last run (id, status,
|
||||||
|
/// whether the server told of it).
|
||||||
|
type Row<'a> = (&'a str, &'a str, i64, Option<(&'a str, &'a str, bool)>);
|
||||||
|
|
||||||
|
fn schedules(rows: &[Row]) -> Value {
|
||||||
|
json!({"version": "v", "schedules": rows.iter().map(|(id, notify, streak, run)| json!({
|
||||||
|
"id": id, "title": format!("Task {id}"), "notify": notify, "failure_streak": streak,
|
||||||
|
"last_run": run.map(|(r, status, told)| json!({"id": r, "status": status, "notified": told})),
|
||||||
|
})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ran(id: &str, told: Told) -> Ran {
|
||||||
|
Ran {
|
||||||
|
title: format!("Task {id}"),
|
||||||
|
told,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
||||||
|
let w = Watch::default();
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[
|
||||||
|
("a", "matters", 0, Some(("r1", "ok", false))),
|
||||||
|
("b", "always", 0, None)
|
||||||
|
]))
|
||||||
|
.is_empty());
|
||||||
|
// Running is not news; finishing is, even under the same run id.
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r2", "running", false))
|
||||||
|
)]))
|
||||||
|
.is_empty());
|
||||||
|
let out = w.look_schedules(&schedules(&[
|
||||||
|
("a", "matters", 0, Some(("r2", "ok", false))),
|
||||||
|
("b", "always", 0, Some(("r3", "silent", false))),
|
||||||
|
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||||
|
("d", "never", 0, Some(("r5", "ok", false))),
|
||||||
|
]));
|
||||||
|
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
|
||||||
|
// The same list again (the server told a device meanwhile): not said twice.
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_said_the_first_time_and_the_third_asks_to_pause() {
|
||||||
|
assert_eq!(decide("matters", "failed", false, 1), Some(Told::Failed));
|
||||||
|
assert_eq!(decide("matters", "failed", false, 2), None);
|
||||||
|
assert_eq!(decide("always", "skipped", false, 3), Some(Told::Pause));
|
||||||
|
assert_eq!(decide("never", "failed", false, 1), None);
|
||||||
|
assert_eq!(decide("matters", "missed", false, 0), None);
|
||||||
|
// The server's own word wins: it told a device.
|
||||||
|
assert_eq!(decide("matters", "failed", true, 2), Some(Told::Failed));
|
||||||
|
assert_eq!(decide("matters", "silent", true, 0), Some(Told::Result));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reset_ends_the_stream_and_starts_quiet() {
|
||||||
|
let w = Watch::default();
|
||||||
|
let old = w.stop_flag();
|
||||||
|
w.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r1", "running", false)),
|
||||||
|
)]));
|
||||||
|
w.reset();
|
||||||
|
assert!(old.load(Ordering::SeqCst));
|
||||||
|
assert!(!w.stop_flag().load(Ordering::SeqCst));
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r1", "ok", false))
|
||||||
|
)]))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
fn list(rows: &[(&str, bool, &str)]) -> Value {
|
fn list(rows: &[(&str, bool, &str)]) -> Value {
|
||||||
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
|
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
|
||||||
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
|
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.4",
|
"tag": "v2.0.0-beta.7",
|
||||||
"commit": "7969067f72605db0df7dd877b58d847156373f18",
|
"commit": "e3bd54f4d210cff6adb4586199caed4f41987f5e",
|
||||||
"sha256": "9fba389ddb4168034d0ea75e65707f7b98cd53ef2aff9769fb9db7ff01aa9eda"
|
"sha256": "c08adb703678408b469520ee41a0918424cb962ecc89d80f4cbdac5e5c437b92"
|
||||||
}
|
}
|
||||||
+1230
-273
File diff suppressed because it is too large.
Load diff
+1712
-88
File diff suppressed because it is too large.
Load diff
+87
-48
@@ -13,13 +13,13 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="bar" data-tauri-drag-region>
|
<header class="bar" data-tauri-drag-region>
|
||||||
<button class="bar__btn bar__side" id="side-toggle" type="button" aria-label="Chats" title="Chats" aria-expanded="false" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
|
<button class="bar__btn" id="side-toggle" type="button" aria-label="Sidebar" title="Sidebar" aria-expanded="true" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
|
||||||
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
||||||
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
||||||
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
||||||
<button class="bar__update" id="update" type="button" hidden>update</button>
|
<button class="bar__update" id="update" type="button" hidden>update</button>
|
||||||
<button class="bar__agent" id="agent" type="button" title="Agent">
|
<button class="bar__agent" id="agent" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||||
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
|
<span class="dot" aria-hidden="true"></span><span class="bar__agent-text" id="agent-text">agent off</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
|
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
|
||||||
<span class="bar__sep" aria-hidden="true"></span>
|
<span class="bar__sep" aria-hidden="true"></span>
|
||||||
@@ -41,70 +41,109 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shell" id="shell" hidden data-side="false" data-viewer="false">
|
<div class="shell" id="shell" hidden data-side="true" data-viewer="false" data-view="chat">
|
||||||
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label data-local="true" data-server="false">
|
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label>
|
||||||
<div class="switch" role="tablist">
|
<div class="side__top">
|
||||||
<button type="button" role="tab" data-kind="agent" aria-selected="true" data-i18n>Agent</button>
|
<button type="button" class="side__new" id="new-chat-btn"><span class="side__new-label" data-i18n>New chat</span><kbd class="kbd">Ctrl N</kbd></button>
|
||||||
<button type="button" role="tab" data-kind="chat" aria-selected="false" data-i18n>Chats</button>
|
<label class="search">
|
||||||
|
<span class="search__icon" id="side-search-icon"></span>
|
||||||
|
<input type="search" id="side-search" placeholder="Search chats" aria-label="Search chats" autocomplete="off" spellcheck="false" data-i18n-placeholder data-i18n-aria-label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="side__nav">
|
||||||
|
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
||||||
|
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
||||||
|
</div>
|
||||||
|
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
||||||
|
<div class="side__foot">
|
||||||
|
<button type="button" class="navrow navrow--account" id="settings-btn" data-view="settings"></button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn--primary side__new" id="new-chat-btn"><span data-i18n>New agent chat</span></button>
|
|
||||||
<ul class="list" id="chat-list"></ul>
|
|
||||||
|
|
||||||
<section class="pane" id="pane-local" data-open="true" aria-labelledby="pane-local-h">
|
|
||||||
<div class="pane__head">
|
|
||||||
<button type="button" class="pane__toggle" aria-expanded="true" id="pane-local-h"><span data-i18n>This computer</span></button>
|
|
||||||
<button type="button" class="pane__btn" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
|
||||||
</div>
|
|
||||||
<div class="pane__body">
|
|
||||||
<div class="pane__where" id="local-where"></div>
|
|
||||||
<div class="pane__tree" id="local-tree"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="pane" id="pane-server" data-open="false" aria-labelledby="pane-server-h">
|
|
||||||
<div class="pane__head">
|
|
||||||
<button type="button" class="pane__toggle" aria-expanded="false" id="pane-server-h"><span data-i18n>Server files</span></button>
|
|
||||||
<button type="button" class="pane__btn" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
|
||||||
</div>
|
|
||||||
<div class="pane__body">
|
|
||||||
<div class="pane__tree" id="server-tree"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button type="button" class="side__settings" id="settings-btn"></button>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
||||||
|
|
||||||
<main class="main" id="main" aria-labelledby="chat-title">
|
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head__title" id="chat-title"></h1>
|
<h1 class="head__title" id="chat-title"></h1>
|
||||||
<div class="head__place" id="chat-place"></div>
|
<div class="head__place" id="chat-place"></div>
|
||||||
<div class="head__tools" id="chat-tools"></div>
|
<div class="head__tools" id="chat-tools"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="thread" id="thread">
|
<div class="thread" id="thread">
|
||||||
<p class="thread__note is-error" id="thread-note" hidden></p>
|
<div class="hello" id="new-chat" hidden></div>
|
||||||
<div class="new" id="new-chat" hidden></div>
|
|
||||||
<div class="messages" id="messages"></div>
|
<div class="messages" id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cards" id="cards" hidden></div>
|
<div class="dock" id="dock">
|
||||||
<form class="composer" id="composer" hidden data-running="false">
|
<p class="thread__note is-error" id="thread-note" hidden></p>
|
||||||
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
<div class="cards" id="cards" hidden></div>
|
||||||
<div class="composer__actions">
|
<div class="notice" id="link-notice" hidden></div>
|
||||||
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
|
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
||||||
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
|
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
||||||
</div>
|
<ul class="composer__files" id="composer-files" hidden></ul>
|
||||||
</form>
|
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
||||||
|
<div class="composer__bar">
|
||||||
|
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<div class="composer__chips" id="composer-chips"></div>
|
||||||
|
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</div>
|
||||||
|
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section class="settings" id="settings" hidden aria-labelledby="settings-title">
|
<section class="page" id="page-files" hidden aria-labelledby="files-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="files-title" data-i18n>Files</h1>
|
||||||
|
<div class="head__place"></div>
|
||||||
|
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body files">
|
||||||
|
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
|
||||||
|
<div class="files__head">
|
||||||
|
<div class="files__where" id="local-where"></div>
|
||||||
|
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</div>
|
||||||
|
<div class="files__tree" id="local-tree"></div>
|
||||||
|
</section>
|
||||||
|
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||||
|
<div class="files__head">
|
||||||
|
<div class="files__where muted" id="server-where"></div>
|
||||||
|
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</div>
|
||||||
|
<div class="files__tree" id="server-tree"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-scheduled" hidden aria-labelledby="scheduled-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="scheduled-title" data-i18n>Scheduled</h1>
|
||||||
|
<div class="head__place" id="scheduled-place"></div>
|
||||||
|
<div class="head__tools" id="scheduled-tools"></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body" id="scheduled-body"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
||||||
<div class="head__place"></div>
|
<div class="head__place"></div>
|
||||||
<div class="head__tools"><button type="button" class="pane__btn" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
|
<div class="head__tools"><button type="button" class="tool" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="settings__body" id="settings-body"></div>
|
<div class="settings__layout">
|
||||||
|
<nav class="settings__nav" id="settings-nav" aria-label="Settings" data-i18n-aria-label></nav>
|
||||||
|
<div class="settings__body" id="settings-body"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||||
|
<header class="viewer__head activity__head">
|
||||||
|
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<h2 class="viewer__name" id="activity-title"></h2>
|
||||||
|
<button type="button" class="tool" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</header>
|
||||||
|
<div class="activity__body" id="activity-body"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
|
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
|
||||||
<header class="viewer__head">
|
<header class="viewer__head">
|
||||||
<div class="viewer__id">
|
<div class="viewer__id">
|
||||||
@@ -112,7 +151,7 @@
|
|||||||
<p class="viewer__where" id="viewer-where"></p>
|
<p class="viewer__where" id="viewer-where"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer__modes" id="viewer-modes"></div>
|
<div class="viewer__modes" id="viewer-modes"></div>
|
||||||
<button type="button" class="pane__btn" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="viewer__body" id="viewer-body"></div>
|
<div class="viewer__body" id="viewer-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,841 @@
|
|||||||
|
// The chat's activity panel, beside it, opened from the header's button at any time: where an
|
||||||
|
// agent chat works; what is in its context, by category, against the model's window, how far it
|
||||||
|
// is to the automatic compaction and a button to compact now; the files the chat and its
|
||||||
|
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
|
||||||
|
// that leads them, background commands -- with how long it has run and a stop square, unfolding
|
||||||
|
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
|
||||||
|
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
|
||||||
|
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
|
||||||
|
// from /api/v2:
|
||||||
|
//
|
||||||
|
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
||||||
|
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||||
|
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
|
||||||
|
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
||||||
|
// POST /chats/{chat}/compact summarise the earlier turns now
|
||||||
|
//
|
||||||
|
// 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 onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
|
||||||
|
let onCompacted = () => {} // (chat id) -> the conversation is read again
|
||||||
|
let chat = null // the open chat: { id, kind, … }
|
||||||
|
let stream = null // the events stream's handle
|
||||||
|
let retry = 0 // the reconnect timer
|
||||||
|
let failures = 0
|
||||||
|
let summary = null
|
||||||
|
let version = ""
|
||||||
|
let data = null // the last `activity`
|
||||||
|
let room = [] // the room so far: each read asks only for what came after `cursor`
|
||||||
|
let cursor = 0
|
||||||
|
let readAt = 0 // when `data` was read: the elapsed times count on from there
|
||||||
|
let contextAt = 0
|
||||||
|
let reading = null // the read in flight
|
||||||
|
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
||||||
|
let view = null // null: the panel; { kind, id, name }: one agent's conversation
|
||||||
|
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
||||||
|
let ticker = 0
|
||||||
|
|
||||||
|
// --- Words ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
|
||||||
|
export function duration(seconds) {
|
||||||
|
const total = Math.max(0, Math.round(Number(seconds) || 0))
|
||||||
|
if (total < 60) return `${total}s`
|
||||||
|
const minutes = Math.floor(total / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
|
||||||
|
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
|
||||||
|
export function compact(value) {
|
||||||
|
const n = Math.trunc(Number(value) || 0)
|
||||||
|
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
|
||||||
|
if (n < 1000) return String(n)
|
||||||
|
if (n < 100000) return `${trim(n / 1000, 1)}k`
|
||||||
|
if (n < 1000000) return `${Math.round(n / 1000)}k`
|
||||||
|
return `${trim(n / 1000000, 2)}M`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
|
||||||
|
* team's member, a command -- counts the same, as the panel lists them together.
|
||||||
|
*/
|
||||||
|
export function summaryText(counts) {
|
||||||
|
if (!counts) return ""
|
||||||
|
const parts = []
|
||||||
|
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
|
||||||
|
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
||||||
|
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||||
|
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWord(state) {
|
||||||
|
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextWord(key) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
messages: t("Messages"),
|
||||||
|
system: t("System prompt"),
|
||||||
|
memory: t("Memory and personal"),
|
||||||
|
instructions: t("Instruction files"),
|
||||||
|
tools: t("Tools"),
|
||||||
|
mcp_tools: t("MCP tools"),
|
||||||
|
since: t("Since the last reply"),
|
||||||
|
buffer: t("Compaction buffer"),
|
||||||
|
free: t("Free space"),
|
||||||
|
}[key] || String(key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
|
||||||
|
function percentSentence(text, fields) {
|
||||||
|
return t(text, fields).replace(/%%/g, "%")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
|
||||||
|
export function planList(plan) {
|
||||||
|
return h(
|
||||||
|
"ol",
|
||||||
|
{ class: "plan" },
|
||||||
|
(plan.phases || []).map((p) =>
|
||||||
|
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Following a chat -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
|
export function panelOpen() {
|
||||||
|
return !$("activity").hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
|
||||||
|
export function watchActivity(next) {
|
||||||
|
const id = next?.id || null
|
||||||
|
if (id && chat?.id === id) {
|
||||||
|
chat = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
chat = id ? next : null
|
||||||
|
summary = null
|
||||||
|
version = ""
|
||||||
|
data = null
|
||||||
|
room = []
|
||||||
|
cursor = 0
|
||||||
|
readAt = contextAt = 0
|
||||||
|
view = null
|
||||||
|
compactError = ""
|
||||||
|
openRows.clear()
|
||||||
|
renderStrip()
|
||||||
|
markOpen()
|
||||||
|
if (!chat) {
|
||||||
|
if (panelOpen()) showPanel(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listenTo(chat.id)
|
||||||
|
if (panelOpen()) {
|
||||||
|
render()
|
||||||
|
read(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 line above the message box, and the header's button ---------------------------------------
|
||||||
|
|
||||||
|
/** Both ways into the panel say whether it is open. */
|
||||||
|
function markOpen() {
|
||||||
|
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrip() {
|
||||||
|
const strip = $("activity-strip")
|
||||||
|
// Only while something runs, as the server's own line: the header's button is the way into the
|
||||||
|
// panel at any other time (and a chat whose tasks all finished long ago does not carry
|
||||||
|
// "12 finished" for ever: the panel still lists them).
|
||||||
|
const active = chat && summary?.active ? summaryText(summary) : ""
|
||||||
|
strip.hidden = !active
|
||||||
|
if (!active) return clear(strip)
|
||||||
|
strip.replaceChildren(
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
id: "activity-strip-btn",
|
||||||
|
class: "strip__btn is-active",
|
||||||
|
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
||||||
|
"aria-controls": "activity",
|
||||||
|
"aria-expanded": String(panelOpen()),
|
||||||
|
onclick: togglePanel,
|
||||||
|
},
|
||||||
|
h("span", { class: "dot", "aria-hidden": "true" }),
|
||||||
|
h("span", { class: "strip__text", text: active }),
|
||||||
|
icon(ICONS.chevron),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header's button: the panel, open or closed, for every open chat. */
|
||||||
|
export function activityButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
id: "activity-open",
|
||||||
|
"aria-label": t("Context and background tasks"),
|
||||||
|
title: t("Context and background tasks"),
|
||||||
|
"aria-controls": "activity",
|
||||||
|
"aria-expanded": String(panelOpen()),
|
||||||
|
onclick: togglePanel,
|
||||||
|
},
|
||||||
|
icon(ICONS.tasks),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The panel ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||||
|
export function showPanel(on) {
|
||||||
|
if (on && !chat) return
|
||||||
|
$("activity").hidden = !on
|
||||||
|
layout(on)
|
||||||
|
markOpen()
|
||||||
|
clearInterval(ticker)
|
||||||
|
ticker = 0
|
||||||
|
if (!on) {
|
||||||
|
view = null
|
||||||
|
renderStrip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker = setInterval(tick, 1000)
|
||||||
|
renderStrip()
|
||||||
|
render()
|
||||||
|
read(true)
|
||||||
|
$("activity-close").focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePanel() {
|
||||||
|
showPanel(!panelOpen())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The elapsed times of agents still at work count on between reads. */
|
||||||
|
function tick() {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
const extra = (Date.now() - readAt) / 1000
|
||||||
|
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
||||||
|
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const entries = data.agents || []
|
||||||
|
const parts = [
|
||||||
|
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
|
||||||
|
section("context", t("Context"), 0, contextPart(data.context)),
|
||||||
|
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
||||||
|
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
|
||||||
|
data.team ? section("room", t("Room"), room.length, roomPart()) : null,
|
||||||
|
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
||||||
|
]
|
||||||
|
body.replaceChildren(...parts.filter(Boolean))
|
||||||
|
body.scrollTop = scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many tasks, nested ones included: those still at work, or those finished. */
|
||||||
|
function count(list, active) {
|
||||||
|
let n = 0
|
||||||
|
for (const a of list) {
|
||||||
|
if (ACTIVE.includes(a.state) === active) n += 1
|
||||||
|
n += count(a.children || [], active)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where an agent chat works: its folder, on this computer or another device. */
|
||||||
|
function environmentPart(c) {
|
||||||
|
const dir = c.device?.directory || ""
|
||||||
|
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
|
||||||
|
const here = onThisComputer(c)
|
||||||
|
return h(
|
||||||
|
"p",
|
||||||
|
{ class: "env", title: dir },
|
||||||
|
icon(ICONS.folder),
|
||||||
|
h("bdi", { class: "env__name", text: name }),
|
||||||
|
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextPart(c) {
|
||||||
|
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||||
|
const known = c.percent !== null && c.percent !== undefined
|
||||||
|
const used = h(
|
||||||
|
"p",
|
||||||
|
{ class: "ctx__used" },
|
||||||
|
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
|
||||||
|
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
|
||||||
|
)
|
||||||
|
const cats = c.categories || []
|
||||||
|
const bar = c.limit
|
||||||
|
? h(
|
||||||
|
"div",
|
||||||
|
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
|
||||||
|
cats
|
||||||
|
.filter((r) => r.key !== "free" && r.tokens)
|
||||||
|
.map((r) => {
|
||||||
|
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
|
||||||
|
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
|
||||||
|
return seg
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const rows = h(
|
||||||
|
"ul",
|
||||||
|
{ class: "ctx__rows" },
|
||||||
|
cats.map((r) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{ class: "ctx__row" },
|
||||||
|
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
|
||||||
|
h("span", { class: "ctx__name", text: contextWord(r.key) }),
|
||||||
|
h("span", { class: "ctx__num", text: compact(r.tokens) }),
|
||||||
|
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const note = h(
|
||||||
|
"p",
|
||||||
|
{ class: "ctx__note" },
|
||||||
|
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
||||||
|
)
|
||||||
|
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far it is to the automatic compaction, and the button that compacts now. The server says it
|
||||||
|
* (2.0.0-beta.7: `auto_compact`, `until_compact` in tokens, `compactable` whether Compact can run
|
||||||
|
* now); an older one gives only `threshold` -- the server compacts before a turn once the context
|
||||||
|
* would reach that % of the window, 0 is off -- and it is worked out from that.
|
||||||
|
*/
|
||||||
|
function compactRow(c) {
|
||||||
|
const limit = Number(c.limit) || 0
|
||||||
|
const threshold = Number(c.threshold)
|
||||||
|
const auto = "auto_compact" in c ? !!c.auto_compact : threshold > 0
|
||||||
|
let until = c.until_compact
|
||||||
|
if (until === undefined && auto && limit && threshold < 100) until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
|
||||||
|
let words
|
||||||
|
if (!auto) words = t("Automatic compaction is off.")
|
||||||
|
else if (until === null || until === undefined) words = t("Compacts by itself once the window is known.")
|
||||||
|
else words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
|
||||||
|
const busy = compacting === chat?.id
|
||||||
|
// `compactable` false: nothing to summarise yet, a reply being written, compaction turned off --
|
||||||
|
// the button stays, greyed; pressing an older server's says why in its own words.
|
||||||
|
const can = !("compactable" in c) || !!c.compactable
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ class: "ctx__compact" },
|
||||||
|
h("span", { class: "ctx__until", text: words }),
|
||||||
|
h("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--sm",
|
||||||
|
text: busy ? t("Compacting…") : t("Compact"),
|
||||||
|
disabled: busy || !can,
|
||||||
|
title: t("Summarise the earlier turns now and send the summary in their place."),
|
||||||
|
onclick: compactNow,
|
||||||
|
}),
|
||||||
|
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let compacting = null // the chat being compacted
|
||||||
|
let compactError = ""
|
||||||
|
|
||||||
|
async function compactNow() {
|
||||||
|
if (!chat || compacting) return
|
||||||
|
const id = chat.id
|
||||||
|
compacting = id
|
||||||
|
compactError = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(id)}/compact`)
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
onCompacted(id)
|
||||||
|
read(true)
|
||||||
|
} catch (err) {
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
|
||||||
|
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||||
|
function safeKey(key) {
|
||||||
|
return String(key).replace(/[^a-z_]/gi, "") || "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputsPart(outputs) {
|
||||||
|
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
|
||||||
|
return h(
|
||||||
|
"ul",
|
||||||
|
{ class: "outs" },
|
||||||
|
outputs.map((o) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{ class: "outs__row" },
|
||||||
|
icon(ICONS.file),
|
||||||
|
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "outs__diff" },
|
||||||
|
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksPart(entries) {
|
||||||
|
const team = data.team
|
||||||
|
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
||||||
|
if (!entries.length && !team) {
|
||||||
|
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
if (team) {
|
||||||
|
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
|
||||||
|
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||||
|
}
|
||||||
|
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
|
||||||
|
// what finished with nothing under it still at work, folded.
|
||||||
|
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
|
||||||
|
const ended = entries.filter((e) => !live.includes(e))
|
||||||
|
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
|
||||||
|
if (live.length) {
|
||||||
|
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
||||||
|
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
||||||
|
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
|
||||||
|
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomPart() {
|
||||||
|
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
|
||||||
|
return h(
|
||||||
|
"ol",
|
||||||
|
{ class: "room" },
|
||||||
|
room.map((m) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{ class: `room__entry${m.report ? " is-report" : ""}` },
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "room__meta" },
|
||||||
|
h("span", { class: "room__n", text: `#${m.n}` }),
|
||||||
|
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
|
||||||
|
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
|
||||||
|
),
|
||||||
|
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folded list of tasks ("Show 3 more", "Finished 12", "Tasks: 2"), remembered across redraws. */
|
||||||
|
function fold(name, label, list, cls = "") {
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
|
||||||
|
h("div", { class: "tasks__list" }, list.map(agentRows)),
|
||||||
|
)
|
||||||
|
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A task and, folded under it, the tasks it leads or started ("Tasks: 3"). */
|
||||||
|
function agentRows(a) {
|
||||||
|
const kids = a.children || []
|
||||||
|
if (!kids.length) return agentRow(a)
|
||||||
|
const nested = fold(`under:${a.kind}:${a.id}`, t("Tasks: %(n)s", { n: kids.length }), kids, "tasks__nested")
|
||||||
|
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
|
||||||
|
return [agentRow(a), nested]
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRow(a) {
|
||||||
|
const key = `${a.kind}:${a.id}`
|
||||||
|
const active = ACTIVE.includes(a.state)
|
||||||
|
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
|
||||||
|
if (active) {
|
||||||
|
time.dataset.active = ""
|
||||||
|
time.dataset.seconds = String(Number(a.seconds) || 0)
|
||||||
|
}
|
||||||
|
const stopBtn = a.stoppable
|
||||||
|
? h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "agent__stop",
|
||||||
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
|
onclick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
stopAgent(a)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
|
)
|
||||||
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
|
const facts = h(
|
||||||
|
"p",
|
||||||
|
{ class: "agent__facts" },
|
||||||
|
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||||
|
h("span", { text: kindWord(a) }),
|
||||||
|
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
|
||||||
|
a.agent ? h("span", { text: a.agent }) : null,
|
||||||
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
|
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||||
|
)
|
||||||
|
const step = a.step
|
||||||
|
? h(
|
||||||
|
"p",
|
||||||
|
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
|
||||||
|
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
||||||
|
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
|
||||||
|
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
|
||||||
|
h(
|
||||||
|
"summary",
|
||||||
|
{ class: "agent__row" },
|
||||||
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
|
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
|
||||||
|
time,
|
||||||
|
stopBtn,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agent__body" },
|
||||||
|
facts,
|
||||||
|
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||||
|
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||||
|
step,
|
||||||
|
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||||
|
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||||
|
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task's line: a team's member by its name (it is how the room and the others call it); a
|
||||||
|
* subagent or a command by what it was given to do, its id only where there is nothing else.
|
||||||
|
*/
|
||||||
|
function label(a) {
|
||||||
|
if (a.kind === "member") return a.name || a.id
|
||||||
|
return a.role || a.brief || a.name || a.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a task is: a team's member, a subagent, or a background command. */
|
||||||
|
function kindWord(a) {
|
||||||
|
if (a.kind === "member") return t("Team member")
|
||||||
|
if (a.type === "command") return t("Background command")
|
||||||
|
return t("Subagent")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAgent(a) {
|
||||||
|
if (!chat) return
|
||||||
|
if (a.kind === "member") {
|
||||||
|
const yes = await ask({
|
||||||
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
|
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
|
||||||
|
ok: t("Stop"),
|
||||||
|
cancel: t("Cancel"),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!yes) return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
|
||||||
|
} catch (err) {
|
||||||
|
// 409: it had finished meanwhile; the read below shows that.
|
||||||
|
if (err?.status !== 409) renderError(why(err))
|
||||||
|
}
|
||||||
|
read(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- One agent's conversation, read-only ------------------------------------------------------------
|
||||||
|
|
||||||
|
function openAgent(a) {
|
||||||
|
view = { kind: a.kind, id: a.id, name: a.name || a.id }
|
||||||
|
$("activity").dataset.view = "agent"
|
||||||
|
$("activity-title").textContent = view.name
|
||||||
|
$("activity-back").hidden = false
|
||||||
|
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||||
|
readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAgent() {
|
||||||
|
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, here, compacted }) {
|
||||||
|
layout = onLayout || layout
|
||||||
|
onThisComputer = here || onThisComputer
|
||||||
|
onCompacted = compacted || onCompacted
|
||||||
|
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||||
|
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||||
|
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||||
|
$("activity-back").addEventListener("click", closeAgent)
|
||||||
|
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
|
||||||
|
// panel stays; one Escape closes one thing.
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(e) => {
|
||||||
|
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
|
||||||
|
if (!$("viewer").hidden) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (view) closeAgent()
|
||||||
|
else showPanel(false)
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState !== "visible" || !chat) return
|
||||||
|
if (!stream && !retry) listenTo(chat.id)
|
||||||
|
if (panelOpen()) read(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
// The agent on this computer as the person sees it: one sentence for its state, and the one thing
|
||||||
|
// to do about it -- link this computer, link it again, add a folder, start it. The same words in
|
||||||
|
// three places: the title bar's chip (its popover), above the composer of a new agent chat, and
|
||||||
|
// Settings. The state is the CLI's word for its link (src-tauri/src/agent.rs); "refused" is the
|
||||||
|
// server's word only, with the server's reason.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, listen, why } from "./bridge.js"
|
||||||
|
|
||||||
|
let linking = null // { user_code, uri } while a link waits for its approval
|
||||||
|
let linkError = ""
|
||||||
|
const watchers = new Set()
|
||||||
|
|
||||||
|
/** Linked and allowed into a folder: what an agent chat needs. */
|
||||||
|
export function agentReady(s) {
|
||||||
|
const a = s?.agent || {}
|
||||||
|
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||||
|
return linked && !!(s.remote?.enabled && s.remote.roots?.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent's state for the page: `tone` (ok, busy, warn, off), the sentence, and `action`: what
|
||||||
|
* the one button does ("link", "relink", "folder", "start" or null).
|
||||||
|
*/
|
||||||
|
export function agentState(s) {
|
||||||
|
const a = s?.agent || { state: "off" }
|
||||||
|
const why = typeof a.detail === "string" ? a.detail : ""
|
||||||
|
const noFolders = s?.remote && !(s.remote.enabled && s.remote.roots?.length)
|
||||||
|
switch (a.state) {
|
||||||
|
case "not_linked":
|
||||||
|
return { tone: "warn", action: "link", sentence: t("This computer is not linked yet."), more: t("Link it to your server, and agent chats can work in its folders.") }
|
||||||
|
case "refused":
|
||||||
|
return { tone: "warn", action: "relink", sentence: t("The server refused this computer's link: %(why)s", { why: why || "…" }), more: t("Link it again to give the agent a new token.") }
|
||||||
|
case "starting":
|
||||||
|
return { tone: "busy", action: null, sentence: t("The agent is starting…") }
|
||||||
|
case "connecting":
|
||||||
|
return { tone: "busy", action: null, sentence: t("The agent is connecting to the server…") }
|
||||||
|
case "waiting":
|
||||||
|
return { tone: "warn", action: null, sentence: t("The agent is not connected: %(why)s. It keeps trying.", { why: why || "…" }) }
|
||||||
|
case "failed":
|
||||||
|
return { tone: "warn", action: s.linked ? "start" : "link", sentence: t("The agent stopped: %(why)s. Trying again shortly.", { why: why || "…" }) }
|
||||||
|
case "elsewhere": {
|
||||||
|
const pid = a.detail?.pid
|
||||||
|
if (!a.detail?.linked) return { tone: "warn", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }) }
|
||||||
|
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||||
|
return { tone: "ok", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }) }
|
||||||
|
}
|
||||||
|
case "running":
|
||||||
|
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||||
|
return { tone: "ok", action: null, sentence: t("The agent is running: agent chats can use this computer.") }
|
||||||
|
default:
|
||||||
|
return { tone: "off", action: s?.linked ? "start" : "link", sentence: s?.linked ? t("The agent is off.") : t("This computer is not linked yet.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start linking this computer: the approval opens in the browser; the page hears `linking`. */
|
||||||
|
export function link() {
|
||||||
|
linkError = ""
|
||||||
|
changed()
|
||||||
|
return invoke("link_computer").catch((err) => {
|
||||||
|
linkError = why(err)
|
||||||
|
changed()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
link: () => t("Link this computer"),
|
||||||
|
relink: () => t("Link again"),
|
||||||
|
folder: () => t("Add folder…"),
|
||||||
|
start: () => t("Start the agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(action) {
|
||||||
|
if (action === "link" || action === "relink") return link()
|
||||||
|
if (action === "folder") return invoke("remote_add")
|
||||||
|
if (action === "start") return invoke("set_agent", { on: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent's state as a block: its sentence, a line more where there is one, and its button;
|
||||||
|
* while a link waits for approval, the code to compare and Cancel instead. `compact` leaves the
|
||||||
|
* second line out.
|
||||||
|
*/
|
||||||
|
export function agentBlock(s, { compact = false } = {}) {
|
||||||
|
const st = agentState(s)
|
||||||
|
const el = h("div", { class: `agentbox is-${st.tone}`, role: "status", "aria-live": "polite" })
|
||||||
|
el.append(h("span", { class: "agentbox__dot", "aria-hidden": "true" }), h("p", { class: "agentbox__text", text: st.sentence }))
|
||||||
|
if (!compact && st.more) el.append(h("p", { class: "agentbox__more", text: st.more }))
|
||||||
|
if (linking) {
|
||||||
|
el.append(
|
||||||
|
h("p", { class: "agentbox__more", text: t("Approve this code in the page that opened in your browser:") }),
|
||||||
|
h("p", { class: "code agentbox__code", text: linking.user_code }),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agentbox__actions" },
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Open the page again"), onclick: () => invoke("open_external", { url: linking.uri }).catch(() => {}) }),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => invoke("link_cancel") }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (st.action) {
|
||||||
|
el.append(
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agentbox__actions" },
|
||||||
|
h("button", { type: "button", class: `btn btn--sm ${st.tone === "warn" || st.tone === "off" ? "btn--primary" : ""}`, onclick: () => run(st.action) }, st.action === "link" || st.action === "relink" ? icon(ICONS.link) : null, LABELS[st.action]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (linkError) el.append(h("p", { class: "agentbox__error", text: linkError }))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something that shows `agentBlock` and draws it again when linking moves on. */
|
||||||
|
export function onAgentChange(fn) {
|
||||||
|
watchers.add(fn)
|
||||||
|
return () => watchers.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
for (const fn of watchers) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkingNow() {
|
||||||
|
return linking
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAgent() {
|
||||||
|
listen("linking", (e) => {
|
||||||
|
const p = e.payload
|
||||||
|
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
||||||
|
linkError = p.state === "failed" ? p.message : ""
|
||||||
|
changed()
|
||||||
|
})
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
// The files the next message carries. Three ways in, each the person's own act: the attach
|
||||||
|
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
|
||||||
|
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
|
||||||
|
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
|
||||||
|
// with the message that is sent next; one taken off before that is deleted on the server.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
|
import { invoke, listen, del, why } from "./bridge.js"
|
||||||
|
import { picture } from "./models.js"
|
||||||
|
|
||||||
|
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
|
||||||
|
let seq = 0
|
||||||
|
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||||
|
|
||||||
|
const REASONS = {
|
||||||
|
folder: () => t("This is a folder; attach the files in it."),
|
||||||
|
too_large: () => t("This file is too large to send."),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ids of the files sent and ready to go with the message. */
|
||||||
|
export function readyIds() {
|
||||||
|
return pending.filter((p) => p.state === "ready").map((p) => p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file is still being sent. */
|
||||||
|
export function sending() {
|
||||||
|
return pending.some((p) => p.state === "sending")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a send: the files belong to the message now. */
|
||||||
|
export function sent() {
|
||||||
|
pending = []
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another chat opened, or a new one: files never sent are deleted on the server. */
|
||||||
|
export function discard() {
|
||||||
|
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||||
|
pending = []
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(p) {
|
||||||
|
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||||
|
pending = pending.filter((x) => x !== p)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
|
||||||
|
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
|
||||||
|
* a button to take it off. The same tracks on every tile.
|
||||||
|
*/
|
||||||
|
function row(p) {
|
||||||
|
const status =
|
||||||
|
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||||
|
const tile = h(
|
||||||
|
"li",
|
||||||
|
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||||
|
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
|
||||||
|
h("span", { class: "tile__icon" }, icon(ICONS.file)),
|
||||||
|
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
|
||||||
|
h("span", { class: "tile__size", text: status }),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "file__remove",
|
||||||
|
"aria-label": t("Remove %(dir)s", { dir: p.name }),
|
||||||
|
title: t("Remove"),
|
||||||
|
disabled: p.state === "sending",
|
||||||
|
onclick: () => remove(p),
|
||||||
|
},
|
||||||
|
icon(ICONS.close),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return tile
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const list = clear($("composer-files"))
|
||||||
|
list.className = "tiles composer__files"
|
||||||
|
list.hidden = !pending.length
|
||||||
|
for (const p of pending) list.append(row(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show `names` as being sent while `call` sends them; then what became of each. */
|
||||||
|
async function track(names, call) {
|
||||||
|
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
|
||||||
|
pending.push(...placeholders)
|
||||||
|
render()
|
||||||
|
let results
|
||||||
|
try {
|
||||||
|
results = await call()
|
||||||
|
} catch (err) {
|
||||||
|
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
|
||||||
|
}
|
||||||
|
pending = pending.filter((p) => !placeholders.includes(p))
|
||||||
|
for (const r of results || []) {
|
||||||
|
pending.push(
|
||||||
|
r.attachment
|
||||||
|
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
|
||||||
|
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
|
||||||
|
for (const p of pending) {
|
||||||
|
if (p.state !== "ready" || !p.image || p.thumb) continue
|
||||||
|
picture(`/attachments/${p.id}`).then((url) => {
|
||||||
|
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
|
||||||
|
p.thumb = url
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
|
||||||
|
function dataUrl(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
|
||||||
|
reader.onerror = () => resolve(undefined)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose files in the system's dialog. */
|
||||||
|
async function pick() {
|
||||||
|
const b = $("attach")
|
||||||
|
b.disabled = true
|
||||||
|
try {
|
||||||
|
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
|
||||||
|
} finally {
|
||||||
|
b.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
|
||||||
|
async function paste(e) {
|
||||||
|
const files = [...(e.clipboardData?.files || [])]
|
||||||
|
if (!files.length || !ctx.allowed()) return
|
||||||
|
e.preventDefault()
|
||||||
|
for (const f of files) {
|
||||||
|
const name = f.name || "pasted"
|
||||||
|
track([name], async () => {
|
||||||
|
const data = new Uint8Array(await f.arrayBuffer())
|
||||||
|
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||||
|
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAttach(context) {
|
||||||
|
ctx = context
|
||||||
|
$("attach").replaceChildren(icon(ICONS.attach))
|
||||||
|
$("attach").addEventListener("click", pick)
|
||||||
|
$("composer-text").addEventListener("paste", paste)
|
||||||
|
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
|
||||||
|
listen("drag", (e) => {
|
||||||
|
$("composer").dataset.drag = String(!!e.payload && usable())
|
||||||
|
})
|
||||||
|
listen("drop", (e) => {
|
||||||
|
$("composer").dataset.drag = "false"
|
||||||
|
if (!usable()) return
|
||||||
|
const names = Array.isArray(e.payload) ? e.payload : []
|
||||||
|
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this account may attach files at all: the button shows only then. */
|
||||||
|
export function allowAttach(on) {
|
||||||
|
$("attach").hidden = !on
|
||||||
|
$("composer").dataset.attach = String(on)
|
||||||
|
}
|
||||||
+576
-106
@@ -1,20 +1,27 @@
|
|||||||
// One chat: its transcript, the reply being written (followed as the server streams it), the
|
// One chat: its transcript, the reply being written (followed as the server streams it), the
|
||||||
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
|
// cards that wait for an answer, and the composer with the chat's settings as chips under the
|
||||||
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
|
// text (folder, mode, model, effort, agents). And a new chat, which is the same page empty: a
|
||||||
|
// greeting, the choice of an agent chat on this computer or a chat on the server, and the composer.
|
||||||
//
|
//
|
||||||
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
|
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
|
||||||
// repository is the contract): a person's message is their text as typed; a reply is its steps --
|
// 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.
|
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
|
||||||
|
|
||||||
import { $, h, clear, append } from "./dom.js"
|
import { $, h, clear, append, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
|
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
|
||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
|
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||||
|
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||||
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
|
import { watchActivity, activityChanged, planList, activityButton } from "./activity.js"
|
||||||
|
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||||
|
import { folderName } from "./list.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
const MODES = ["manual", "edit", "auto", "plan"]
|
||||||
|
|
||||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
|
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||||
let open = null // { chat, live, more, first }
|
let open = null // { chat, live, more, first }
|
||||||
|
|
||||||
export function openChatId() {
|
export function openChatId() {
|
||||||
@@ -23,7 +30,7 @@ export function openChatId() {
|
|||||||
|
|
||||||
// --- Reading -----------------------------------------------------------------------------------
|
// --- Reading -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
function onThisComputer(chat) {
|
export function onThisComputer(chat) {
|
||||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +44,117 @@ function toBottom(force) {
|
|||||||
if (force || nearBottom()) th.scrollTop = th.scrollHeight
|
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(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file a message carries, as a tile: a picture as its thumbnail (read through the bridge, as
|
||||||
|
* a `data:` URL), anything else as its icon, name and size.
|
||||||
|
*/
|
||||||
|
export function fileTile(a) {
|
||||||
|
const tile = h(
|
||||||
|
"li",
|
||||||
|
{ class: `tile${a.image ? " tile--image" : ""}`, title: a.name },
|
||||||
|
h("span", { class: "tile__icon" }, icon(ICONS.file)),
|
||||||
|
h("span", { class: "tile__name" }, h("bdi", { text: a.name })),
|
||||||
|
h("span", { class: "tile__size", text: a.size ? bytes(a.size) : "" }),
|
||||||
|
)
|
||||||
|
if (a.image && a.id) {
|
||||||
|
picture(`/attachments/${a.id}`).then((url) => {
|
||||||
|
if (!url || !url.startsWith("data:image/")) return
|
||||||
|
tile.classList.add("has-thumb")
|
||||||
|
tile.prepend(h("img", { class: "tile__thumb", src: url, alt: "", draggable: "false" }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tile
|
||||||
|
}
|
||||||
|
|
||||||
function userMessage(m) {
|
function userMessage(m) {
|
||||||
return h(
|
const el = h(
|
||||||
"article",
|
"article",
|
||||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
{ 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: "tiles msg__files" }, m.attachments.map(fileTile)) : null,
|
||||||
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
|
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
|
||||||
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : 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) {
|
function metricsLine(x) {
|
||||||
@@ -65,9 +175,22 @@ function assistantMessage(m, chat) {
|
|||||||
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
|
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.") }))
|
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
|
||||||
const meta = metricsLine(m.metrics)
|
const meta = metricsLine(m.metrics)
|
||||||
if (meta || m.model) {
|
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" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
|
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: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
||||||
|
meta,
|
||||||
|
),
|
||||||
|
)
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +205,7 @@ function step(s, chat) {
|
|||||||
return h(
|
return h(
|
||||||
"details",
|
"details",
|
||||||
{ class: "step step--thinking", open: s.open || null },
|
{ class: "step step--thinking", open: s.open || null },
|
||||||
h("summary", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
|
h("summary", { class: "step__row" }, h("span", { class: "step__icon" }, icon(ICONS.bulb)), h("span", { class: "step__label" }, t("Thought"), s.label ? h("span", { class: "step__meta", text: ` · ${s.label}` }) : null), h("span", { class: "step__chev" }, icon(ICONS.chevron))),
|
||||||
h("div", { class: "step__pre", text: s.text || "" }),
|
h("div", { class: "step__pre", text: s.text || "" }),
|
||||||
)
|
)
|
||||||
case "text":
|
case "text":
|
||||||
@@ -138,8 +261,10 @@ function toolCall(c, chat) {
|
|||||||
"summary",
|
"summary",
|
||||||
{ class: "call__head" },
|
{ class: "call__head" },
|
||||||
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
||||||
h("span", { class: "call__title", title: block.title || c.name, text: block.title || c.name || "" }),
|
h("span", { class: "call__name", text: c.name || "" }),
|
||||||
|
h("span", { class: "call__title", title: block.title || c.name, text: block.title && block.title !== c.name ? block.title : "" }),
|
||||||
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
|
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
|
||||||
|
h("span", { class: "step__chev" }, icon(ICONS.chevron)),
|
||||||
)
|
)
|
||||||
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
|
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
|
||||||
if (isShow) {
|
if (isShow) {
|
||||||
@@ -266,10 +391,14 @@ class Live {
|
|||||||
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
|
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
|
||||||
this.status.classList.add("is-error")
|
this.status.classList.add("is-error")
|
||||||
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
|
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
|
||||||
} else reload()
|
} else {
|
||||||
|
reload()
|
||||||
|
activityChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** While a reply is written, Stop takes Send's place (Send comes back beside it for a message typed meanwhile). */
|
||||||
function setRunning(on) {
|
function setRunning(on) {
|
||||||
$("send").hidden = on && !$("composer-text").value.trim()
|
$("send").hidden = on && !$("composer-text").value.trim()
|
||||||
$("stop").hidden = !on
|
$("stop").hidden = !on
|
||||||
@@ -390,16 +519,6 @@ function cardOf(chat, card) {
|
|||||||
return el
|
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 --------------------------------------------------------------------------------
|
// --- Opening a chat --------------------------------------------------------------------------------
|
||||||
|
|
||||||
function setTitle(title) {
|
function setTitle(title) {
|
||||||
@@ -407,6 +526,7 @@ function setTitle(title) {
|
|||||||
$("chat-title").title = title || ""
|
$("chat-title").title = title || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chat's header: its title, where it works (an agent chat's folder and device), and its tools. */
|
||||||
function renderHead(chat) {
|
function renderHead(chat) {
|
||||||
setTitle(chat.title)
|
setTitle(chat.title)
|
||||||
const place = clear($("chat-place"))
|
const place = clear($("chat-place"))
|
||||||
@@ -414,17 +534,326 @@ function renderHead(chat) {
|
|||||||
if (chat.kind === "agent" && chat.device) {
|
if (chat.kind === "agent" && chat.device) {
|
||||||
const here = onThisComputer(chat)
|
const here = onThisComputer(chat)
|
||||||
place.append(
|
place.append(
|
||||||
h("span", { class: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
|
h(
|
||||||
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
|
"span",
|
||||||
|
{ class: `place${here ? " is-here" : ""}`, title: chat.device.directory },
|
||||||
|
icon(ICONS.folder),
|
||||||
|
h("span", { class: "place__name", text: folderName(chat.device.directory) }),
|
||||||
|
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const mode = h(
|
if (chat.device_session_id) {
|
||||||
"select",
|
tools.append(
|
||||||
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
|
h(
|
||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
"button",
|
||||||
)
|
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
||||||
tools.append(mode)
|
icon(ICONS.terminal),
|
||||||
} else if (chat.model) {
|
),
|
||||||
place.append(h("span", { class: "chip", text: chat.model }))
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools.append(activityButton())
|
||||||
|
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 is offered subagents: `{ cap, own }` (the instance's most; whether only a model
|
||||||
|
* the person names could serve them), or null. The chat's own `subagents` where the server gives
|
||||||
|
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
|
||||||
|
*/
|
||||||
|
async function teamOffered(chat) {
|
||||||
|
const said = chat.subagents
|
||||||
|
if (said !== undefined && said !== null) {
|
||||||
|
const offered = typeof said === "object" ? said.offered : said
|
||||||
|
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
|
||||||
|
}
|
||||||
|
if (chat.kind !== "chat") return null
|
||||||
|
try {
|
||||||
|
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
|
||||||
|
return team?.offered ? { cap: subagentsCap(), own: false } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The chips under the text -------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A chat's settings are small buttons under what is being written, each opening a menu: the
|
||||||
|
// folder and the mode (an agent chat), the model with its picture, how hard it thinks (as the
|
||||||
|
// model offers it: nothing for one level), and the team's size (where a team is offered). For a
|
||||||
|
// new chat they say what it will start with; for an open one, changing one changes the chat.
|
||||||
|
|
||||||
|
/** One chip: an icon or a lead (an avatar), its words, and a chevron when it opens a menu. */
|
||||||
|
function chip({ ico, lead, label, title, onclick, menuish = true, cls = "", disabled = false }) {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: `cchip ${cls}`, title: title || label, "aria-haspopup": menuish ? "menu" : null, disabled, onclick },
|
||||||
|
lead || (ico ? icon(ico) : null),
|
||||||
|
h("span", { class: "cchip__label", text: label }),
|
||||||
|
menuish ? h("span", { class: "cchip__chev" }, icon(ICONS.chevronDown)) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_WORDS = {
|
||||||
|
manual: () => t("Manual — asks first"),
|
||||||
|
edit: () => t("Edit — asks before commands"),
|
||||||
|
auto: () => t("Auto — asks nothing"),
|
||||||
|
plan: () => t("Plan — changes nothing"),
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeChip(value, onChange, disabled) {
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.shield,
|
||||||
|
label: modeName(value),
|
||||||
|
title: `${t("Mode")}: ${MODE_WORDS[value]?.() || value}`,
|
||||||
|
disabled,
|
||||||
|
onclick: (e) => menu(e.currentTarget, MODES.map((m) => ({ label: MODE_WORDS[m](), checked: m === value, run: () => m !== value && onChange(m) }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelChip(models, value, onChange, mayPick) {
|
||||||
|
const m = models.find((x) => x.id === value) || (value ? { id: value, name: value } : null)
|
||||||
|
if (!m) return chip({ ico: ICONS.info, label: t("No model is available to this account."), menuish: false, disabled: true })
|
||||||
|
return chip({
|
||||||
|
lead: avatar(m),
|
||||||
|
label: m.name,
|
||||||
|
title: `${t("Model")}: ${m.name} (${m.id})`,
|
||||||
|
menuish: mayPick && models.length > 1,
|
||||||
|
cls: "cchip--model",
|
||||||
|
onclick: (e) => {
|
||||||
|
if (!mayPick || models.length < 2) return
|
||||||
|
menu(e.currentTarget, models.map((x) => ({ label: x.name, lead: avatar(x), checked: x.id === value, run: () => x.id !== value && onChange(x.id) })))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How hard the model thinks: nothing where it has one level; else its levels, in a menu. */
|
||||||
|
function effortChip(model, value, onChange) {
|
||||||
|
const { levels, start, control } = levelsOf(model)
|
||||||
|
if (control === "none") return null
|
||||||
|
const current = levels.includes(value) ? value : start
|
||||||
|
const words = (l) => (control === "switch" ? (l === "off" ? t("off") : t("Think before answering")) : levelWord(l))
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.bulb,
|
||||||
|
label: control === "switch" ? (current === "off" ? t("off") : levelWord(current)) : levelWord(current),
|
||||||
|
title: `${t("Reasoning effort")}: ${levelWord(current)}`,
|
||||||
|
cls: current === "off" ? "is-dim" : "",
|
||||||
|
onclick: (e) => menu(e.currentTarget, levels.map((l) => ({ label: words(l), hint: control === "switch" && l !== "off" ? levelWord(l) : "", checked: l === current, run: () => l !== current && onChange(l) }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
|
||||||
|
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
|
||||||
|
*/
|
||||||
|
function agentsChip(value, onChange, { cap, own } = {}) {
|
||||||
|
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.users,
|
||||||
|
label: `${t("Subagents")} ${said}`,
|
||||||
|
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
|
||||||
|
cls: `cchip--subagents${own ? " is-warn" : ""}`,
|
||||||
|
onclick: (e) => {
|
||||||
|
const fields = agentsControl({ value, id: "agents-min", cap })
|
||||||
|
popover(
|
||||||
|
e.currentTarget,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "pop__body" },
|
||||||
|
h("p", { class: "pop__title", text: t("Subagents") }),
|
||||||
|
fields,
|
||||||
|
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
|
||||||
|
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
|
||||||
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
||||||
|
),
|
||||||
|
{ up: true, label: t("Subagents") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder of a new agent chat: one of the agent's folders, or Add folder… where there is none. */
|
||||||
|
function folderChip(roots, value, onChange) {
|
||||||
|
if (!roots.length) return chip({ ico: ICONS.folder, label: t("Add folder…"), menuish: false, cls: "is-warn", onclick: () => invoke("remote_add") })
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.folder,
|
||||||
|
label: folderName(value),
|
||||||
|
title: `${t("Folder")}: ${value}`,
|
||||||
|
onclick: (e) =>
|
||||||
|
menu(e.currentTarget, [
|
||||||
|
...roots.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
|
||||||
|
null,
|
||||||
|
{ label: t("Add folder…"), run: () => invoke("remote_add") },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
||||||
|
|
||||||
|
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||||
|
async function renderChips() {
|
||||||
|
const box = $("composer-chips")
|
||||||
|
const drawing = ++chipsDrawn
|
||||||
|
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||||
|
if (draft) {
|
||||||
|
const s = ctx.status()
|
||||||
|
const models = ctx.models()
|
||||||
|
const model = models.find((m) => m.id === draft.model)
|
||||||
|
const parts = []
|
||||||
|
if (draft.kind === "agent") {
|
||||||
|
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||||
|
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||||
|
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
|
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||||
|
}
|
||||||
|
parts.push(modelChip(models, draft.model, (id) => {
|
||||||
|
draft.model = id
|
||||||
|
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
||||||
|
renderChips()
|
||||||
|
}, mayPick))
|
||||||
|
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||||
|
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||||
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chat = open?.chat
|
||||||
|
if (!chat?.kind) return box.replaceChildren()
|
||||||
|
const save = async (body) => {
|
||||||
|
try {
|
||||||
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||||
|
if (open?.chat?.id === chat.id) {
|
||||||
|
open.chat = r.chat
|
||||||
|
renderChips()
|
||||||
|
if (body.model) activityChanged()
|
||||||
|
}
|
||||||
|
alertIn("")
|
||||||
|
} catch (err) {
|
||||||
|
alertIn(why(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const models = await chatModels(chat)
|
||||||
|
if (drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||||
|
const parts = []
|
||||||
|
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||||
|
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||||
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
const offer = await teamOffered(chat)
|
||||||
|
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
|
||||||
|
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
|
||||||
|
*/
|
||||||
|
function renderNotice() {
|
||||||
|
const box = $("link-notice")
|
||||||
|
const s = ctx.status()
|
||||||
|
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||||
|
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||||
|
box.hidden = !show
|
||||||
|
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||||
|
else box.replaceChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
|
||||||
|
export function statusChanged() {
|
||||||
|
renderNotice()
|
||||||
|
if (draft) {
|
||||||
|
renderChips()
|
||||||
|
renderHello()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,11 +869,17 @@ function alertIn(text) {
|
|||||||
|
|
||||||
/** Open a chat by id: its newest page, and the reply being written, if one is. */
|
/** Open a chat by id: its newest page, and the reply being written, if one is. */
|
||||||
export async function openChat(id) {
|
export async function openChat(id) {
|
||||||
|
if (open?.chat?.id !== id) discard()
|
||||||
if (open?.live) open.live.stop()
|
if (open?.live) open.live.stop()
|
||||||
open = { chat: { id }, live: null, more: false }
|
open = { chat: { id }, live: null, more: false }
|
||||||
|
draft = null
|
||||||
|
closePopover()
|
||||||
invoke("set_open_chat", { chat: id })
|
invoke("set_open_chat", { chat: id })
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
$("new-chat").hidden = true
|
$("new-chat").hidden = true
|
||||||
|
$("main").dataset.new = "false"
|
||||||
|
clear($("composer-chips"))
|
||||||
|
renderNotice()
|
||||||
const list = clear($("messages"))
|
const list = clear($("messages"))
|
||||||
renderCards({ id }, [])
|
renderCards({ id }, [])
|
||||||
alertIn("")
|
alertIn("")
|
||||||
@@ -460,7 +895,10 @@ export async function openChat(id) {
|
|||||||
if (open?.chat?.id !== id) return
|
if (open?.chat?.id !== id) return
|
||||||
open.chat = r.chat
|
open.chat = r.chat
|
||||||
open.more = r.more
|
open.more = r.more
|
||||||
|
watchActivity(r.chat)
|
||||||
renderHead(r.chat)
|
renderHead(r.chat)
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
clear(list)
|
clear(list)
|
||||||
if (r.more) list.append(earlier())
|
if (r.more) list.append(earlier())
|
||||||
for (const m of r.messages) {
|
for (const m of r.messages) {
|
||||||
@@ -507,101 +945,123 @@ function reload() {
|
|||||||
export function closeChat() {
|
export function closeChat() {
|
||||||
if (open?.live) open.live.stop()
|
if (open?.live) open.live.stop()
|
||||||
open = null
|
open = null
|
||||||
|
watchActivity(null)
|
||||||
invoke("set_open_chat", { chat: null })
|
invoke("set_open_chat", { chat: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- A new chat ------------------------------------------------------------------------------------
|
// --- A new chat ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
let draft = null // { kind }
|
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
|
||||||
|
let lastKind = "agent"
|
||||||
|
|
||||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
||||||
|
export function draftKind() {
|
||||||
|
return draft?.kind || lastKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The greeting of a new chat, and the choice of what it is. */
|
||||||
|
function renderHello() {
|
||||||
|
const box = $("new-chat")
|
||||||
|
if (!draft) return
|
||||||
|
const me = ctx.me()
|
||||||
|
const first = String(me?.name || "").trim().split(/\s+/)[0]
|
||||||
|
const kind = draft.kind
|
||||||
|
const s = ctx.status()
|
||||||
|
const server = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(s?.server || "").host
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const tab = (k, ico, label) =>
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
role: "tab",
|
||||||
|
class: "kind__tab",
|
||||||
|
"aria-selected": String(kind === k),
|
||||||
|
onclick: () => {
|
||||||
|
if (draft.kind === k) return
|
||||||
|
draft.kind = lastKind = k
|
||||||
|
renderHello()
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
|
ctx.drafted?.(k)
|
||||||
|
$("composer-text").focus()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon(ico),
|
||||||
|
h("span", { text: label }),
|
||||||
|
)
|
||||||
|
box.replaceChildren(
|
||||||
|
h("img", { class: "hello__mark", src: "logo-mark.svg", alt: "", width: "40", height: "40" }),
|
||||||
|
h("h2", { class: "hello__title", text: first ? t("Hello, %(name)s", { name: first }) : t("Hello") }),
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{ class: "hello__sub" },
|
||||||
|
kind === "agent"
|
||||||
|
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
||||||
|
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
||||||
|
),
|
||||||
|
h("div", { class: "kind", role: "tablist", "aria-label": t("New chat") }, tab("agent", ICONS.terminal, t("Agent")), tab("chat", ICONS.chat, t("Chat"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new chat in the main view: the page empty, the greeting, and the composer ready. */
|
||||||
export function newChat(kind) {
|
export function newChat(kind) {
|
||||||
closeChat()
|
closeChat()
|
||||||
draft = { kind }
|
discard()
|
||||||
|
closePopover()
|
||||||
|
const models = ctx.models()
|
||||||
|
const start = models.find((m) => m.default) || models[0]
|
||||||
|
lastKind = kind || lastKind
|
||||||
|
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
renderCards({ id: "" }, [])
|
renderCards({ id: "" }, [])
|
||||||
alertIn("")
|
alertIn("")
|
||||||
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
|
setTitle(t("New chat"))
|
||||||
clear($("chat-place"))
|
clear($("chat-place"))
|
||||||
clear($("chat-tools"))
|
clear($("chat-tools"))
|
||||||
const form = clear($("new-chat"))
|
$("main").dataset.new = "true"
|
||||||
form.hidden = false
|
$("new-chat").hidden = false
|
||||||
const s = ctx.status()
|
renderHello()
|
||||||
const models = ctx.models()
|
renderChips()
|
||||||
const modelField = field(
|
renderNotice()
|
||||||
t("Model"),
|
|
||||||
h(
|
|
||||||
"select",
|
|
||||||
{ id: "new-model" },
|
|
||||||
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
|
|
||||||
),
|
|
||||||
models.length ? "" : t("No model is available to this account."),
|
|
||||||
)
|
|
||||||
if (kind === "agent") {
|
|
||||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
|
||||||
const devices = ctx.thisDevices()
|
|
||||||
form.append(
|
|
||||||
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
|
|
||||||
)
|
|
||||||
if (!devices.length) {
|
|
||||||
form.append(
|
|
||||||
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
form.append(
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "fields" },
|
|
||||||
field(
|
|
||||||
t("Folder"),
|
|
||||||
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
|
|
||||||
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
|
|
||||||
),
|
|
||||||
field(
|
|
||||||
t("Mode"),
|
|
||||||
h(
|
|
||||||
"select",
|
|
||||||
{ id: "new-mode" },
|
|
||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
|
|
||||||
),
|
|
||||||
t("A chat asking for more than the folder allows is lowered to it."),
|
|
||||||
),
|
|
||||||
modelField,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
form.append(h("div", { class: "fields" }, modelField))
|
|
||||||
}
|
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
invoke("set_open_chat", { chat: null })
|
invoke("set_open_chat", { chat: null })
|
||||||
$("composer-text").focus()
|
$("composer-text").focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field: label, control, hint -- always all three, so a row of them lines up. */
|
async function createChat(text, attachments) {
|
||||||
function field(label, control, hint) {
|
|
||||||
const id = control.id || `f${Math.random().toString(36).slice(2)}`
|
|
||||||
control.id = id
|
|
||||||
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createChat(text) {
|
|
||||||
const body = { text }
|
const body = { text }
|
||||||
const model = $("new-model")?.value
|
if (attachments.length) body.attachments = attachments
|
||||||
if (model) body.model = model
|
if (draft.model) body.model = draft.model
|
||||||
|
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
|
||||||
|
const agents = draft.agents
|
||||||
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = ctx.thisDevices()[0]
|
||||||
const dir = $("new-dir")?.value
|
if (!device || !agentReady(ctx.status())) {
|
||||||
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
|
renderNotice()
|
||||||
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
throw { detail: agentState(ctx.status()).sentence }
|
||||||
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
|
}
|
||||||
|
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||||
|
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
||||||
}
|
}
|
||||||
const r = await post("/chats", body)
|
const r = await post("/chats", body)
|
||||||
draft = null
|
draft = null
|
||||||
$("new-chat").hidden = true
|
$("new-chat").hidden = true
|
||||||
|
$("main").dataset.new = "false"
|
||||||
open = { chat: r.chat, live: null, more: false }
|
open = { chat: r.chat, live: null, more: false }
|
||||||
invoke("set_open_chat", { chat: r.chat.id })
|
invoke("set_open_chat", { chat: r.chat.id })
|
||||||
|
watchActivity(r.chat)
|
||||||
renderHead(r.chat)
|
renderHead(r.chat)
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
if (r.message) $("messages").append(userMessage(r.message))
|
if (r.message) $("messages").append(userMessage(r.message))
|
||||||
ctx.changed(r.chat)
|
ctx.changed(r.chat)
|
||||||
@@ -618,16 +1078,24 @@ function showComposer(on) {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
const box = $("composer-text")
|
const box = $("composer-text")
|
||||||
const text = box.value.trim()
|
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
|
$("send").disabled = true
|
||||||
alertIn("")
|
alertIn("")
|
||||||
try {
|
try {
|
||||||
if (draft) await createChat(text)
|
if (draft) await createChat(text, files)
|
||||||
else if (open?.chat?.id) {
|
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.message) $("messages").append(userMessage(r.message))
|
||||||
if (r.reply) follower(open.chat, r.reply.id)
|
if (r.reply) follower(open.chat, r.reply.id)
|
||||||
}
|
} else return
|
||||||
|
sent()
|
||||||
box.value = ""
|
box.value = ""
|
||||||
grow()
|
grow()
|
||||||
toBottom(true)
|
toBottom(true)
|
||||||
@@ -647,6 +1115,8 @@ function grow() {
|
|||||||
|
|
||||||
export function initChat(context) {
|
export function initChat(context) {
|
||||||
ctx = context
|
ctx = context
|
||||||
|
$("send").replaceChildren(icon(ICONS.send))
|
||||||
|
$("stop").replaceChildren(icon(ICONS.stop))
|
||||||
$("composer").addEventListener("submit", (e) => {
|
$("composer").addEventListener("submit", (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
submit()
|
submit()
|
||||||
@@ -670,6 +1140,6 @@ export function initChat(context) {
|
|||||||
alertIn(why(err))
|
alertIn(why(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onAgentChange(renderNotice)
|
||||||
showComposer(false)
|
showComposer(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +74,30 @@ export const ICONS = {
|
|||||||
min: "M4 10h8v1.5H4z",
|
min: "M4 10h8v1.5H4z",
|
||||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||||
|
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||||
|
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||||
|
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||||
|
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||||
|
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||||
|
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||||
|
stop: "M4.5 4.5h7v7h-7z",
|
||||||
|
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
|
||||||
|
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||||
|
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||||
|
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||||
|
send: "M8 2.25l5.03 5.03-1.06 1.06-3.22-3.22V13.5h-1.5V5.12L4.03 8.34 2.97 7.28z",
|
||||||
|
clock: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 4.5h1.5v3.19l2.03 2.03-1.06 1.06-2.47-2.47z"],
|
||||||
|
chat: "M2 2.5h12v8.5H7l-3.5 3v-3H2zM3.5 4v5.5h1.5v1.25L6.45 9.5h6.05V4z",
|
||||||
|
bulb: ["M8 1.5a4.5 4.5 0 012.75 8.06v1.94h-5.5V9.56A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-1.83 5.38l.58.45V10h2.5V8.83l.58-.45A3 3 0 008 3z", "M5.75 12.5h4.5V14h-4.5z"],
|
||||||
|
users: ["M6 2.5a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM6 4a1 1 0 100 2 1 1 0 000-2z", "M1.5 13.5c0-2.5 2-4.25 4.5-4.25s4.5 1.75 4.5 4.25H9c0-1.6-1.3-2.75-3-2.75S3 11.9 3 13.5z", "M11 3.5a2 2 0 110 4 2 2 0 010-4z", "M11.75 9.4c1.65.35 2.75 1.85 2.75 4.1H13c0-1.4-.55-2.4-1.6-2.7z"],
|
||||||
|
shield: "M8 1l5.5 2v4.5c0 3.3-2.3 6-5.5 7.5-3.2-1.5-5.5-4.2-5.5-7.5V3zM4 4.05V7.5c0 2.45 1.6 4.6 4 5.85 2.4-1.25 4-3.4 4-5.85V4.05L8 2.6z",
|
||||||
|
link: ["M6.47 8.47l2-2 1.06 1.06-2 2z", "M4.6 7.4l1.06 1.06-1.4 1.4a1.5 1.5 0 002.12 2.12l1.4-1.4 1.06 1.06-1.4 1.4a3 3 0 01-4.24-4.24z", "M11.4 8.6l-1.06-1.06 1.4-1.4a1.5 1.5 0 00-2.12-2.12l-1.4 1.4L7.16 4.36l1.4-1.4a3 3 0 014.24 4.24z"],
|
||||||
|
pause: ["M4.5 3.5H7v9H4.5z", "M9 3.5h2.5v9H9z"],
|
||||||
|
play: "M5 2.75L13 8l-8 5.25z",
|
||||||
|
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
||||||
|
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
||||||
|
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
||||||
|
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||||
|
|||||||
+44
-32
@@ -1,6 +1,6 @@
|
|||||||
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
|
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
|
||||||
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the server's
|
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
|
||||||
// tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { invoke, get, why } from "./bridge.js"
|
import { invoke, get, why } from "./bridge.js"
|
||||||
@@ -84,7 +84,7 @@ function renderLocal() {
|
|||||||
if (!local.roots.length) {
|
if (!local.roots.length) {
|
||||||
clear(box).append(
|
clear(box).append(
|
||||||
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||||
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,39 +130,51 @@ export function renderServer() {
|
|||||||
tree(box, {
|
tree(box, {
|
||||||
rootPath: "/",
|
rootPath: "/",
|
||||||
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
|
||||||
open: async (e) => {
|
open: (e) => openServerFile(e.path, e.name),
|
||||||
const where = t("Server")
|
|
||||||
try {
|
|
||||||
const f = await readServer(e.path)
|
|
||||||
openViewer({
|
|
||||||
name: f.name || e.name,
|
|
||||||
path: f.path || e.path,
|
|
||||||
where,
|
|
||||||
text: f.text,
|
|
||||||
// The server's own kind and language: its table and the viewer's are the same one.
|
|
||||||
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
|
|
||||||
language: f.language || undefined,
|
|
||||||
withheld: f.withheld,
|
|
||||||
size: f.size,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
openViewer({ name: e.name, path: e.path, where, error: why(err) })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initExplorer() {
|
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
|
||||||
for (const pane of document.querySelectorAll(".pane")) {
|
export async function openServerFile(path, name) {
|
||||||
const toggle = pane.querySelector(".pane__toggle")
|
const where = t("Server")
|
||||||
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
|
try {
|
||||||
toggle.addEventListener("click", () => {
|
const f = await readServer(path)
|
||||||
const open = pane.dataset.open !== "true"
|
openViewer({
|
||||||
pane.dataset.open = String(open)
|
name: f.name || name,
|
||||||
toggle.setAttribute("aria-expanded", String(open))
|
path: f.path || path,
|
||||||
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
|
where,
|
||||||
|
text: f.text,
|
||||||
|
// The server's own kind and language: its table and the viewer's are the same one.
|
||||||
|
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
|
||||||
|
language: f.language || undefined,
|
||||||
|
withheld: f.withheld,
|
||||||
|
size: f.size,
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
openViewer({ name, path, where, error: why(err) })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab = "local"
|
||||||
|
let serverRead = false
|
||||||
|
|
||||||
|
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
|
||||||
|
export function showFilesTab(which) {
|
||||||
|
tab = which === "server" ? "server" : "local"
|
||||||
|
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
|
||||||
|
$("pane-local").hidden = tab !== "local"
|
||||||
|
$("pane-server").hidden = tab !== "server"
|
||||||
|
if (tab === "server" && !serverRead) {
|
||||||
|
serverRead = true
|
||||||
|
renderServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initExplorer() {
|
||||||
|
const tabBtn = (which, ico, label) =>
|
||||||
|
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
|
||||||
|
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
|
||||||
|
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
$("server-refresh").addEventListener("click", renderServer)
|
$("server-refresh").addEventListener("click", renderServer)
|
||||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
|
|||||||
+84
-33
@@ -1,20 +1,28 @@
|
|||||||
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
|
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
|
||||||
// for) or chats on the server. Agent chats on this computer come first; those on another device
|
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
|
||||||
// of the account follow under their own heading.
|
// on another device of the account says so in its title. The search line above narrows the list
|
||||||
|
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
|
||||||
|
|
||||||
import { $, h, clear, when, append } from "./dom.js"
|
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||||
import { get, why } from "./bridge.js"
|
import { get, why } from "./bridge.js"
|
||||||
|
|
||||||
let ctx = null // { kind(), thisDevices(), open(id), current() }
|
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
||||||
let rows = []
|
let rows = []
|
||||||
let more = false
|
let more = false
|
||||||
|
let query = ""
|
||||||
|
|
||||||
|
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||||
|
function fold(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{M}+/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
/** The list again, from the server. */
|
/** The list again, from the server. */
|
||||||
export async function refreshList() {
|
export async function refreshList() {
|
||||||
const kind = ctx.kind()
|
|
||||||
try {
|
try {
|
||||||
const r = await get(`/chats?kind=${kind}&limit=50`)
|
const r = await get("/chats?limit=50")
|
||||||
if (ctx.kind() !== kind) return
|
|
||||||
rows = r.chats || []
|
rows = r.chats || []
|
||||||
more = !!r.more
|
more = !!r.more
|
||||||
render()
|
render()
|
||||||
@@ -26,9 +34,7 @@ export async function refreshList() {
|
|||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
const last = rows[rows.length - 1]
|
const last = rows[rows.length - 1]
|
||||||
if (!last) return
|
if (!last) return
|
||||||
const kind = ctx.kind()
|
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
|
||||||
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
|
|
||||||
if (ctx.kind() !== kind) return
|
|
||||||
rows = rows.concat(r.chats || [])
|
rows = rows.concat(r.chats || [])
|
||||||
more = !!r.more
|
more = !!r.more
|
||||||
render()
|
render()
|
||||||
@@ -39,43 +45,88 @@ export function markCurrent() {
|
|||||||
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The folder's last part: what tells one project from another at a glance. */
|
||||||
|
export function folderName(dir) {
|
||||||
|
const parts = String(dir || "").replace(/\/+$/, "").split("/")
|
||||||
|
return parts[parts.length - 1] || dir || ""
|
||||||
|
}
|
||||||
|
|
||||||
function row(c) {
|
function row(c) {
|
||||||
|
const agent = c.kind === "agent"
|
||||||
|
const elsewhere = agent && c.device && !ctx.thisDevices().includes(c.device.token_id)
|
||||||
|
const title = c.title || t("New chat")
|
||||||
const b = h(
|
const b = h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
|
{
|
||||||
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
type: "button",
|
||||||
h("span", { class: "row__title", text: c.title || t("New chat") }),
|
class: `chatrow${c.unread ? " is-unread" : ""}`,
|
||||||
h("span", { class: "row__when", text: when(c.updated_at) }),
|
dataset: { id: c.id, kind: c.kind },
|
||||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
title: agent && c.device?.directory ? `${title}\n${c.device.directory}${elsewhere ? ` · ${t("Another device")}` : ""}` : title,
|
||||||
|
},
|
||||||
|
h("span", { class: `chatrow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||||
|
h("span", { class: "chatrow__title", text: title }),
|
||||||
|
agent && c.device?.directory ? h("span", { class: "chatrow__where", text: folderName(c.device.directory) }) : h("span", { class: "chatrow__where" }),
|
||||||
|
h("span", { class: `chatrow__state${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
||||||
)
|
)
|
||||||
b.addEventListener("click", () => ctx.open(c.id))
|
b.addEventListener("click", () => ctx.open(c.id, c.kind))
|
||||||
|
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||||
|
b.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, c)
|
||||||
|
})
|
||||||
return h("li", null, b)
|
return h("li", null, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isToday(stamp) {
|
||||||
|
const d = new Date(stamp)
|
||||||
|
if (Number.isNaN(d.getTime())) return false
|
||||||
|
const now = new Date()
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const list = clear($("chat-list"))
|
const list = clear($("chat-list"))
|
||||||
const kind = ctx.kind()
|
const words = fold(query).split(/\s+/).filter(Boolean)
|
||||||
const visible = rows.filter((r) => !r.archived && !r.temporary)
|
const visible = rows
|
||||||
|
.filter((r) => !r.archived && !r.temporary)
|
||||||
|
.filter((r) => {
|
||||||
|
if (!words.length) return true
|
||||||
|
const text = fold(`${r.title || t("New chat")} ${r.device?.directory ? folderName(r.device.directory) : ""}`)
|
||||||
|
return words.every((w) => text.includes(w))
|
||||||
|
})
|
||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
list.append(
|
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||||
h("li", {
|
|
||||||
class: "list__note",
|
|
||||||
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (kind === "agent") {
|
const today = visible.filter((r) => isToday(r.updated_at))
|
||||||
const mine = ctx.thisDevices()
|
const earlier = visible.filter((r) => !isToday(r.updated_at))
|
||||||
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
|
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
||||||
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
|
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
||||||
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
|
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||||
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
|
|
||||||
} else append(list, visible.map(row))
|
|
||||||
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
|
||||||
markCurrent()
|
markCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chats as the list has them (the palette's and the sidebar's first look). */
|
||||||
|
export function listed() {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
export function initList(context) {
|
export function initList(context) {
|
||||||
ctx = context
|
ctx = context
|
||||||
|
$("side-search-icon").replaceChildren(icon(ICONS.search))
|
||||||
|
$("side-search").addEventListener("input", (e) => {
|
||||||
|
query = e.target.value
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
$("side-search").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && e.target.value) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.target.value = ""
|
||||||
|
query = ""
|
||||||
|
render()
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
// The first match opens.
|
||||||
|
$("chat-list").querySelector(".chatrow")?.click()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+177
-56
@@ -1,27 +1,37 @@
|
|||||||
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
|
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar (New
|
||||||
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
|
// chat, search, Scheduled, Files, the chats, Settings), and in the main area one of: a chat (a new
|
||||||
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
|
// one is the same page, empty), the files, the scheduled tasks, or the settings; the activity
|
||||||
// `api` (the server's /api/v2, as the signed-in account).
|
// panel and the viewer beside it. Everything it knows comes from Rust: `status` (the app, the
|
||||||
|
// agent, the server) and `api` (the server's /api/v2, as the signed-in account).
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, get, why } from "./bridge.js"
|
import { invoke, listen, get, why } from "./bridge.js"
|
||||||
import { renderBar, initBar } from "./titlebar.js"
|
import { renderBar, initBar } from "./titlebar.js"
|
||||||
import { renderSetup, initSetup } from "./setup.js"
|
import { renderSetup, initSetup } from "./setup.js"
|
||||||
import { renderSettings, initSettings } from "./settings.js"
|
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||||
import { initList, refreshList, markCurrent } from "./list.js"
|
import { initList, refreshList, markCurrent } from "./list.js"
|
||||||
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer } from "./chat.js"
|
||||||
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
|
import { initAgent, link } from "./agent.js"
|
||||||
|
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||||
|
import { closePopover } from "./popup.js"
|
||||||
|
import { noteSubagentsCap } from "./models.js"
|
||||||
|
|
||||||
let status = null
|
let status = null
|
||||||
let view = "" // "setup" | "app"
|
let view = "" // "setup" | "app"
|
||||||
|
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
||||||
let me = null
|
let me = null
|
||||||
let models = []
|
let models = []
|
||||||
let devices = [] // this account's linked devices
|
let devices = [] // this account's linked devices
|
||||||
let kind = "agent"
|
|
||||||
let started = false
|
let started = false
|
||||||
let currentChat = null
|
let currentChat = null
|
||||||
|
|
||||||
|
const SIDE_KEY = "lembas.side"
|
||||||
|
|
||||||
const remembered = (() => {
|
const remembered = (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
||||||
@@ -68,13 +78,17 @@ function route() {
|
|||||||
view = want
|
view = want
|
||||||
$("setup").hidden = want !== "setup"
|
$("setup").hidden = want !== "setup"
|
||||||
$("shell").hidden = want !== "app"
|
$("shell").hidden = want !== "app"
|
||||||
|
// The sidebar's button keeps its place in the title bar, unseen while there is no sidebar.
|
||||||
|
$("side-toggle").style.visibility = want === "app" ? "" : "hidden"
|
||||||
if (want === "app") startApp()
|
if (want === "app") startApp()
|
||||||
else stopApp()
|
else stopApp()
|
||||||
}
|
}
|
||||||
if (view === "setup") renderSetup(status)
|
if (view === "setup") renderSetup(status)
|
||||||
if (view === "app") {
|
if (view === "app") {
|
||||||
if (!$("settings").hidden) renderSettings(status)
|
if (page === "settings") renderSettings(status)
|
||||||
localPane()
|
localPane()
|
||||||
|
statusChanged()
|
||||||
|
accountButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +103,11 @@ async function startApp() {
|
|||||||
$("thread-note").hidden = false
|
$("thread-note").hidden = false
|
||||||
}
|
}
|
||||||
await Promise.all([loadModels(), loadDevices()])
|
await Promise.all([loadModels(), loadDevices()])
|
||||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
$("new-chat-btn").disabled = me?.permissions && me.permissions["chat.create"] === false
|
||||||
setKind(kind)
|
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||||
renderServer()
|
accountButton()
|
||||||
|
refreshList()
|
||||||
|
if (!openChatId()) startNew()
|
||||||
invoke("remote_refresh")
|
invoke("remote_refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +119,9 @@ function stopApp() {
|
|||||||
|
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
models = (await get("/models")).models || []
|
const r = await get("/models")
|
||||||
|
models = r.models || []
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
} catch {
|
} catch {
|
||||||
models = []
|
models = []
|
||||||
}
|
}
|
||||||
@@ -117,69 +135,124 @@ async function loadDevices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The sidebar --------------------------------------------------------------------------------
|
/** The sidebar's foot: Settings, with the account it is signed in as. */
|
||||||
|
function accountButton() {
|
||||||
function setKind(k) {
|
const who = status?.account?.name || me?.name || status?.account?.email || ""
|
||||||
kind = k
|
const initial = (who.trim()[0] || "?").toUpperCase()
|
||||||
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
|
$("settings-btn").replaceChildren(
|
||||||
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
|
h("span", { class: "navrow__avatar", "aria-hidden": "true", text: initial }),
|
||||||
clear($("chat-list"))
|
h("span", { class: "navrow__two" }, h("span", { class: "navrow__label", text: t("Settings") }), who ? h("span", { class: "navrow__sub", text: who }) : null),
|
||||||
refreshList()
|
h("span", { class: "navrow__icon" }, icon(ICONS.gear)),
|
||||||
// With no chat open, the main view is where a chat of this kind starts.
|
)
|
||||||
if (!openChatId() && $("settings").hidden) {
|
|
||||||
currentChat = null
|
|
||||||
newChat(k)
|
|
||||||
localPane()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
|
// --- The pages ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
||||||
|
function showPage(name) {
|
||||||
|
page = name
|
||||||
|
closePopover()
|
||||||
|
$("shell").dataset.view = name
|
||||||
|
$("main").hidden = name !== "chat"
|
||||||
|
$("page-files").hidden = name !== "files"
|
||||||
|
$("page-scheduled").hidden = name !== "scheduled"
|
||||||
|
$("settings").hidden = name !== "settings"
|
||||||
|
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||||
|
if (name !== "chat") showPanel(false)
|
||||||
|
if (narrow()) sideOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This computer's tree: the open agent chat's folder when it runs here, else the one chosen. */
|
||||||
function localPane(chat) {
|
function localPane(chat) {
|
||||||
const roots = status?.remote?.enabled ? status.remote.roots : []
|
const roots = status?.remote?.enabled ? status.remote.roots : []
|
||||||
const c = chat || currentChat
|
const c = chat || currentChat
|
||||||
const here = c?.device && thisDevices().includes(c.device.token_id)
|
const here = c?.device && thisDevices().includes(c.device.token_id)
|
||||||
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
|
showLocal({ roots, root: here && roots.includes(c.device.directory) ? c.device.directory : "", fixed: false })
|
||||||
else showLocal({ roots, root: "", fixed: false })
|
}
|
||||||
|
|
||||||
|
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
|
||||||
|
function startNew(kind) {
|
||||||
|
showPage("chat")
|
||||||
|
currentChat = null
|
||||||
|
newChat(kind || draftKind())
|
||||||
|
markCurrent()
|
||||||
|
localPane()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a chat, from the list or the palette. */
|
||||||
|
function showChat(id) {
|
||||||
|
showPage("chat")
|
||||||
|
openChat(id).then(markCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSettings(on, section) {
|
||||||
|
if (!on) {
|
||||||
|
showPage("chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showPage("settings")
|
||||||
|
renderSettings(status)
|
||||||
|
invoke("remote_refresh")
|
||||||
|
if (section) scrollToSection(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFiles(which) {
|
||||||
|
showPage("files")
|
||||||
|
if (which) showFilesTab(which)
|
||||||
|
invoke("remote_refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The palette's commands: what the menus and buttons do, by name. */
|
||||||
|
function commands() {
|
||||||
|
const out = [
|
||||||
|
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||||
|
{ label: t("New chat"), run: () => startNew("chat") },
|
||||||
|
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||||
|
...scheduledCommands(),
|
||||||
|
{ label: t("Files"), run: () => showFiles() },
|
||||||
|
{ label: t("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: link },
|
||||||
|
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||||
|
)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function narrow() {
|
function narrow() {
|
||||||
return window.matchMedia("(max-width: 48rem)").matches
|
return window.matchMedia("(max-width: 48rem)").matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sidebar: shown or folded away (remembered); a drawer over the page on a narrow window. */
|
||||||
function sideOpen(on) {
|
function sideOpen(on) {
|
||||||
$("shell").dataset.side = String(on)
|
$("shell").dataset.side = String(on)
|
||||||
$("side-toggle").setAttribute("aria-expanded", String(on))
|
$("side-toggle").setAttribute("aria-expanded", String(on))
|
||||||
}
|
if (!narrow()) {
|
||||||
|
try {
|
||||||
// --- Settings -----------------------------------------------------------------------------------
|
localStorage.setItem(SIDE_KEY, on ? "open" : "closed")
|
||||||
|
} catch {}
|
||||||
function showSettings(on) {
|
|
||||||
$("settings").hidden = !on
|
|
||||||
$("main").hidden = on
|
|
||||||
if (on) {
|
|
||||||
renderSettings(status)
|
|
||||||
invoke("remote_refresh")
|
|
||||||
$("settings-close").focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start --------------------------------------------------------------------------------------
|
// --- Start --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
initBar()
|
initAgent()
|
||||||
|
initBar({ status: () => status, openSettings: (section) => showSettings(true, section) })
|
||||||
initViewer()
|
initViewer()
|
||||||
initExplorer()
|
initExplorer()
|
||||||
initSetup(() => status)
|
initSetup(() => status)
|
||||||
initSettings(() => status)
|
initSettings(() => status)
|
||||||
initList({
|
initList({
|
||||||
kind: () => kind,
|
|
||||||
thisDevices,
|
thisDevices,
|
||||||
current: () => openChatId(),
|
current: () => openChatId(),
|
||||||
open: (id) => {
|
open: (id) => {
|
||||||
showSettings(false)
|
|
||||||
if (narrow()) sideOpen(false)
|
if (narrow()) sideOpen(false)
|
||||||
openChat(id).then(markCurrent)
|
showChat(id)
|
||||||
},
|
},
|
||||||
|
menu: (anchor, c) => chatMenu(anchor, c),
|
||||||
})
|
})
|
||||||
initChat({
|
initChat({
|
||||||
status: () => status,
|
status: () => status,
|
||||||
@@ -192,32 +265,80 @@ async function boot() {
|
|||||||
localPane(chat)
|
localPane(chat)
|
||||||
markCurrent()
|
markCurrent()
|
||||||
},
|
},
|
||||||
|
// Archived or deleted: the list without it, and a new chat where it was open.
|
||||||
|
gone: (chat) => {
|
||||||
|
if (!openChatId() || openChatId() === chat.id) {
|
||||||
|
currentChat = null
|
||||||
|
newChat()
|
||||||
|
localPane()
|
||||||
|
}
|
||||||
|
refreshList()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||||
|
initPalette({ commands, open: (c) => showChat(c.id) })
|
||||||
|
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||||
onViewer((on) => {
|
onViewer((on) => {
|
||||||
$("shell").dataset.viewer = String(on)
|
$("shell").dataset.viewer = String(on)
|
||||||
})
|
})
|
||||||
|
initActivity({
|
||||||
|
onLayout: (on) => {
|
||||||
|
$("shell").dataset.activity = String(on)
|
||||||
|
},
|
||||||
|
here: onThisComputer,
|
||||||
|
// The conversation as it now stands: the summary's divider, read again, if it is still open.
|
||||||
|
compacted: (id) => openChatId() === id && openChat(id),
|
||||||
|
})
|
||||||
|
|
||||||
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").prepend(icon(ICONS.plus))
|
||||||
$("new-chat-btn").addEventListener("click", () => {
|
$("new-chat-btn").addEventListener("click", () => {
|
||||||
showSettings(false)
|
|
||||||
currentChat = null
|
|
||||||
if (narrow()) sideOpen(false)
|
if (narrow()) sideOpen(false)
|
||||||
newChat(kind)
|
startNew()
|
||||||
markCurrent()
|
|
||||||
localPane()
|
|
||||||
})
|
})
|
||||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
|
||||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
$("nav-scheduled").addEventListener("click", () => {
|
||||||
|
showPage("scheduled")
|
||||||
|
showScheduled()
|
||||||
|
})
|
||||||
|
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||||
|
$("nav-files").addEventListener("click", () => showFiles())
|
||||||
|
accountButton()
|
||||||
|
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||||
$("settings-close").addEventListener("click", () => showSettings(false))
|
$("settings-close").addEventListener("click", () => showSettings(false))
|
||||||
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
||||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||||
|
// A wide window starts with the sidebar as it was left; a narrow one with it closed.
|
||||||
|
let saved = "open"
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(SIDE_KEY) || "open"
|
||||||
|
} catch {}
|
||||||
|
sideOpen(!narrow() && saved !== "closed")
|
||||||
|
window.matchMedia("(max-width: 48rem)").addEventListener("change", (e) => {
|
||||||
|
if (e.matches) sideOpen(false)
|
||||||
|
})
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
if (e.key === "Escape" && narrow() && $("shell").dataset.side === "true") sideOpen(false)
|
||||||
|
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||||
|
const mod = e.ctrlKey || e.metaKey
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
// Ctrl+K: the palette. Ctrl+N: a new chat. Ctrl+,: the settings. Ctrl+B: the sidebar.
|
||||||
|
if (mod && !e.shiftKey && !e.altKey && key === "k") {
|
||||||
|
e.preventDefault()
|
||||||
|
openPalette()
|
||||||
|
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
|
||||||
|
e.preventDefault()
|
||||||
|
startNew()
|
||||||
|
} else if (mod && e.key === ",") {
|
||||||
|
e.preventDefault()
|
||||||
|
showSettings(true)
|
||||||
|
} else if (mod && !e.shiftKey && !e.altKey && key === "b") {
|
||||||
|
e.preventDefault()
|
||||||
|
sideOpen($("shell").dataset.side !== "true")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
|
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed."), scheduled_pause: t("A scheduled task failed 3 times in a row. Pause it?") } })
|
||||||
listen("status", (e) => {
|
listen("status", (e) => {
|
||||||
const before = status
|
const before = status
|
||||||
status = e.payload
|
status = e.payload
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
// Models as the person picks them: each with its avatar. A model with a picture (`image` in
|
||||||
|
// /api/v2/models) shows it, read through the bridge with the app's token and handed to the page
|
||||||
|
// as a `data:` URL; until it arrives, and for a model without one, the avatar is what the
|
||||||
|
// server's web interface draws then -- its initial on a colour of its own, the hue worked out
|
||||||
|
// from the model's id exactly as the server does it (`stable_hue`: the first two bytes of the
|
||||||
|
// id's MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
|
||||||
|
// lightness are the theme's (app.css), only the hue is the model's.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke } from "./bridge.js"
|
||||||
|
import { menu } from "./popup.js"
|
||||||
|
|
||||||
|
// Each picture once per run: its path -> a promise of its `data:` URL (null when it cannot be had,
|
||||||
|
// so a missing picture is not asked for again with every redraw).
|
||||||
|
const pictures = new Map()
|
||||||
|
|
||||||
|
export function picture(path) {
|
||||||
|
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
||||||
|
return pictures.get(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
|
||||||
|
function md5(text) {
|
||||||
|
const bytes = new TextEncoder().encode(text)
|
||||||
|
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
|
||||||
|
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
|
||||||
|
const len = bytes.length
|
||||||
|
const words = (((len + 8) >>> 6) + 1) * 16
|
||||||
|
const w = new Uint32Array(words)
|
||||||
|
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
|
||||||
|
w[len >> 2] |= 0x80 << ((len % 4) * 8)
|
||||||
|
w[words - 2] = (len * 8) >>> 0
|
||||||
|
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
|
||||||
|
let a0 = 0x67452301
|
||||||
|
let b0 = 0xefcdab89
|
||||||
|
let c0 = 0x98badcfe
|
||||||
|
let d0 = 0x10325476
|
||||||
|
for (let off = 0; off < words; off += 16) {
|
||||||
|
let [A, B, C, D] = [a0, b0, c0, d0]
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
let F
|
||||||
|
let g
|
||||||
|
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
|
||||||
|
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
|
||||||
|
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
|
||||||
|
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
|
||||||
|
F = (F + A + K[i] + w[off + g]) >>> 0
|
||||||
|
const s = S[(i >> 4) * 4 + (i % 4)]
|
||||||
|
A = D
|
||||||
|
D = C
|
||||||
|
C = B
|
||||||
|
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
|
||||||
|
}
|
||||||
|
a0 = (a0 + A) >>> 0
|
||||||
|
b0 = (b0 + B) >>> 0
|
||||||
|
c0 = (c0 + C) >>> 0
|
||||||
|
d0 = (d0 + D) >>> 0
|
||||||
|
}
|
||||||
|
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
|
||||||
|
export function stableHue(id) {
|
||||||
|
const d = md5(String(id))
|
||||||
|
return ((d[0] << 8) | d[1]) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model's avatar: its picture, or its initial on its own colour while there is none. */
|
||||||
|
export function avatar(model) {
|
||||||
|
const label = String(model?.name || model?.id || "?").trim() || "?"
|
||||||
|
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
|
||||||
|
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
|
||||||
|
if (typeof model?.image === "string" && model.image) {
|
||||||
|
picture(model.image).then((url) => {
|
||||||
|
if (!url || !url.startsWith("data:image/")) return
|
||||||
|
el.classList.add("avatar--image")
|
||||||
|
el.replaceChildren(h("img", { src: url, alt: "", draggable: "false" }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
|
||||||
|
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
|
||||||
|
*/
|
||||||
|
export function modelPicker({ id, models, value, onChange, label }) {
|
||||||
|
let current = value
|
||||||
|
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
|
||||||
|
const draw = () => {
|
||||||
|
const m = models.find((x) => x.id === current)
|
||||||
|
btn.replaceChildren(
|
||||||
|
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
|
||||||
|
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
|
||||||
|
icon(ICONS.chevronDown),
|
||||||
|
)
|
||||||
|
btn.title = m ? `${m.name} (${m.id})` : current || ""
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
menu(
|
||||||
|
btn,
|
||||||
|
models.map((m) => ({
|
||||||
|
label: m.name,
|
||||||
|
lead: avatar(m),
|
||||||
|
checked: m.id === current,
|
||||||
|
run: () => {
|
||||||
|
if (m.id === current) return
|
||||||
|
current = m.id
|
||||||
|
draw()
|
||||||
|
onChange?.(m.id)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Object.defineProperty(btn, "value", {
|
||||||
|
get: () => current,
|
||||||
|
set: (v) => {
|
||||||
|
current = v
|
||||||
|
draw()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
draw()
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- How hard a model thinks, and how many subagents a chat may have --------------------------------
|
||||||
|
//
|
||||||
|
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||||
|
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||||
|
// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws
|
||||||
|
// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or
|
||||||
|
// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`.
|
||||||
|
|
||||||
|
/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */
|
||||||
|
export function levelsOf(model) {
|
||||||
|
const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null
|
||||||
|
const levels = given || (model?.efforts?.length ? model.efforts : ["default"])
|
||||||
|
const control =
|
||||||
|
["none", "switch", "slider"].includes(model?.control) ? model.control
|
||||||
|
: levels.length < 2 ? "none"
|
||||||
|
: levels.length === 2 && levels.includes("off") ? "switch"
|
||||||
|
: "slider"
|
||||||
|
const start = levels.includes(model?.effort) ? model.effort : levels[0]
|
||||||
|
return { levels, start, control }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */
|
||||||
|
export function levelWord(level) {
|
||||||
|
return level === "off" ? t("off") : level === "default" ? t("default") : String(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control for a model's levels, or null where it has one level: the Thinking switch (a real
|
||||||
|
* checkbox with the switch role, the label its tap target) or the selector. `value` is the level
|
||||||
|
* in force; `onChange(level)` when the person changes it. The element's `value` is the level.
|
||||||
|
*/
|
||||||
|
export function effortControl({ model, value, onChange, id, compact }) {
|
||||||
|
const { levels, start, control } = levelsOf(model)
|
||||||
|
if (control === "none") return null
|
||||||
|
let current = levels.includes(value) ? value : start
|
||||||
|
if (control === "switch") {
|
||||||
|
const on = levels.find((l) => l !== "off") || start
|
||||||
|
const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" })
|
||||||
|
const el = h(
|
||||||
|
"label",
|
||||||
|
{ class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") },
|
||||||
|
box,
|
||||||
|
h("span", { class: "toggle__label", text: t("Think before answering") }),
|
||||||
|
)
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
current = box.checked ? on : "off"
|
||||||
|
onChange?.(current)
|
||||||
|
})
|
||||||
|
Object.defineProperty(el, "value", {
|
||||||
|
get: () => current,
|
||||||
|
set: (v) => {
|
||||||
|
current = v
|
||||||
|
box.checked = v !== "off"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
const el = h(
|
||||||
|
"select",
|
||||||
|
{ id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") },
|
||||||
|
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })),
|
||||||
|
)
|
||||||
|
el.addEventListener("change", () => {
|
||||||
|
current = el.value
|
||||||
|
onChange?.(current)
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a new chat on `model` is offered subagents: the model's `subagents.offered` in `/models`
|
||||||
|
* (server 2.0.0-beta.7: subagents on for the model and the instance, and at least one model could
|
||||||
|
* serve as one -- the model itself with room for two requests at once, or a usable model outside
|
||||||
|
* its one-model-at-a-time server). A server that does not say it: a model that calls tools and
|
||||||
|
* serves two requests at once (`parallel` null is a hosted API with no stated limit). The server
|
||||||
|
* decides again; this only keeps the chip away where it could never apply. An open chat has its
|
||||||
|
* own `subagents` (chat.js).
|
||||||
|
*/
|
||||||
|
export function teamPossible(model) {
|
||||||
|
const said = model?.subagents
|
||||||
|
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
|
||||||
|
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether only a model the person names in the chat could serve its subagents (`named_only`). */
|
||||||
|
export function namedOnly(said) {
|
||||||
|
return !!(said && typeof said === "object" && said.named_only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instance's most, as `/models` said it last (`subagents_cap`); 32 where it says nothing.
|
||||||
|
export const SUBAGENTS_CAP = 32
|
||||||
|
let knownCap = null
|
||||||
|
export function noteSubagentsCap(n) {
|
||||||
|
knownCap = Number(n) >= 1 ? Number(n) : null
|
||||||
|
}
|
||||||
|
/** The most subagents a chat may have: the first of `sources` that says it (`cap`), else the instance's. */
|
||||||
|
export function subagentsCap(...sources) {
|
||||||
|
for (const s of sources) {
|
||||||
|
const n = Number(s?.cap)
|
||||||
|
if (Number.isFinite(n) && n >= 1) return n
|
||||||
|
}
|
||||||
|
return knownCap || SUBAGENTS_CAP
|
||||||
|
}
|
||||||
|
// The numbers count the chat's own agent: 1 is the agent alone.
|
||||||
|
export const SUBAGENTS_HINT = () => t("The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.")
|
||||||
|
// The spec's `subagents.setting.own`: beside the setting where the chat's model cannot serve them.
|
||||||
|
export const SUBAGENTS_OWN = () => t("This model serves one request at a time: subagents need another model, on another server, named in the chat.")
|
||||||
|
|
||||||
|
// A new chat's setting as the harness spec seeds it (`loop.json` agents): 1 to 4.
|
||||||
|
export const AGENTS_DEFAULT = { min: 1, max: 4 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agents a chat may run, its own agent counted: one range from 1 to `cap` with two handles,
|
||||||
|
* Min and Max -- two range inputs overlaid on one track, the span between them filled. Each is a
|
||||||
|
* real slider (arrow keys, Home/End, Page Up/Down), and Min never passes Max. `value` is
|
||||||
|
* { min, max } (empty for a new chat: the spec's defaults are shown, and left to the server
|
||||||
|
* unless a handle moves); `el.value` the pair. The server checks it again.
|
||||||
|
*/
|
||||||
|
export function agentsControl({ value, onChange, id, cap = SUBAGENTS_CAP }) {
|
||||||
|
const top = Math.max(1, Number(cap) || SUBAGENTS_CAP)
|
||||||
|
const clamp = (n, d) => Math.min(top, Math.max(1, Number.isFinite(Number(n)) && n !== null && n !== "" ? Number(n) : d))
|
||||||
|
let given = value || {}
|
||||||
|
let touched = false
|
||||||
|
const handle = (which, label) =>
|
||||||
|
h("input", {
|
||||||
|
type: "range",
|
||||||
|
class: `range2__input range2__input--${which}`,
|
||||||
|
id: which === "min" ? id : null,
|
||||||
|
min: "1",
|
||||||
|
max: String(top),
|
||||||
|
step: "1",
|
||||||
|
"aria-label": label,
|
||||||
|
title: label,
|
||||||
|
})
|
||||||
|
const low = handle("min", t("Min"))
|
||||||
|
const high = handle("max", t("Max"))
|
||||||
|
const said = h("output", { class: "range2__said", "aria-live": "polite", for: id || null })
|
||||||
|
const fill = h("span", { class: "range2__fill", "aria-hidden": "true" })
|
||||||
|
const box = h("span", { class: "range2__box" }, h("span", { class: "range2__track", "aria-hidden": "true" }, fill), low, high)
|
||||||
|
const draw = () => {
|
||||||
|
const lo = Number(low.value)
|
||||||
|
const hi = Number(high.value)
|
||||||
|
const at = (n) => (top > 1 ? ((n - 1) / (top - 1)) * 100 : 0)
|
||||||
|
box.style.setProperty("--lo", `${at(lo)}%`)
|
||||||
|
box.style.setProperty("--hi", `${at(hi)}%`)
|
||||||
|
// Where the two meet, the one that can still move is on top: Min at the far end, else Max.
|
||||||
|
low.classList.toggle("is-top", lo === hi && hi === top)
|
||||||
|
low.setAttribute("aria-valuetext", `${t("Min")} ${lo}`)
|
||||||
|
high.setAttribute("aria-valuetext", `${t("Max")} ${hi}`)
|
||||||
|
said.textContent = `${t("Min")} ${lo} · ${t("Max")} ${hi}`
|
||||||
|
}
|
||||||
|
const set = (v) => {
|
||||||
|
const hi = clamp(v?.max, Math.min(AGENTS_DEFAULT.max, top))
|
||||||
|
const lo = Math.min(hi, clamp(v?.min, AGENTS_DEFAULT.min))
|
||||||
|
high.value = String(hi)
|
||||||
|
low.value = String(lo)
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
low.addEventListener("input", () => {
|
||||||
|
if (Number(low.value) > Number(high.value)) low.value = high.value
|
||||||
|
touched = true
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
high.addEventListener("input", () => {
|
||||||
|
if (Number(high.value) < Number(low.value)) high.value = low.value
|
||||||
|
touched = true
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
for (const input of [low, high]) input.addEventListener("change", () => onChange?.(read()))
|
||||||
|
const read = () => (touched || given.min || given.max ? { min: Number(low.value), max: Number(high.value) } : {})
|
||||||
|
set(given)
|
||||||
|
const el = h(
|
||||||
|
"span",
|
||||||
|
{ class: "range2", role: "group", "aria-label": t("Subagents") },
|
||||||
|
said,
|
||||||
|
box,
|
||||||
|
h("span", { class: "range2__ends", "aria-hidden": "true" }, h("span", { text: "1" }), h("span", { text: String(top) })),
|
||||||
|
)
|
||||||
|
Object.defineProperty(el, "value", {
|
||||||
|
get: read,
|
||||||
|
set: (v) => {
|
||||||
|
given = v || {}
|
||||||
|
touched = false
|
||||||
|
set(given)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
|
||||||
|
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
|
||||||
|
// click outside close them; the focus goes back where it came from.
|
||||||
|
|
||||||
|
import { h } from "./dom.js"
|
||||||
|
|
||||||
|
let openMenu = null
|
||||||
|
|
||||||
|
/** Close the menu that is open, if any. */
|
||||||
|
export function closeMenu() {
|
||||||
|
if (!openMenu) return
|
||||||
|
const { el, back } = openMenu
|
||||||
|
openMenu = null
|
||||||
|
el.remove()
|
||||||
|
document.removeEventListener("pointerdown", outside, true)
|
||||||
|
back?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outside(e) {
|
||||||
|
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A menu of `items` ({ label, hint?, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||||
|
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||||
|
*/
|
||||||
|
export function menu(anchor, items) {
|
||||||
|
closeMenu()
|
||||||
|
closePopover()
|
||||||
|
const plain = !items.some((i) => i?.lead)
|
||||||
|
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item) {
|
||||||
|
el.append(h("div", { class: "menu__sep", role: "separator" }))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
|
||||||
|
// labels line up whether or not an item has a lead.
|
||||||
|
el.append(
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
role: item.checked === undefined ? "menuitem" : "menuitemradio",
|
||||||
|
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
|
||||||
|
class: `menu__item${item.danger ? " is-danger" : ""}`,
|
||||||
|
disabled: !!item.disabled,
|
||||||
|
onclick: () => {
|
||||||
|
closeMenu()
|
||||||
|
item.run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("span", { class: "menu__lead" }, item.lead || null),
|
||||||
|
item.hint
|
||||||
|
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
|
||||||
|
: h("span", { class: "menu__label", text: item.label }),
|
||||||
|
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
el.addEventListener("keydown", (e) => {
|
||||||
|
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
|
||||||
|
const i = all.indexOf(document.activeElement)
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
closeMenu()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
all[(i + 1) % all.length]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
all[(i - 1 + all.length) % all.length]?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.body.append(el)
|
||||||
|
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
|
||||||
|
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
||||||
|
el.style.width = `${Math.ceil(r.width)}px`
|
||||||
|
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||||
|
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
|
||||||
|
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
|
||||||
|
el.style.top = above ? `${at.top - r.height}px` : `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
||||||
|
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||||
|
document.addEventListener("pointerdown", outside, true)
|
||||||
|
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
|
||||||
|
* buttons. Resolves with the line (or true) on OK, null on cancel.
|
||||||
|
*/
|
||||||
|
export function ask({ title, text, value, ok, cancel, danger }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
|
||||||
|
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{ class: "dialog__body", method: "dialog" },
|
||||||
|
h("h2", { class: "dialog__title", text: title }),
|
||||||
|
text ? h("p", { class: "muted", text }) : null,
|
||||||
|
input,
|
||||||
|
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
|
||||||
|
)
|
||||||
|
const dlg = h("dialog", { class: "dialog" }, form)
|
||||||
|
let settled = false
|
||||||
|
const done = (v) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
dlg.close()
|
||||||
|
dlg.remove()
|
||||||
|
resolve(v)
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (input && !input.value.trim()) return input.focus()
|
||||||
|
done(input ? input.value.trim() : true)
|
||||||
|
})
|
||||||
|
dlg.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
done(null)
|
||||||
|
})
|
||||||
|
document.body.append(dlg)
|
||||||
|
dlg.showModal()
|
||||||
|
if (input) {
|
||||||
|
input.focus()
|
||||||
|
input.select()
|
||||||
|
} else okBtn.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
|
||||||
|
* it is and selectable), and Copy beside Close. Resolves when it is closed.
|
||||||
|
*/
|
||||||
|
export function showLine({ title, text, code, copy, copied, close }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
|
||||||
|
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
|
||||||
|
const closeBtn = h("button", { type: "button", class: "btn", text: close })
|
||||||
|
const dlg = h(
|
||||||
|
"dialog",
|
||||||
|
{ class: "dialog" },
|
||||||
|
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
|
||||||
|
)
|
||||||
|
const done = () => {
|
||||||
|
dlg.close()
|
||||||
|
dlg.remove()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
copyBtn.addEventListener("click", () => {
|
||||||
|
navigator.clipboard
|
||||||
|
?.writeText(code)
|
||||||
|
.then(() => (copyBtn.textContent = copied))
|
||||||
|
.catch(() => {
|
||||||
|
// No clipboard: the line is selected, for Ctrl+C.
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(line)
|
||||||
|
getSelection()?.removeAllRanges()
|
||||||
|
getSelection()?.addRange(range)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
closeBtn.addEventListener("click", done)
|
||||||
|
dlg.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
document.body.append(dlg)
|
||||||
|
dlg.showModal()
|
||||||
|
copyBtn.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let openPop = null
|
||||||
|
|
||||||
|
/** Close the popover that is open, if any. */
|
||||||
|
export function closePopover() {
|
||||||
|
if (!openPop) return
|
||||||
|
const { el, back, onClose } = openPop
|
||||||
|
openPop = null
|
||||||
|
el.remove()
|
||||||
|
document.removeEventListener("pointerdown", outsidePop, true)
|
||||||
|
document.removeEventListener("keydown", escPop, true)
|
||||||
|
onClose?.()
|
||||||
|
back?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outsidePop(e) {
|
||||||
|
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
function escPop(e) {
|
||||||
|
if (e.key === "Escape" && openPop) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
|
||||||
|
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
||||||
|
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
||||||
|
*/
|
||||||
|
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
|
||||||
|
closePopover()
|
||||||
|
closeMenu()
|
||||||
|
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
|
||||||
|
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
|
||||||
|
draw()
|
||||||
|
document.body.append(el)
|
||||||
|
const place = () => {
|
||||||
|
const at = anchor.getBoundingClientRect()
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const left = align === "end" ? at.right - r.width : at.left
|
||||||
|
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
||||||
|
const top = up ? at.top - r.height - 6 : at.bottom + 6
|
||||||
|
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||||
|
}
|
||||||
|
place()
|
||||||
|
openPop = { el, back: anchor, onClose }
|
||||||
|
document.addEventListener("pointerdown", outsidePop, true)
|
||||||
|
document.addEventListener("keydown", escPop, true)
|
||||||
|
el.querySelector("button, input, select")?.focus()
|
||||||
|
return {
|
||||||
|
redraw() {
|
||||||
|
if (openPop?.el !== el) return
|
||||||
|
draw()
|
||||||
|
place()
|
||||||
|
},
|
||||||
|
close: closePopover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popoverOpen() {
|
||||||
|
return !!openPop
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
|
||||||
|
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
|
||||||
|
// task's page (what it does, when, its settings, the next runs, its history with each run's
|
||||||
|
// result, Run now, edit, pause, delete), and a task said in words: the server reads them into a
|
||||||
|
// rule and an instruction (compile), the next runs are shown, the settings chosen, and only then
|
||||||
|
// is it saved. The server runs them; this page only reads and asks.
|
||||||
|
//
|
||||||
|
// The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract
|
||||||
|
// fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`):
|
||||||
|
//
|
||||||
|
// GET /schedules { schedules: [schedule] }, newest first
|
||||||
|
// POST /schedules { title, instruction, request, rule, model, notify,
|
||||||
|
// mode, effort, agents } -> 201 { schedule }
|
||||||
|
// GET /schedules/{id} { schedule }
|
||||||
|
// PATCH /schedules/{id} any of the create fields -> { schedule }
|
||||||
|
// DELETE /schedules/{id}?keep_chat=true 204
|
||||||
|
// POST /schedules/{id}/pause | /resume { schedule }
|
||||||
|
// POST /schedules/{id}/run 202 { run }: now, not counted against the rule
|
||||||
|
// GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first
|
||||||
|
// GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted)
|
||||||
|
// POST /schedules/compile { text } -> { ok, reason, title, instruction,
|
||||||
|
// target, rule, description, next_runs, timezone }
|
||||||
|
// POST /schedules/preview { rule } -> { rule, description, next_runs, timezone }
|
||||||
|
// GET /schedules/models { models: [model + free, busy, chosen], instance, mine }
|
||||||
|
// PUT /schedules/models { models: [id] }: the person's narrowing
|
||||||
|
// GET /events `schedules` frames, { version, schedules }: followed
|
||||||
|
// by the app (Rust, watch.rs), handed here as the
|
||||||
|
// `schedules` event
|
||||||
|
//
|
||||||
|
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
||||||
|
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
||||||
|
// model's words, set as text; a result's Markdown goes through markdown.js.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { api, get, post, patch, del, why, listen } from "./bridge.js"
|
||||||
|
import { ask, menu, popover } from "./popup.js"
|
||||||
|
import { avatar, levelsOf, levelWord } from "./models.js"
|
||||||
|
import { markdown } from "./markdown.js"
|
||||||
|
|
||||||
|
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
||||||
|
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
||||||
|
let tasks = []
|
||||||
|
let missing = false // the server has no scheduling routes
|
||||||
|
let shown = null // the task whose page is open: { id, last, running }
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ok: () => t("Done"),
|
||||||
|
silent: () => t("Nothing to report"),
|
||||||
|
failed: () => t("Failed"),
|
||||||
|
missed: () => t("Missed"),
|
||||||
|
skipped: () => t("Skipped"),
|
||||||
|
waiting: () => t("Waiting for a free model"),
|
||||||
|
running: () => t("Running"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFY = {
|
||||||
|
matters: () => t("When it matters"),
|
||||||
|
always: () => t("Always"),
|
||||||
|
never: () => t("Never"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFY_HINT = {
|
||||||
|
matters: () => t("A result, but not a run with nothing to report."),
|
||||||
|
always: () => t("Every result, even with nothing to report."),
|
||||||
|
never: () => t("Only in its history."),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE = {
|
||||||
|
fresh: () => t("A new chat for each run"),
|
||||||
|
continue: () => t("One chat that grows"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMING = {
|
||||||
|
late: () => t("late"),
|
||||||
|
catch_up: () => t("caught up"),
|
||||||
|
manual: () => t("run by hand"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
const RUNS_PAGE = 20
|
||||||
|
|
||||||
|
function statusChip(run, running) {
|
||||||
|
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
||||||
|
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||||
|
const word = STATUS[run.status]?.() || run.status
|
||||||
|
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
||||||
|
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
||||||
|
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
|
||||||
|
function stamp(value) {
|
||||||
|
const d = new Date(value)
|
||||||
|
if (!value || Number.isNaN(d.getTime())) return ""
|
||||||
|
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextWords(task) {
|
||||||
|
if (!task.enabled) return t("Paused")
|
||||||
|
if (!task.next_run) return t("No more runs")
|
||||||
|
return `${when(task.next_run)} · ${stamp(task.next_run)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPage() {
|
||||||
|
return !$("page-scheduled").hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
tasks = (await get("/schedules")).schedules || []
|
||||||
|
missing = false
|
||||||
|
} catch (err) {
|
||||||
|
tasks = []
|
||||||
|
missing = err?.status === 404 || err?.status === 405
|
||||||
|
if (!missing) throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scheduling models, in the order they are tried; null when the server has none to say. */
|
||||||
|
async function schedulingModels() {
|
||||||
|
try {
|
||||||
|
return await get("/schedules/models")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the page: the list, or one task's page (`id`). */
|
||||||
|
export async function showScheduled(id) {
|
||||||
|
view = id ? { name: "task", id } : { name: "list" }
|
||||||
|
await draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
clear($("scheduled-tools"))
|
||||||
|
clear($("scheduled-place"))
|
||||||
|
shown = null
|
||||||
|
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
try {
|
||||||
|
if (view.name === "task") await drawTask(view.id)
|
||||||
|
else if (view.name === "form") await drawForm(view.task)
|
||||||
|
else {
|
||||||
|
await load()
|
||||||
|
drawList()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clear(body).append(h("p", { class: "status is-error", text: why(err) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page's title; with a way back, "Scheduled ›" before it. */
|
||||||
|
function header(title, back) {
|
||||||
|
const h1 = $("scheduled-title")
|
||||||
|
if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title }))
|
||||||
|
else h1.textContent = title
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(next) {
|
||||||
|
view = next
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The list -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function drawList() {
|
||||||
|
header(t("Scheduled"))
|
||||||
|
const tools = clear($("scheduled-tools"))
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
if (missing) {
|
||||||
|
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tools.append(
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })),
|
||||||
|
)
|
||||||
|
if (!tasks.length) {
|
||||||
|
body.append(
|
||||||
|
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
|
||||||
|
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = h("ul", { class: "tasks-list", role: "list" })
|
||||||
|
for (const task of tasks) list.append(taskRow(task))
|
||||||
|
body.append(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(title, text) {
|
||||||
|
return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One task: icon · title and its rule in words · next run · last status · pause switch. */
|
||||||
|
function taskRow(task) {
|
||||||
|
const toggle = h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
role: "switch",
|
||||||
|
checked: !!task.enabled,
|
||||||
|
"aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }),
|
||||||
|
title: task.enabled ? t("Pause") : t("Resume"),
|
||||||
|
onclick: (e) => e.stopPropagation(),
|
||||||
|
onchange: (e) => setEnabled(task, e.target.checked),
|
||||||
|
})
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } },
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
|
||||||
|
h("span", { class: "task__icon" }, icon(ICONS.clock)),
|
||||||
|
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" },
|
||||||
|
h("span", { class: "task__label", text: t("Next run") }),
|
||||||
|
h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "task__last" }, statusChip(task.last_run, task.running)),
|
||||||
|
),
|
||||||
|
h("label", { class: "toggle task__toggle" }, toggle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause or resume: the server's own verbs, which answer the schedule as it now is. */
|
||||||
|
async function setEnabled(task, on) {
|
||||||
|
try {
|
||||||
|
const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`)
|
||||||
|
Object.assign(task, r.schedule || { enabled: on })
|
||||||
|
} catch (err) {
|
||||||
|
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
if (view.name === "list") drawList()
|
||||||
|
else draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheduling models: the ones scheduled work may run on, in the order they are tried, each
|
||||||
|
* free now or busy (and why). A task without a model of its own runs on the first free one of
|
||||||
|
* those ticked; ticking none means all of them.
|
||||||
|
*/
|
||||||
|
async function modelsPanel(anchor) {
|
||||||
|
const data = await schedulingModels()
|
||||||
|
if (!data) return
|
||||||
|
let mine = new Set(data.mine || [])
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) })
|
||||||
|
Object.assign(data, r)
|
||||||
|
mine = new Set(r.mine || [])
|
||||||
|
said.hidden = true
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
}
|
||||||
|
pop.redraw()
|
||||||
|
}
|
||||||
|
const content = () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "pop__body smodels" },
|
||||||
|
h("p", { class: "pop__title", text: t("Models for scheduled tasks") }),
|
||||||
|
h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }),
|
||||||
|
h(
|
||||||
|
"ul",
|
||||||
|
{ class: "smodels__list", role: "list" },
|
||||||
|
data.models.map((m) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
"label",
|
||||||
|
{ class: "smodel" },
|
||||||
|
h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: mine.has(m.id),
|
||||||
|
onchange: (e) => {
|
||||||
|
if (e.target.checked) mine.add(m.id)
|
||||||
|
else mine.delete(m.id)
|
||||||
|
save()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
avatar(m),
|
||||||
|
h("span", { class: "smodel__name", text: m.name }),
|
||||||
|
h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }),
|
||||||
|
said,
|
||||||
|
)
|
||||||
|
const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- One task -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function drawTask(id) {
|
||||||
|
const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)])
|
||||||
|
const task = r.schedule
|
||||||
|
shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running }
|
||||||
|
header(task.title || t("Scheduled"), () => go({ name: "list" }))
|
||||||
|
const tools = clear($("scheduled-tools"))
|
||||||
|
tools.append(
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
"aria-label": t("More"),
|
||||||
|
title: t("More"),
|
||||||
|
onclick: (e) =>
|
||||||
|
menu(e.currentTarget, [
|
||||||
|
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
|
||||||
|
...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []),
|
||||||
|
null,
|
||||||
|
{ label: t("Delete"), danger: true, run: () => remove(task) },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
icon(ICONS.more),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const models = (await schedulingModels())?.models || ctx.models()
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
body.append(
|
||||||
|
h(
|
||||||
|
"section",
|
||||||
|
{ class: "sched" },
|
||||||
|
task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__facts" },
|
||||||
|
fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""),
|
||||||
|
fact(t("Next run"), h("span", { text: nextWords(task) })),
|
||||||
|
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
||||||
|
fact(t("Model"), modelWords(models, task.model)),
|
||||||
|
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
||||||
|
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||||
|
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||||
|
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||||
|
),
|
||||||
|
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
||||||
|
upcoming(task.next_runs || []),
|
||||||
|
),
|
||||||
|
history(task, page.runs || [], page.more),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value, hint) {
|
||||||
|
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelWords(models, id) {
|
||||||
|
if (!id) return h("span", { text: t("The first free scheduling model") })
|
||||||
|
const m = models.find((x) => x.id === id)
|
||||||
|
return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function upcoming(next) {
|
||||||
|
if (!next.length) return null
|
||||||
|
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */
|
||||||
|
function history(task, runs, more) {
|
||||||
|
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
|
||||||
|
if (!runs.length) {
|
||||||
|
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
const list = h("ul", { class: "runs" })
|
||||||
|
const add = (page) => {
|
||||||
|
for (const run of page) list.append(runRow(task, run))
|
||||||
|
}
|
||||||
|
add(runs)
|
||||||
|
box.append(list)
|
||||||
|
if (more) {
|
||||||
|
const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") })
|
||||||
|
earlier.addEventListener("click", async () => {
|
||||||
|
earlier.disabled = true
|
||||||
|
try {
|
||||||
|
const last = list.lastElementChild?.dataset.id || ""
|
||||||
|
const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`)
|
||||||
|
add(r.runs || [])
|
||||||
|
if (!r.more) earlier.remove()
|
||||||
|
else earlier.disabled = false
|
||||||
|
} catch (err) {
|
||||||
|
earlier.disabled = false
|
||||||
|
await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
box.append(earlier)
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a run that did not finish well says: waiting and until when, or its error. */
|
||||||
|
function runNote(run) {
|
||||||
|
if (run.status === "waiting") {
|
||||||
|
const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : ""
|
||||||
|
return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ")
|
||||||
|
}
|
||||||
|
return run.error?.text || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRow(task, run) {
|
||||||
|
const model = run.model || {}
|
||||||
|
const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : ""
|
||||||
|
const hasResult = run.message_id || run.report_path || run.preview
|
||||||
|
const detail = h("div", { class: "run__detail", hidden: true })
|
||||||
|
const open = hasResult
|
||||||
|
? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") })
|
||||||
|
: null
|
||||||
|
open?.addEventListener("click", async () => {
|
||||||
|
const opening = detail.hidden
|
||||||
|
open.setAttribute("aria-expanded", String(opening))
|
||||||
|
if (!opening) {
|
||||||
|
detail.hidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.hidden = false
|
||||||
|
detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") }))
|
||||||
|
try {
|
||||||
|
const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`)
|
||||||
|
detail.replaceChildren(
|
||||||
|
r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }),
|
||||||
|
r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null,
|
||||||
|
r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
detail.replaceChildren(h("p", { class: "status is-error", text: why(err) }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const note = runNote(run)
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: "run", dataset: { id: run.id } },
|
||||||
|
h("span", { class: "run__status" }, statusChip(run)),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" },
|
||||||
|
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||||
|
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||||
|
),
|
||||||
|
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||||
|
open || h("span", { class: "run__error", title: note, text: note }),
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow(task, btn) {
|
||||||
|
btn.disabled = true
|
||||||
|
try {
|
||||||
|
await post(`/schedules/${enc(task.id)}/run`)
|
||||||
|
await draw()
|
||||||
|
} catch (err) {
|
||||||
|
btn.disabled = false
|
||||||
|
await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(task) {
|
||||||
|
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/schedules/${enc(task.id)}?keep_chat=true`)
|
||||||
|
go({ name: "list" })
|
||||||
|
} catch (err) {
|
||||||
|
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A task said in words: new, or an existing one edited ---------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form. The words first: Read them asks the server (compile) for the rule, its title and
|
||||||
|
* instruction, and the next runs; the title and instruction can then be changed. Below, the
|
||||||
|
* settings the server keeps per task. An existing task starts from what it is: its words, title,
|
||||||
|
* instruction, and its rule read back (preview) -- a paused task's next runs too.
|
||||||
|
*/
|
||||||
|
async function drawForm(task) {
|
||||||
|
const editing = !!task
|
||||||
|
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
||||||
|
clear($("scheduled-tools"))
|
||||||
|
const smodels = (await schedulingModels())?.models || []
|
||||||
|
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
||||||
|
const draft = {
|
||||||
|
request: task?.request || "",
|
||||||
|
title: task?.title || "",
|
||||||
|
instruction: task?.instruction || "",
|
||||||
|
rule: task?.rule || null,
|
||||||
|
target: task?.target || "chat",
|
||||||
|
model: task?.model || "",
|
||||||
|
effort: task?.effort || "",
|
||||||
|
agents: task?.agents || null,
|
||||||
|
notify: task?.notify || "matters",
|
||||||
|
mode: task?.mode || "fresh",
|
||||||
|
}
|
||||||
|
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
||||||
|
|
||||||
|
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request })
|
||||||
|
const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") })
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const when_ = h("span", { class: "sched__desc", id: "sched-when" })
|
||||||
|
const zone = h("p", { class: "hint" })
|
||||||
|
const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") })
|
||||||
|
const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction })
|
||||||
|
const settings = h("div", { class: "fields sched__settings" })
|
||||||
|
const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") })
|
||||||
|
const saveSaid = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const nextBox = h("div", { class: "sched__nextbox" })
|
||||||
|
const manual = manualRule((rule) => {
|
||||||
|
post("/schedules/preview", { rule })
|
||||||
|
.then((r) => {
|
||||||
|
reading = r
|
||||||
|
draft.rule = r.rule || {}
|
||||||
|
drawWhen()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
saveSaid.textContent = why(err)
|
||||||
|
saveSaid.hidden = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) })
|
||||||
|
const rest = h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__card", hidden: !editing },
|
||||||
|
h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)),
|
||||||
|
manualBtn,
|
||||||
|
manual,
|
||||||
|
nextBox,
|
||||||
|
h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }),
|
||||||
|
instruction,
|
||||||
|
h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }),
|
||||||
|
settings,
|
||||||
|
saveSaid,
|
||||||
|
h("div", { class: "set__buttons" }, saveBtn),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawWhen = () => {
|
||||||
|
const r = reading
|
||||||
|
const next = r?.next_runs || []
|
||||||
|
const runs = !!(draft.rule && Object.keys(draft.rule).length)
|
||||||
|
when_.textContent = (runs && r?.description) || t("These words give no run in the future.")
|
||||||
|
when_.title = when_.textContent
|
||||||
|
when_.classList.toggle("is-error", !runs)
|
||||||
|
zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : ""
|
||||||
|
nextBox.replaceChildren(upcoming(next) || "")
|
||||||
|
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSettings = () => {
|
||||||
|
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
||||||
|
const select = (id, table, key, onChange) =>
|
||||||
|
h(
|
||||||
|
"select",
|
||||||
|
{ id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) },
|
||||||
|
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })),
|
||||||
|
)
|
||||||
|
const modelSelect = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) },
|
||||||
|
h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }),
|
||||||
|
smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })),
|
||||||
|
draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null,
|
||||||
|
)
|
||||||
|
const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : []
|
||||||
|
const effort = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) },
|
||||||
|
h("option", { value: "", text: t("The model's own"), selected: !draft.effort }),
|
||||||
|
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })),
|
||||||
|
// A level the task has that its model no longer offers stays, as it is, until changed.
|
||||||
|
draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null,
|
||||||
|
)
|
||||||
|
const agents = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
||||||
|
h("option", { value: "", text: t("None"), selected: !draft.agents }),
|
||||||
|
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||||
|
)
|
||||||
|
settings.replaceChildren(
|
||||||
|
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||||
|
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||||
|
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||||
|
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
||||||
|
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
|
||||||
|
)
|
||||||
|
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{ class: "sched sched--new" },
|
||||||
|
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
|
||||||
|
words,
|
||||||
|
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
|
||||||
|
h("div", { class: "set__buttons" }, readBtn),
|
||||||
|
said,
|
||||||
|
rest,
|
||||||
|
)
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const text = words.value.trim()
|
||||||
|
if (!text) return words.focus()
|
||||||
|
readBtn.disabled = true
|
||||||
|
said.hidden = true
|
||||||
|
try {
|
||||||
|
const c = await post("/schedules/compile", { text })
|
||||||
|
draft.request = text
|
||||||
|
draft.rule = c.rule || {}
|
||||||
|
if (c.title || !editing) title.value = c.title || ""
|
||||||
|
if (c.instruction || !editing) instruction.value = c.instruction || text
|
||||||
|
if (c.target) draft.target = c.target
|
||||||
|
reading = c
|
||||||
|
if (!c.ok && c.reason) {
|
||||||
|
said.textContent = c.reason
|
||||||
|
said.hidden = false
|
||||||
|
}
|
||||||
|
// The words gave no rule: the form for one, rather than a task that cannot run.
|
||||||
|
if (!(c.rule && Object.keys(c.rule).length)) {
|
||||||
|
manual.hidden = false
|
||||||
|
manualBtn.hidden = true
|
||||||
|
}
|
||||||
|
rest.hidden = false
|
||||||
|
drawWhen()
|
||||||
|
;(c.ok ? saveBtn : instruction).focus()
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
} finally {
|
||||||
|
readBtn.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
words.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
saveBtn.addEventListener("click", async () => {
|
||||||
|
saveBtn.disabled = true
|
||||||
|
saveSaid.hidden = true
|
||||||
|
const body = {
|
||||||
|
request: draft.request || words.value.trim(),
|
||||||
|
title: title.value.trim(),
|
||||||
|
instruction: instruction.value.trim(),
|
||||||
|
rule: draft.rule,
|
||||||
|
target: draft.target,
|
||||||
|
model: draft.model,
|
||||||
|
effort: draft.effort,
|
||||||
|
agents: draft.agents,
|
||||||
|
notify: draft.notify,
|
||||||
|
mode: draft.mode,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
||||||
|
go({ name: "task", id: r.schedule.id })
|
||||||
|
} catch (err) {
|
||||||
|
saveSaid.textContent = why(err)
|
||||||
|
saveSaid.hidden = false
|
||||||
|
saveBtn.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
clear($("scheduled-body")).append(form)
|
||||||
|
drawSettings()
|
||||||
|
drawWhen()
|
||||||
|
words.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule set by hand, for words the server could not read into one: the days of the week (none
|
||||||
|
* ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0).
|
||||||
|
* `onRule(rule)` on every change; the server reads it back (preview).
|
||||||
|
*/
|
||||||
|
function manualRule(onRule) {
|
||||||
|
const lang = document.documentElement.lang || undefined
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday
|
||||||
|
return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) }))
|
||||||
|
})
|
||||||
|
const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true })
|
||||||
|
const box = h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__manual", hidden: true },
|
||||||
|
h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")),
|
||||||
|
)
|
||||||
|
const changed = () => {
|
||||||
|
if (!time.value) return
|
||||||
|
const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value))
|
||||||
|
onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } })
|
||||||
|
}
|
||||||
|
box.addEventListener("change", changed)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A field on the form's shared tracks: label, control, hint -- always all three. */
|
||||||
|
function field(label, control, hint, forId, hintId) {
|
||||||
|
return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Live: the list as the server's event stream gives it ---------------------------------------
|
||||||
|
|
||||||
|
/** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */
|
||||||
|
function onSchedules(list) {
|
||||||
|
if (!Array.isArray(list?.schedules)) return
|
||||||
|
tasks = list.schedules
|
||||||
|
missing = false
|
||||||
|
if (!onPage() || popoverOpenHere()) return
|
||||||
|
if (view.name === "list") drawList()
|
||||||
|
else if (view.name === "task" && shown) {
|
||||||
|
const now = tasks.find((x) => x.id === shown.id)
|
||||||
|
if (!now) return go({ name: "list" })
|
||||||
|
const last = now.last_run || {}
|
||||||
|
if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function popoverOpenHere() {
|
||||||
|
return !!document.querySelector(".pop .smodels")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The palette's commands for scheduled tasks. */
|
||||||
|
export function scheduledCommands() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("New scheduled task"),
|
||||||
|
run: () => {
|
||||||
|
ctx.show()
|
||||||
|
go({ name: "form" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScheduled(context) {
|
||||||
|
ctx = context
|
||||||
|
listen("schedules", (e) => onSchedules(e.payload))
|
||||||
|
}
|
||||||
+89
-102
@@ -1,86 +1,81 @@
|
|||||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||||
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
|
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
||||||
// notifications, and this release.
|
// sign-in), the agent and the folders it may work in, notifications, and this release.
|
||||||
|
|
||||||
import { $, h, clear } from "./dom.js"
|
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, why } from "./bridge.js"
|
import { invoke, listen } from "./bridge.js"
|
||||||
|
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||||
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…"), ""],
|
|
||||||
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"],
|
|
||||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
|
||||||
}
|
|
||||||
|
|
||||||
let folderError = ""
|
let folderError = ""
|
||||||
let linking = null // { user_code, uri } while the computer is being linked
|
let current = () => null
|
||||||
let linkError = ""
|
|
||||||
|
|
||||||
export function agentWords(s) {
|
const SECTIONS = [
|
||||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
["account", ICONS.user, () => t("Account")],
|
||||||
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
["agent", ICONS.terminal, () => t("Agent")],
|
||||||
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
["folders", ICONS.folder, () => t("Project folders")],
|
||||||
|
["notify", ICONS.bell, () => t("Notifications")],
|
||||||
|
["about", ICONS.info, () => t("About")],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
|
||||||
|
export function scrollToSection(id) {
|
||||||
|
const el = $(`set-${id}`)
|
||||||
|
if (!el) return
|
||||||
|
el.scrollIntoView({ block: "start" })
|
||||||
|
markNav(id)
|
||||||
|
el.querySelector("button, select, input")?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function markNav(id) {
|
||||||
|
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function section(id, title, ...body) {
|
function section(id, title, ...body) {
|
||||||
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
|
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
|
||||||
|
function line(label, hint, control) {
|
||||||
|
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSettings(s) {
|
export function renderSettings(s) {
|
||||||
|
if (!s) return
|
||||||
|
const nav = clear($("settings-nav"))
|
||||||
|
for (const [id, ico, title] of SECTIONS) {
|
||||||
|
if (id === "folders" && !(s.linked && s.remote)) continue
|
||||||
|
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
|
||||||
|
}
|
||||||
const box = clear($("settings-body"))
|
const box = clear($("settings-body"))
|
||||||
// The server and the account.
|
|
||||||
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-server",
|
"account",
|
||||||
t("Server"),
|
t("Account"),
|
||||||
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
|
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
|
||||||
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
|
line(
|
||||||
h("p", { class: "muted", text: t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.") }),
|
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
|
||||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
|
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The agent.
|
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
|
||||||
const [text, cls] = agentWords(s)
|
box.append(
|
||||||
const agent = section(
|
section(
|
||||||
"set-agent",
|
"agent",
|
||||||
t("Agent"),
|
t("Agent"),
|
||||||
h("p", { class: "muted", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
||||||
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
|
agentBlock(s),
|
||||||
)
|
h(
|
||||||
if (linking) {
|
"div",
|
||||||
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
|
{ class: "set__buttons" },
|
||||||
}
|
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
|
||||||
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
|
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
|
||||||
agent.append(
|
),
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "row--buttons" },
|
|
||||||
h("button", {
|
|
||||||
type: "button",
|
|
||||||
class: "btn",
|
|
||||||
text: s.linked ? t("Link again") : t("Link this computer"),
|
|
||||||
onclick: () => {
|
|
||||||
linkError = ""
|
|
||||||
invoke("link_computer").catch((err) => {
|
|
||||||
linkError = why(err)
|
|
||||||
renderSettings(s)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
h("button", {
|
|
||||||
type: "button",
|
|
||||||
class: "btn",
|
|
||||||
text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"),
|
|
||||||
disabled: !s.linked && !s.agent_wanted,
|
|
||||||
onclick: () => invoke("set_agent", { on: !s.agent_wanted }),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
box.append(agent)
|
|
||||||
|
|
||||||
// The folders.
|
// The folders.
|
||||||
const r = s.remote
|
const r = s.remote
|
||||||
@@ -92,14 +87,15 @@ export function renderSettings(s) {
|
|||||||
h(
|
h(
|
||||||
"li",
|
"li",
|
||||||
null,
|
null,
|
||||||
|
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
|
||||||
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
||||||
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const mode = h(
|
const mode = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "max-mode", onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
||||||
[
|
[
|
||||||
["manual", t("Manual — asks first")],
|
["manual", t("Manual — asks first")],
|
||||||
["edit", t("Edit — asks before commands")],
|
["edit", t("Edit — asks before commands")],
|
||||||
@@ -107,72 +103,63 @@ export function renderSettings(s) {
|
|||||||
["plan", t("Plan — changes nothing")],
|
["plan", t("Plan — changes nothing")],
|
||||||
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
||||||
)
|
)
|
||||||
const terminal = h("input", { type: "checkbox", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-folders",
|
"folders",
|
||||||
t("Project folders"),
|
t("Project folders"),
|
||||||
h("p", { class: "muted", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
||||||
list,
|
list,
|
||||||
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
||||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
||||||
h(
|
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
|
||||||
"div",
|
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
|
||||||
{ class: "fields" },
|
|
||||||
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "field" },
|
|
||||||
h("span", { class: "label", text: t("Terminal") }),
|
|
||||||
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
|
|
||||||
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications.
|
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-notify",
|
"notify",
|
||||||
t("Notifications"),
|
t("Notifications"),
|
||||||
h(
|
line(
|
||||||
"label",
|
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
|
||||||
{ class: "check" },
|
t("Scheduled tasks tell you of a result, too, while the app runs."),
|
||||||
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
|
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
|
||||||
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// This release.
|
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-about",
|
"about",
|
||||||
t("About"),
|
t("About"),
|
||||||
h("p", null, `LLeMbas Desktop ${s.version}`),
|
line(
|
||||||
h(
|
`LLeMbas Desktop ${s.version}`,
|
||||||
"div",
|
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||||
{ class: "row--buttons" },
|
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||||
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSettings(current) {
|
export function initSettings(getStatus) {
|
||||||
|
current = getStatus
|
||||||
listen("remote-error", (e) => {
|
listen("remote-error", (e) => {
|
||||||
folderError = String(e.payload)
|
folderError = String(e.payload)
|
||||||
renderSettings(current())
|
if (!$("settings").hidden) renderSettings(current())
|
||||||
})
|
})
|
||||||
listen("status", () => {
|
listen("status", () => {
|
||||||
folderError = ""
|
folderError = ""
|
||||||
})
|
})
|
||||||
listen("linking", (e) => {
|
onAgentChange(() => {
|
||||||
const p = e.payload
|
if (!$("settings").hidden) renderSettings(current())
|
||||||
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
})
|
||||||
linkError = p.state === "failed" ? p.message : ""
|
$("settings-body").addEventListener("scroll", () => {
|
||||||
renderSettings(current())
|
// The group at the top is the one marked beside them.
|
||||||
|
const top = $("settings-body").getBoundingClientRect().top
|
||||||
|
let at = ""
|
||||||
|
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
|
||||||
|
if (at) markNav(at)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+6
-3
@@ -1,8 +1,8 @@
|
|||||||
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
||||||
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
||||||
// server's device login: the approval page opens in the system's browser; this page shows the
|
// server's device login: the approval page opens in the system's browser; this page shows the
|
||||||
// code to compare and waits. Where the server offers it (`one_approval`) and this computer is not
|
// code to compare and waits. Where the server offers it (`one_approval`) and the agent's link does
|
||||||
// linked yet, the same approval links it for the agent: there is no second one.
|
// not work yet, the same approval links this computer for the agent: there is no second one.
|
||||||
|
|
||||||
import { $, h, clear } from "./dom.js"
|
import { $, h, clear } from "./dom.js"
|
||||||
import { invoke, listen, why } from "./bridge.js"
|
import { invoke, listen, why } from "./bridge.js"
|
||||||
@@ -61,7 +61,10 @@ export function renderSetup(s) {
|
|||||||
|
|
||||||
if (s.discovery && !s.account) {
|
if (s.discovery && !s.account) {
|
||||||
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
||||||
const both = s.discovery.one_approval && !s.linked
|
// The same approval links this computer unless the agent's link works already (Rust decides
|
||||||
|
// the same way, `link_works`).
|
||||||
|
const linked = s.agent?.state === "running" || (s.agent?.state === "elsewhere" && s.agent.detail?.linked)
|
||||||
|
const both = s.discovery.one_approval && !linked
|
||||||
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
||||||
if (s.signing_in && code) {
|
if (s.signing_in && code) {
|
||||||
sign.append(
|
sign.append(
|
||||||
|
|||||||
+78
-8
@@ -1,20 +1,49 @@
|
|||||||
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
||||||
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
||||||
// does that for an undecorated one); the menu and the agent pill open the app's native menu.
|
// does that for an undecorated one). The menu opens the app's native menu; the agent's chip opens
|
||||||
|
// a small panel with what the agent says and the one thing to do about it.
|
||||||
|
|
||||||
import { $, icon, ICONS } from "./dom.js"
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, win } from "./bridge.js"
|
import { invoke, listen, win } from "./bridge.js"
|
||||||
|
import { agentBlock, onAgentChange } from "./agent.js"
|
||||||
|
import { popover, closePopover } from "./popup.js"
|
||||||
|
|
||||||
const WORDS = {
|
const WORDS = {
|
||||||
off: () => t("agent off"),
|
off: () => t("agent off"),
|
||||||
not_linked: () => t("not linked"),
|
not_linked: () => t("not linked"),
|
||||||
starting: () => t("agent starting…"),
|
starting: () => t("agent starting…"),
|
||||||
|
connecting: () => t("agent connecting…"),
|
||||||
running: () => t("agent running"),
|
running: () => t("agent running"),
|
||||||
|
waiting: () => t("agent waiting"),
|
||||||
|
refused: () => t("agent refused"),
|
||||||
elsewhere: () => t("agent running (service)"),
|
elsewhere: () => t("agent running (service)"),
|
||||||
|
elsewhere_waiting: () => t("agent waiting"),
|
||||||
failed: () => t("agent stopped"),
|
failed: () => t("agent stopped"),
|
||||||
no_folders: () => t("agent: no folders"),
|
no_folders: () => t("agent: no folders"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ctx = null // { status(), openSettings() }
|
||||||
|
let pop = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
||||||
|
* into no folder is said too -- a green light would say it works when it cannot.
|
||||||
|
*/
|
||||||
|
export function pillState(s) {
|
||||||
|
const a = s.agent
|
||||||
|
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||||
|
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||||
|
if (linked && noFolders) return "no_folders"
|
||||||
|
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
|
||||||
|
return a.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the agent is not working, in the CLI's words, when it said. */
|
||||||
|
export function agentWhy(a) {
|
||||||
|
if (a.state === "elsewhere") return a.detail?.why || ""
|
||||||
|
return typeof a.detail === "string" ? a.detail : ""
|
||||||
|
}
|
||||||
|
|
||||||
export function renderBar(s) {
|
export function renderBar(s) {
|
||||||
let host = ""
|
let host = ""
|
||||||
try {
|
try {
|
||||||
@@ -22,15 +51,54 @@ export function renderBar(s) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
$("host").textContent = host
|
$("host").textContent = host
|
||||||
$("host").title = s.server || ""
|
$("host").title = s.server || ""
|
||||||
// Connected but allowed into no folder: a green light would say it works when it cannot.
|
const state = pillState(s)
|
||||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
const reason = agentWhy(s.agent)
|
||||||
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
|
|
||||||
$("agent").dataset.state = state
|
$("agent").dataset.state = state
|
||||||
$("agent-text").textContent = WORDS[state] ? WORDS[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").hidden = !s.update
|
||||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("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 }) : ""
|
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||||
|
pop?.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chip's panel: the agent's state and its button, then start or stop, and Settings. */
|
||||||
|
function agentPanel() {
|
||||||
|
const s = ctx.status()
|
||||||
|
const box = h("div", { class: "pop__body" }, h("p", { class: "pop__title", text: t("Agent") }), agentBlock(s))
|
||||||
|
const row = h("div", { class: "pop__actions" })
|
||||||
|
if (s.linked) {
|
||||||
|
row.append(h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }))
|
||||||
|
}
|
||||||
|
row.append(
|
||||||
|
h("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--sm",
|
||||||
|
text: t("Settings"),
|
||||||
|
onclick: () => {
|
||||||
|
closePopover()
|
||||||
|
ctx.openSettings("agent")
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
box.append(row)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAgent() {
|
||||||
|
if (pop) {
|
||||||
|
closePopover()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$("agent").setAttribute("aria-expanded", "true")
|
||||||
|
pop = popover($("agent"), agentPanel, {
|
||||||
|
align: "end",
|
||||||
|
label: t("Agent"),
|
||||||
|
onClose: () => {
|
||||||
|
pop = null
|
||||||
|
$("agent").setAttribute("aria-expanded", "false")
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function maximized(on) {
|
function maximized(on) {
|
||||||
@@ -46,7 +114,8 @@ function menuAt(button) {
|
|||||||
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initBar() {
|
export function initBar(context) {
|
||||||
|
ctx = context
|
||||||
$("minimize").replaceChildren(icon(ICONS.min))
|
$("minimize").replaceChildren(icon(ICONS.min))
|
||||||
$("close").replaceChildren(icon(ICONS.close))
|
$("close").replaceChildren(icon(ICONS.close))
|
||||||
$("menu").replaceChildren(icon(ICONS.menu))
|
$("menu").replaceChildren(icon(ICONS.menu))
|
||||||
@@ -56,7 +125,8 @@ export function initBar() {
|
|||||||
$("close").addEventListener("click", () => win.close())
|
$("close").addEventListener("click", () => win.close())
|
||||||
$("update").addEventListener("click", () => invoke("update_app"))
|
$("update").addEventListener("click", () => invoke("update_app"))
|
||||||
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
||||||
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
|
$("agent").addEventListener("click", toggleAgent)
|
||||||
|
onAgentChange(() => pop?.redraw())
|
||||||
listen("maximized", (e) => maximized(!!e.payload))
|
listen("maximized", (e) => maximized(!!e.payload))
|
||||||
win.isMaximized().then(maximized).catch(() => maximized(false))
|
win.isMaximized().then(maximized).catch(() => maximized(false))
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
/* LLeMbas v2.0.0-beta.4 (7969067f7260) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
/* LLeMbas v2.0.0-beta.7 (e3bd54f4d210) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||||
The font-face rules are left out; the app bundles no font. */
|
The font-face rules are left out; the app bundles no font. */
|
||||||
/*
|
/*
|
||||||
Design tokens.
|
Design tokens.
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
one that names what it is: a theme can then widen every menu at once, and
|
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
|
two popovers cannot drift a rem apart by accident. Control heights are
|
||||||
`--control-h`; these are the boxes around them. */
|
`--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-xs: 8rem; /* a short input: a count, a duration */
|
||||||
--w-field-sm: 10rem; /* a short select's floor */
|
--w-field-sm: 10rem; /* a short select's floor */
|
||||||
--w-field: 14rem; /* a capped field, a label column, a small menu */
|
--w-field: 14rem; /* a capped field, a label column, a small menu */
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.4",
|
"tag": "v2.0.0-beta.7",
|
||||||
"commit": "7969067f72605db0df7dd877b58d847156373f18",
|
"commit": "e3bd54f4d210cff6adb4586199caed4f41987f5e",
|
||||||
"files": {
|
"files": {
|
||||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||||
|
|||||||
Reference in new issue
Block a user