Compare commits
8
Commits
v1.0.0-beta.3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ddde6c870
|
||
|
|
a13e437823
|
||
|
|
89806982a9
|
||
|
|
6a095cb102
|
||
|
|
ad36932c9f
|
||
|
|
af14f4a229
|
||
|
|
5b00822551
|
||
|
|
1749d2369e
|
No files matched your search
@@ -6,6 +6,55 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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): the list with each task's next run, how its
|
||||||
|
last run went and a switch to pause it; a task's page with what it does, when, how it tells you,
|
||||||
|
the next runs, its history (each run's status, time, model and its result to open) and **Run
|
||||||
|
now**; and a new task said in your own words -- the server reads them into a rule and an
|
||||||
|
instruction and shows the next five runs before you save. While the app runs, a scheduled task's
|
||||||
|
result the server decides to tell of comes as a system notification (never a run with nothing
|
||||||
|
to report). A server without scheduled tasks says so.
|
||||||
|
|
||||||
## [1.0.0-beta.3] — 2026-11-09
|
## [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
|
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.5. Needs a LLeMbas 2.0 server
|
||||||
|
|||||||
@@ -8,15 +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,
|
- **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.
|
**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.
|
- **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,
|
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
||||||
waiting and why -- not whether a process runs.
|
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.
|
||||||
|
|
||||||
@@ -57,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
|
||||||
|
|||||||
+475
-91
@@ -79,19 +79,13 @@ KEYS = (
|
|||||||
"Model",
|
"Model",
|
||||||
"No model is available to this account.",
|
"No model is available to this account.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.",
|
|
||||||
"Folder",
|
"Folder",
|
||||||
"Only the agent's folders: Settings, Project folders.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.",
|
"No folder yet: add one under Settings, Project folders.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.",
|
|
||||||
# explorer.js
|
# explorer.js
|
||||||
"Empty folder",
|
"Empty folder",
|
||||||
"No project folder yet: the agent can open nothing on this computer.",
|
"No project folder yet: the agent can open nothing on this computer.",
|
||||||
"Add folder…",
|
"Add folder…",
|
||||||
# list.js
|
# list.js
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.",
|
|
||||||
"No chat yet.",
|
|
||||||
"Other devices",
|
|
||||||
"More",
|
"More",
|
||||||
# main.js
|
# main.js
|
||||||
"The reply is ready.",
|
"The reply is ready.",
|
||||||
@@ -100,12 +94,10 @@ KEYS = (
|
|||||||
"Copy",
|
"Copy",
|
||||||
# settings.js
|
# settings.js
|
||||||
"The agent is off.",
|
"The agent is off.",
|
||||||
"This computer is not linked to the server yet.",
|
|
||||||
"The agent is starting…",
|
"The agent is starting…",
|
||||||
"The agent is running: agent chats can use this computer.",
|
"The agent is running: agent chats can use this computer.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.",
|
"The agent stopped: %(why)s. Trying again shortly.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.",
|
|
||||||
"Signed in as %(who)s.",
|
"Signed in as %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.",
|
||||||
"Sign out",
|
"Sign out",
|
||||||
@@ -127,7 +119,6 @@ KEYS = (
|
|||||||
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.",
|
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.",
|
||||||
"The most an agent chat may do here",
|
"The most an agent chat may do here",
|
||||||
"A chat asking for more is lowered to this.",
|
"A chat asking for more is lowered to this.",
|
||||||
"Terminal",
|
|
||||||
"Let the server open a terminal here",
|
"Let the server open a terminal here",
|
||||||
"A shell as you, in one of these folders.",
|
"A shell as you, in one of these folders.",
|
||||||
"Notifications",
|
"Notifications",
|
||||||
@@ -179,13 +170,11 @@ KEYS = (
|
|||||||
"Type a command or a chat's title",
|
"Type a command or a chat's title",
|
||||||
"The agent is connecting to the server…",
|
"The agent is connecting to the server…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.",
|
"The agent is not connected: %(why)s. It keeps trying.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s",
|
||||||
"agent connecting…",
|
"agent connecting…",
|
||||||
"agent waiting",
|
"agent waiting",
|
||||||
"agent refused",
|
"agent refused",
|
||||||
"Agent: %(why)s",
|
"Agent: %(why)s",
|
||||||
"Server files",
|
|
||||||
"Drop the files to attach them",
|
"Drop the files to attach them",
|
||||||
"Attach files",
|
"Attach files",
|
||||||
"Edit message",
|
"Edit message",
|
||||||
@@ -263,6 +252,73 @@ KEYS = (
|
|||||||
"Open its conversation",
|
"Open its conversation",
|
||||||
"Stop %(name)s and the members under it? They leave the team.",
|
"Stop %(name)s and the members under it? They leave the team.",
|
||||||
"Nothing yet.",
|
"Nothing yet.",
|
||||||
|
# 1.0.0-beta.4: the redesign (agent.js, list.js, chat.js, scheduled.js, settings.js)
|
||||||
|
"Stop",
|
||||||
|
"Sidebar",
|
||||||
|
"Search chats",
|
||||||
|
"This computer is not linked yet.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.",
|
||||||
|
"The server refused this computer's link: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.",
|
||||||
|
"The agent is connected, but may open no folder yet.",
|
||||||
|
"Hello, %(name)s",
|
||||||
|
"Hello",
|
||||||
|
"A chat on %(server)s, with your account's models and files.",
|
||||||
|
"Chat",
|
||||||
|
"Your files on the server, as the chats see them.",
|
||||||
|
"No chat yet. Start one with New chat.",
|
||||||
|
"Today",
|
||||||
|
"Earlier",
|
||||||
|
"Scheduled",
|
||||||
|
"Files",
|
||||||
|
"A scheduled task has a result.",
|
||||||
|
"A scheduled task failed.",
|
||||||
|
"Account",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.",
|
||||||
|
"Done",
|
||||||
|
"Nothing to report",
|
||||||
|
"Failed",
|
||||||
|
"Missed",
|
||||||
|
"Skipped",
|
||||||
|
"Waiting for a free model",
|
||||||
|
"Running",
|
||||||
|
"Always",
|
||||||
|
"When it matters",
|
||||||
|
"Never",
|
||||||
|
"A new chat for each run",
|
||||||
|
"One chat that grows",
|
||||||
|
"Not run yet",
|
||||||
|
"Paused",
|
||||||
|
"No more runs",
|
||||||
|
"This server has no scheduled tasks yet.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.",
|
||||||
|
"New scheduled task",
|
||||||
|
"Nothing is scheduled.",
|
||||||
|
"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.",
|
||||||
|
"Pause %(title)s",
|
||||||
|
"Resume %(title)s",
|
||||||
|
"Pause",
|
||||||
|
"Resume",
|
||||||
|
"Next run",
|
||||||
|
"Run now",
|
||||||
|
"When",
|
||||||
|
"Last run",
|
||||||
|
"Tell me",
|
||||||
|
"Runs",
|
||||||
|
"What it does",
|
||||||
|
"You asked: “%(text)s”",
|
||||||
|
"The next runs",
|
||||||
|
"History",
|
||||||
|
"It has not run yet.",
|
||||||
|
"late",
|
||||||
|
"caught up",
|
||||||
|
"Open the result",
|
||||||
|
"Delete this scheduled task?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list",
|
||||||
|
"What should happen, and when",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.",
|
||||||
|
"These words give no run in the future.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||||
@@ -300,27 +356,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nová agentní konverzace",
|
"New agent chat": "Nová agentní konverzace",
|
||||||
"No model is available to this account.": "Tomuto účtu není dostupný žádný model.",
|
"No model is available to this account.": "Tomuto účtu není dostupný žádný model.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Agentní konverzace pracuje ve složce na tomto počítači přes agenta vestavěného v této aplikaci.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Agentní konverzace pracuje ve složce na tomto počítači přes agenta vestavěného v této aplikaci.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent ještě není připojený k serveru: spusťte ho nebo propojte tento počítač v Nastavení.",
|
|
||||||
"Folder": "Složka",
|
"Folder": "Složka",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Jen složky agenta: Nastavení, Složky projektů.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Zatím žádná složka: přidejte ji v Nastavení, Složky projektů.",
|
"No folder yet: add one under Settings, Project folders.": "Zatím žádná složka: přidejte ji v Nastavení, Složky projektů.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Konverzace, která chce víc, než složka dovoluje, je snížena na to.",
|
|
||||||
"Empty folder": "Prázdná složka",
|
"Empty folder": "Prázdná složka",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Zatím žádná složka projektu: agent na tomto počítači nemůže nic otevřít.",
|
"No project folder yet: the agent can open nothing on this computer.": "Zatím žádná složka projektu: agent na tomto počítači nemůže nic otevřít.",
|
||||||
"Add folder…": "Přidat složku…",
|
"Add folder…": "Přidat složku…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatím žádná agentní konverzace. Začněte ji: pracuje ve složce na tomto počítači.",
|
|
||||||
"No chat yet.": "Zatím žádná konverzace.",
|
|
||||||
"Other devices": "Jiná zařízení",
|
|
||||||
"More": "Více",
|
"More": "Více",
|
||||||
"The reply is ready.": "Odpověď je hotová.",
|
"The reply is ready.": "Odpověď je hotová.",
|
||||||
"The agent has finished.": "Agent dokončil práci.",
|
"The agent has finished.": "Agent dokončil práci.",
|
||||||
"The agent is off.": "Agent je vypnutý.",
|
"The agent is off.": "Agent je vypnutý.",
|
||||||
"This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.",
|
|
||||||
"The agent is starting…": "Agent se spouští…",
|
"The agent is starting…": "Agent se spouští…",
|
||||||
"The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.",
|
"The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
|
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "Agent je připojený, ale zatím nesmí otevřít žádnou složku: přidejte ji níže.",
|
|
||||||
"Signed in as %(who)s.": "Přihlášen jako %(who)s.",
|
"Signed in as %(who)s.": "Přihlášen jako %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.",
|
||||||
"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.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
|
"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.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
|
||||||
@@ -380,18 +428,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Napište příkaz nebo název konverzace",
|
"Type a command or a chat's title": "Napište příkaz nebo název konverzace",
|
||||||
"The agent is connecting to the server…": "Agent se připojuje k serveru…",
|
"The agent is connecting to the server…": "Agent se připojuje k serveru…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent není připojený: %(why)s. Zkouší to dál.",
|
"The agent is not connected: %(why)s. It keeps trying.": "Agent není připojený: %(why)s. Zkouší to dál.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent se nepřipojí: %(why)s. Propojte tento počítač znovu.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI už tu běží (pid %(pid)s), ale není připojená: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI už tu běží (pid %(pid)s), ale není připojená: %(why)s",
|
||||||
"agent connecting…": "agent se připojuje…",
|
"agent connecting…": "agent se připojuje…",
|
||||||
"agent waiting": "agent čeká",
|
"agent waiting": "agent čeká",
|
||||||
"agent refused": "agent odmítnut",
|
"agent refused": "agent odmítnut",
|
||||||
"Agent: %(why)s": "Agent: %(why)s",
|
"Agent: %(why)s": "Agent: %(why)s",
|
||||||
"Server files": "Soubory na serveru",
|
|
||||||
"Drop the files to attach them": "Pusťte soubory a přiloží se",
|
"Drop the files to attach them": "Pusťte soubory a přiloží se",
|
||||||
"Continue in a terminal…": "Pokračovat v terminálu…",
|
"Continue in a terminal…": "Pokračovat v terminálu…",
|
||||||
"Continue in a terminal": "Pokračovat v terminálu",
|
"Continue in a terminal": "Pokračovat v terminálu",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spusťte to v terminálu: relace agenta tam pokračuje, ve složce této konverzace, a konverzace si ji ponechá.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spusťte to v terminálu: relace agenta tam pokračuje, ve složce této konverzace, a konverzace si ji ponechá.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spusťte to v terminálu na počítači, na kterém tato konverzace pracuje: relace agenta tam pokračuje a konverzace si ji ponechá.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spusťte to v terminálu na počítači, na kterém tato konverzace pracuje: relace agenta tam pokračuje a konverzace si ji ponechá.",
|
||||||
|
"Search chats": "Hledat v konverzacích",
|
||||||
|
"This computer is not linked yet.": "Tento počítač ještě není propojený.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Propojte ho se svým serverem a agentní konverzace budou moci pracovat v jeho složkách.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Propojte ho znovu a agent dostane nový token.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "Agent je připojený, ale zatím nesmí otevřít žádnou složku.",
|
||||||
|
"Hello, %(name)s": "Dobrý den, %(name)s",
|
||||||
|
"Hello": "Dobrý den",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Konverzace na %(server)s s modely a soubory vašeho účtu.",
|
||||||
|
"Your files on the server, as the chats see them.": "Vaše soubory na serveru, tak jak je vidí konverzace.",
|
||||||
|
"No chat yet. Start one with New chat.": "Zatím žádná konverzace. Začněte ji tlačítkem Nová konverzace.",
|
||||||
|
"Today": "Dnes",
|
||||||
|
"Earlier": "Starší",
|
||||||
|
"A scheduled task has a result.": "Naplánovaná úloha má výsledek.",
|
||||||
|
"A scheduled task failed.": "Naplánovaná úloha selhala.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Naplánované úlohy vám dají vědět i o výsledku, dokud aplikace běží.",
|
||||||
|
"Nothing to report": "Nic k hlášení",
|
||||||
|
"Missed": "Zmeškáno",
|
||||||
|
"Skipped": "Přeskočeno",
|
||||||
|
"Waiting for a free model": "Čeká na volný model",
|
||||||
|
"Always": "Vždy",
|
||||||
|
"When it matters": "Když na tom záleží",
|
||||||
|
"A new chat for each run": "Nová konverzace pro každé spuštění",
|
||||||
|
"One chat that grows": "Jedna konverzace, která roste",
|
||||||
|
"Not run yet": "Ještě neběžela",
|
||||||
|
"Paused": "Pozastaveno",
|
||||||
|
"No more runs": "Žádná další spuštění",
|
||||||
|
"This server has no scheduled tasks yet.": "Tento server zatím nemá naplánované úlohy.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Naplánované úlohy potřebují novější server LLeMbas. Požádejte jeho správce o aktualizaci.",
|
||||||
|
"Nothing is scheduled.": "Nic není naplánováno.",
|
||||||
|
"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.": "Řekněte, co se má stát a kdy — „každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení“ — a server to udělá za vás.",
|
||||||
|
"Pause %(title)s": "Pozastavit %(title)s",
|
||||||
|
"Resume %(title)s": "Obnovit %(title)s",
|
||||||
|
"Next run": "Další spuštění",
|
||||||
|
"Last run": "Poslední spuštění",
|
||||||
|
"Tell me": "Dát vědět",
|
||||||
|
"Runs": "Spuštění",
|
||||||
|
"What it does": "Co dělá",
|
||||||
|
"You asked: “%(text)s”": "Požádali jste: „%(text)s“",
|
||||||
|
"The next runs": "Další spuštění",
|
||||||
|
"History": "Historie",
|
||||||
|
"It has not run yet.": "Ještě neběžela.",
|
||||||
|
"late": "pozdě",
|
||||||
|
"caught up": "dohnáno",
|
||||||
|
"Open the result": "Otevřít výsledek",
|
||||||
|
"Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ se zastaví a její historie zmizí. Konverzace, které napsala, zůstanou.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení",
|
||||||
|
"What should happen, and when": "Co se má stát a kdy",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastními slovy. Server z nich vytvoří pravidlo a pokyn a před uložením vám ukáže další spuštění.",
|
||||||
|
"These words give no run in the future.": "Z těchto slov nevychází žádné budoucí spuštění.",
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
@@ -424,27 +521,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Neuer Agenten-Chat",
|
"New agent chat": "Neuer Agenten-Chat",
|
||||||
"No model is available to this account.": "Diesem Konto steht kein Modell zur Verfügung.",
|
"No model is available to this account.": "Diesem Konto steht kein Modell zur Verfügung.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Ein Agenten-Chat arbeitet in einem Ordner auf diesem Computer, über den in diese App eingebauten Agenten.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Ein Agenten-Chat arbeitet in einem Ordner auf diesem Computer, über den in diese App eingebauten Agenten.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Der Agent ist noch nicht mit dem Server verbunden: Starten Sie ihn oder verbinden Sie diesen Computer unter Einstellungen.",
|
|
||||||
"Folder": "Ordner",
|
"Folder": "Ordner",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Nur die Ordner des Agenten: Einstellungen, Projektordner.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Noch kein Ordner: Fügen Sie einen unter Einstellungen, Projektordner hinzu.",
|
"No folder yet: add one under Settings, Project folders.": "Noch kein Ordner: Fügen Sie einen unter Einstellungen, Projektordner hinzu.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Ein Chat, der mehr will, als der Ordner erlaubt, wird darauf herabgesetzt.",
|
|
||||||
"Empty folder": "Leerer Ordner",
|
"Empty folder": "Leerer Ordner",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Noch kein Projektordner: Der Agent kann auf diesem Computer nichts öffnen.",
|
"No project folder yet: the agent can open nothing on this computer.": "Noch kein Projektordner: Der Agent kann auf diesem Computer nichts öffnen.",
|
||||||
"Add folder…": "Ordner hinzufügen…",
|
"Add folder…": "Ordner hinzufügen…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Noch kein Agenten-Chat. Starten Sie einen: Er arbeitet in einem Ordner auf diesem Computer.",
|
|
||||||
"No chat yet.": "Noch kein Chat.",
|
|
||||||
"Other devices": "Andere Geräte",
|
|
||||||
"More": "Mehr",
|
"More": "Mehr",
|
||||||
"The reply is ready.": "Die Antwort ist fertig.",
|
"The reply is ready.": "Die Antwort ist fertig.",
|
||||||
"The agent has finished.": "Der Agent ist fertig.",
|
"The agent has finished.": "Der Agent ist fertig.",
|
||||||
"The agent is off.": "Der Agent ist aus.",
|
"The agent is off.": "Der Agent ist aus.",
|
||||||
"This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.",
|
|
||||||
"The agent is starting…": "Der Agent startet…",
|
"The agent is starting…": "Der Agent startet…",
|
||||||
"The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.",
|
"The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
|
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "Der Agent ist verbunden, darf aber noch keinen Ordner öffnen: Fügen Sie unten einen hinzu.",
|
|
||||||
"Signed in as %(who)s.": "Angemeldet als %(who)s.",
|
"Signed in as %(who)s.": "Angemeldet als %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.",
|
||||||
"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.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
|
"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.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
|
||||||
@@ -504,18 +593,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Befehl oder Chat-Titel eingeben",
|
"Type a command or a chat's title": "Befehl oder Chat-Titel eingeben",
|
||||||
"The agent is connecting to the server…": "Der Agent verbindet sich mit dem Server…",
|
"The agent is connecting to the server…": "Der Agent verbindet sich mit dem Server…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "Der Agent ist nicht verbunden: %(why)s. Er versucht es weiter.",
|
"The agent is not connected: %(why)s. It keeps trying.": "Der Agent ist nicht verbunden: %(why)s. Er versucht es weiter.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "Der Agent verbindet sich nicht: %(why)s. Verknüpfen Sie diesen Computer erneut.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s), ist aber nicht verbunden: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s), ist aber nicht verbunden: %(why)s",
|
||||||
"agent connecting…": "Agent verbindet…",
|
"agent connecting…": "Agent verbindet…",
|
||||||
"agent waiting": "Agent wartet",
|
"agent waiting": "Agent wartet",
|
||||||
"agent refused": "Agent abgewiesen",
|
"agent refused": "Agent abgewiesen",
|
||||||
"Agent: %(why)s": "Agent: %(why)s",
|
"Agent: %(why)s": "Agent: %(why)s",
|
||||||
"Server files": "Dateien auf dem Server",
|
|
||||||
"Drop the files to attach them": "Dateien ablegen, um sie anzuhängen",
|
"Drop the files to attach them": "Dateien ablegen, um sie anzuhängen",
|
||||||
"Continue in a terminal…": "Im Terminal fortsetzen…",
|
"Continue in a terminal…": "Im Terminal fortsetzen…",
|
||||||
"Continue in a terminal": "Im Terminal fortsetzen",
|
"Continue in a terminal": "Im Terminal fortsetzen",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Führen Sie dies in einem Terminal aus: Die Sitzung des Agenten geht dort weiter, im Ordner dieses Chats, und der Chat behält sie.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Führen Sie dies in einem Terminal aus: Die Sitzung des Agenten geht dort weiter, im Ordner dieses Chats, und der Chat behält sie.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Führen Sie dies in einem Terminal auf dem Computer aus, auf dem dieser Chat arbeitet: Die Sitzung des Agenten geht dort weiter, und der Chat behält sie.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Führen Sie dies in einem Terminal auf dem Computer aus, auf dem dieser Chat arbeitet: Die Sitzung des Agenten geht dort weiter, und der Chat behält sie.",
|
||||||
|
"Search chats": "Chats durchsuchen",
|
||||||
|
"This computer is not linked yet.": "Dieser Computer ist noch nicht verknüpft.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Verknüpfen Sie ihn mit Ihrem Server, dann können Agent-Chats in seinen Ordnern arbeiten.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Verknüpfen Sie ihn erneut, damit der Agent ein neues Token bekommt.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "Der Agent ist verbunden, darf aber noch keinen Ordner öffnen.",
|
||||||
|
"Hello, %(name)s": "Hallo, %(name)s",
|
||||||
|
"Hello": "Hallo",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Ein Chat auf %(server)s, mit den Modellen und Dateien Ihres Kontos.",
|
||||||
|
"Your files on the server, as the chats see them.": "Ihre Dateien auf dem Server, so wie die Chats sie sehen.",
|
||||||
|
"No chat yet. Start one with New chat.": "Noch kein Chat. Beginnen Sie einen mit Neuer Chat.",
|
||||||
|
"Today": "Heute",
|
||||||
|
"Earlier": "Früher",
|
||||||
|
"A scheduled task has a result.": "Eine geplante Aufgabe hat ein Ergebnis.",
|
||||||
|
"A scheduled task failed.": "Eine geplante Aufgabe ist fehlgeschlagen.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Geplante Aufgaben melden Ihnen auch ein Ergebnis, solange die App läuft.",
|
||||||
|
"Nothing to report": "Nichts zu melden",
|
||||||
|
"Missed": "Verpasst",
|
||||||
|
"Skipped": "Übersprungen",
|
||||||
|
"Waiting for a free model": "Wartet auf ein freies Modell",
|
||||||
|
"Always": "Immer",
|
||||||
|
"When it matters": "Wenn es wichtig ist",
|
||||||
|
"A new chat for each run": "Ein neuer Chat für jeden Lauf",
|
||||||
|
"One chat that grows": "Ein Chat, der wächst",
|
||||||
|
"Not run yet": "Noch nicht gelaufen",
|
||||||
|
"Paused": "Pausiert",
|
||||||
|
"No more runs": "Keine weiteren Läufe",
|
||||||
|
"This server has no scheduled tasks yet.": "Dieser Server hat noch keine geplanten Aufgaben.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Geplante Aufgaben brauchen einen neueren LLeMbas-Server. Bitten Sie seinen Administrator um ein Update.",
|
||||||
|
"Nothing is scheduled.": "Nichts ist geplant.",
|
||||||
|
"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.": "Sagen Sie, was wann geschehen soll — „jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen“ — und der Server erledigt es für Sie.",
|
||||||
|
"Pause %(title)s": "%(title)s pausieren",
|
||||||
|
"Resume %(title)s": "%(title)s fortsetzen",
|
||||||
|
"Next run": "Nächster Lauf",
|
||||||
|
"Last run": "Letzter Lauf",
|
||||||
|
"Tell me": "Benachrichtigen",
|
||||||
|
"Runs": "Läufe",
|
||||||
|
"What it does": "Was sie tut",
|
||||||
|
"You asked: “%(text)s”": "Ihre Worte: „%(text)s“",
|
||||||
|
"The next runs": "Die nächsten Läufe",
|
||||||
|
"History": "Verlauf",
|
||||||
|
"It has not run yet.": "Sie ist noch nicht gelaufen.",
|
||||||
|
"late": "verspätet",
|
||||||
|
"caught up": "nachgeholt",
|
||||||
|
"Open the result": "Ergebnis öffnen",
|
||||||
|
"Delete this scheduled task?": "Diese geplante Aufgabe löschen?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ hört auf, und ihr Verlauf wird gelöscht. Die Chats, die sie geschrieben hat, bleiben.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen",
|
||||||
|
"What should happen, and when": "Was soll wann geschehen?",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "In Ihren eigenen Worten. Der Server macht daraus eine Regel und eine Anweisung und zeigt Ihnen die nächsten Läufe, bevor etwas gespeichert wird.",
|
||||||
|
"These words give no run in the future.": "Diese Worte ergeben keinen künftigen Lauf.",
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
"Menu": "Menú",
|
"Menu": "Menú",
|
||||||
@@ -548,27 +686,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nuevo chat de agente",
|
"New agent chat": "Nuevo chat de agente",
|
||||||
"No model is available to this account.": "Ningún modelo está disponible para esta cuenta.",
|
"No model is available to this account.": "Ningún modelo está disponible para esta cuenta.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Un chat de agente trabaja en una carpeta de este equipo, a través del agente integrado en esta aplicación.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Un chat de agente trabaja en una carpeta de este equipo, a través del agente integrado en esta aplicación.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "El agente aún no está conectado al servidor: inícielo o vincule este equipo en Ajustes.",
|
|
||||||
"Folder": "Carpeta",
|
"Folder": "Carpeta",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Solo las carpetas del agente: Ajustes, Carpetas de proyecto.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Aún no hay carpeta: añada una en Ajustes, Carpetas de proyecto.",
|
"No folder yet: add one under Settings, Project folders.": "Aún no hay carpeta: añada una en Ajustes, Carpetas de proyecto.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Un chat que pide más de lo que la carpeta permite se rebaja a ello.",
|
|
||||||
"Empty folder": "Carpeta vacía",
|
"Empty folder": "Carpeta vacía",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Aún no hay carpeta de proyecto: el agente no puede abrir nada en este equipo.",
|
"No project folder yet: the agent can open nothing on this computer.": "Aún no hay carpeta de proyecto: el agente no puede abrir nada en este equipo.",
|
||||||
"Add folder…": "Añadir carpeta…",
|
"Add folder…": "Añadir carpeta…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Aún no hay chats de agente. Inicie uno: trabaja en una carpeta de este equipo.",
|
|
||||||
"No chat yet.": "Aún no hay chats.",
|
|
||||||
"Other devices": "Otros dispositivos",
|
|
||||||
"More": "Más",
|
"More": "Más",
|
||||||
"The reply is ready.": "La respuesta está lista.",
|
"The reply is ready.": "La respuesta está lista.",
|
||||||
"The agent has finished.": "El agente ha terminado.",
|
"The agent has finished.": "El agente ha terminado.",
|
||||||
"The agent is off.": "El agente está apagado.",
|
"The agent is off.": "El agente está apagado.",
|
||||||
"This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.",
|
|
||||||
"The agent is starting…": "El agente se está iniciando…",
|
"The agent is starting…": "El agente se está iniciando…",
|
||||||
"The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.",
|
"The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
|
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "El agente está conectado, pero aún no puede abrir ninguna carpeta: añada una abajo.",
|
|
||||||
"Signed in as %(who)s.": "Sesión iniciada como %(who)s.",
|
"Signed in as %(who)s.": "Sesión iniciada como %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.",
|
||||||
"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.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
|
"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.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
|
||||||
@@ -628,18 +758,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Escriba un comando o el título de un chat",
|
"Type a command or a chat's title": "Escriba un comando o el título de un chat",
|
||||||
"The agent is connecting to the server…": "El agente se está conectando al servidor…",
|
"The agent is connecting to the server…": "El agente se está conectando al servidor…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "El agente no está conectado: %(why)s. Sigue intentándolo.",
|
"The agent is not connected: %(why)s. It keeps trying.": "El agente no está conectado: %(why)s. Sigue intentándolo.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "El agente no se conectará: %(why)s. Vuelva a vincular este equipo.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ya hay un servicio de LLeMbas CLI aquí (pid %(pid)s), pero no está conectado: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Ya hay un servicio de LLeMbas CLI aquí (pid %(pid)s), pero no está conectado: %(why)s",
|
||||||
"agent connecting…": "agente conectando…",
|
"agent connecting…": "agente conectando…",
|
||||||
"agent waiting": "agente en espera",
|
"agent waiting": "agente en espera",
|
||||||
"agent refused": "agente rechazado",
|
"agent refused": "agente rechazado",
|
||||||
"Agent: %(why)s": "Agente: %(why)s",
|
"Agent: %(why)s": "Agente: %(why)s",
|
||||||
"Server files": "Archivos del servidor",
|
|
||||||
"Drop the files to attach them": "Suelte los archivos para adjuntarlos",
|
"Drop the files to attach them": "Suelte los archivos para adjuntarlos",
|
||||||
"Continue in a terminal…": "Continuar en un terminal…",
|
"Continue in a terminal…": "Continuar en un terminal…",
|
||||||
"Continue in a terminal": "Continuar en un terminal",
|
"Continue in a terminal": "Continuar en un terminal",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Ejecútelo en un terminal: la sesión del agente continúa allí, en la carpeta de este chat, y el chat la conserva.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Ejecútelo en un terminal: la sesión del agente continúa allí, en la carpeta de este chat, y el chat la conserva.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Ejecútelo en un terminal del equipo en el que trabaja este chat: la sesión del agente continúa allí y el chat la conserva.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Ejecútelo en un terminal del equipo en el que trabaja este chat: la sesión del agente continúa allí y el chat la conserva.",
|
||||||
|
"Search chats": "Buscar en los chats",
|
||||||
|
"This computer is not linked yet.": "Este equipo aún no está vinculado.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Vincúlelo a su servidor y los chats de agente podrán trabajar en sus carpetas.",
|
||||||
|
"The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Vincúlelo de nuevo para que el agente reciba un token nuevo.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "El agente está conectado, pero aún no puede abrir ninguna carpeta.",
|
||||||
|
"Hello, %(name)s": "Hola, %(name)s",
|
||||||
|
"Hello": "Hola",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Un chat en %(server)s, con los modelos y archivos de su cuenta.",
|
||||||
|
"Your files on the server, as the chats see them.": "Sus archivos en el servidor, tal como los ven los chats.",
|
||||||
|
"No chat yet. Start one with New chat.": "Aún no hay chats. Empiece uno con Nuevo chat.",
|
||||||
|
"Today": "Hoy",
|
||||||
|
"Earlier": "Anteriores",
|
||||||
|
"A scheduled task has a result.": "Una tarea programada tiene un resultado.",
|
||||||
|
"A scheduled task failed.": "Una tarea programada ha fallado.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Las tareas programadas también le avisan de un resultado mientras la aplicación está abierta.",
|
||||||
|
"Nothing to report": "Nada que informar",
|
||||||
|
"Missed": "Perdida",
|
||||||
|
"Skipped": "Omitida",
|
||||||
|
"Waiting for a free model": "Esperando un modelo libre",
|
||||||
|
"Always": "Siempre",
|
||||||
|
"When it matters": "Cuando importa",
|
||||||
|
"A new chat for each run": "Un chat nuevo en cada ejecución",
|
||||||
|
"One chat that grows": "Un chat que va creciendo",
|
||||||
|
"Not run yet": "Aún no se ha ejecutado",
|
||||||
|
"Paused": "En pausa",
|
||||||
|
"No more runs": "No hay más ejecuciones",
|
||||||
|
"This server has no scheduled tasks yet.": "Este servidor aún no tiene tareas programadas.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Las tareas programadas necesitan un servidor LLeMbas más reciente. Pida a su administrador que lo actualice.",
|
||||||
|
"Nothing is scheduled.": "No hay nada programado.",
|
||||||
|
"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.": "Diga qué debe ocurrir y cuándo —«cada día laborable a las 8:00, resume las noticias de mi lista de lectura»— y el servidor lo hará por usted.",
|
||||||
|
"Pause %(title)s": "Pausar %(title)s",
|
||||||
|
"Resume %(title)s": "Reanudar %(title)s",
|
||||||
|
"Next run": "Próxima ejecución",
|
||||||
|
"Last run": "Última ejecución",
|
||||||
|
"Tell me": "Avisarme",
|
||||||
|
"Runs": "Ejecuciones",
|
||||||
|
"What it does": "Qué hace",
|
||||||
|
"You asked: “%(text)s”": "Usted pidió: «%(text)s»",
|
||||||
|
"The next runs": "Las próximas ejecuciones",
|
||||||
|
"History": "Historial",
|
||||||
|
"It has not run yet.": "Aún no se ha ejecutado.",
|
||||||
|
"late": "con retraso",
|
||||||
|
"caught up": "recuperada",
|
||||||
|
"Open the result": "Abrir el resultado",
|
||||||
|
"Delete this scheduled task?": "¿Eliminar esta tarea programada?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» se detiene y su historial se borra. Los chats que escribió se conservan.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Cada día laborable a las 8:00, resume las noticias de mi lista de lectura",
|
||||||
|
"What should happen, and when": "Qué debe ocurrir, y cuándo",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con sus propias palabras. El servidor las convierte en una regla y una instrucción, y le muestra las próximas ejecuciones antes de guardar nada.",
|
||||||
|
"These words give no run in the future.": "Estas palabras no dan ninguna ejecución futura.",
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -672,27 +851,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nouvelle discussion d'agent",
|
"New agent chat": "Nouvelle discussion d'agent",
|
||||||
"No model is available to this account.": "Aucun modèle n'est disponible pour ce compte.",
|
"No model is available to this account.": "Aucun modèle n'est disponible pour ce compte.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Une discussion d'agent travaille dans un dossier de cet ordinateur, via l'agent intégré à cette application.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Une discussion d'agent travaille dans un dossier de cet ordinateur, via l'agent intégré à cette application.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "L'agent n'est pas encore connecté au serveur : démarrez-le ou reliez cet ordinateur dans les Paramètres.",
|
|
||||||
"Folder": "Dossier",
|
"Folder": "Dossier",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Uniquement les dossiers de l'agent : Paramètres, Dossiers de projet.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Aucun dossier pour l'instant : ajoutez-en un dans Paramètres, Dossiers de projet.",
|
"No folder yet: add one under Settings, Project folders.": "Aucun dossier pour l'instant : ajoutez-en un dans Paramètres, Dossiers de projet.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Une discussion qui demande plus que le dossier ne permet y est ramenée.",
|
|
||||||
"Empty folder": "Dossier vide",
|
"Empty folder": "Dossier vide",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Aucun dossier de projet pour l'instant : l'agent ne peut rien ouvrir sur cet ordinateur.",
|
"No project folder yet: the agent can open nothing on this computer.": "Aucun dossier de projet pour l'instant : l'agent ne peut rien ouvrir sur cet ordinateur.",
|
||||||
"Add folder…": "Ajouter un dossier…",
|
"Add folder…": "Ajouter un dossier…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Aucune discussion d'agent pour l'instant. Commencez-en une : elle travaille dans un dossier de cet ordinateur.",
|
|
||||||
"No chat yet.": "Aucune discussion pour l'instant.",
|
|
||||||
"Other devices": "Autres appareils",
|
|
||||||
"More": "Plus",
|
"More": "Plus",
|
||||||
"The reply is ready.": "La réponse est prête.",
|
"The reply is ready.": "La réponse est prête.",
|
||||||
"The agent has finished.": "L'agent a terminé.",
|
"The agent has finished.": "L'agent a terminé.",
|
||||||
"The agent is off.": "L'agent est arrêté.",
|
"The agent is off.": "L'agent est arrêté.",
|
||||||
"This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.",
|
|
||||||
"The agent is starting…": "L'agent démarre…",
|
"The agent is starting…": "L'agent démarre…",
|
||||||
"The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.",
|
"The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
|
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "L'agent est connecté, mais ne peut encore ouvrir aucun dossier : ajoutez-en un ci-dessous.",
|
|
||||||
"Signed in as %(who)s.": "Connecté en tant que %(who)s.",
|
"Signed in as %(who)s.": "Connecté en tant que %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.",
|
||||||
"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.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
|
"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.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
|
||||||
@@ -752,18 +923,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation",
|
"Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation",
|
||||||
"The agent is connecting to the server…": "L'agent se connecte au serveur…",
|
"The agent is connecting to the server…": "L'agent se connecte au serveur…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "L'agent n'est pas connecté : %(why)s. Il réessaie.",
|
"The agent is not connected: %(why)s. It keeps trying.": "L'agent n'est pas connecté : %(why)s. Il réessaie.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "L'agent ne se connectera pas : %(why)s. Reliez à nouveau cet ordinateur.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s), mais n'est pas connecté : %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s), mais n'est pas connecté : %(why)s",
|
||||||
"agent connecting…": "agent en connexion…",
|
"agent connecting…": "agent en connexion…",
|
||||||
"agent waiting": "agent en attente",
|
"agent waiting": "agent en attente",
|
||||||
"agent refused": "agent refusé",
|
"agent refused": "agent refusé",
|
||||||
"Agent: %(why)s": "Agent : %(why)s",
|
"Agent: %(why)s": "Agent : %(why)s",
|
||||||
"Server files": "Fichiers du serveur",
|
|
||||||
"Drop the files to attach them": "Déposez les fichiers pour les joindre",
|
"Drop the files to attach them": "Déposez les fichiers pour les joindre",
|
||||||
"Continue in a terminal…": "Continuer dans un terminal…",
|
"Continue in a terminal…": "Continuer dans un terminal…",
|
||||||
"Continue in a terminal": "Continuer dans un terminal",
|
"Continue in a terminal": "Continuer dans un terminal",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Exécutez ceci dans un terminal : la session de l'agent s'y poursuit, dans le dossier de cette discussion, et la discussion la conserve.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Exécutez ceci dans un terminal : la session de l'agent s'y poursuit, dans le dossier de cette discussion, et la discussion la conserve.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Exécutez ceci dans un terminal de l'ordinateur sur lequel travaille cette discussion : la session de l'agent s'y poursuit, et la discussion la conserve.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Exécutez ceci dans un terminal de l'ordinateur sur lequel travaille cette discussion : la session de l'agent s'y poursuit, et la discussion la conserve.",
|
||||||
|
"Search chats": "Rechercher dans les conversations",
|
||||||
|
"This computer is not linked yet.": "Cet ordinateur n’est pas encore lié.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Liez-le à votre serveur, et les conversations d’agent pourront travailler dans ses dossiers.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Liez-le à nouveau pour donner un nouveau jeton à l’agent.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "L’agent est connecté, mais ne peut encore ouvrir aucun dossier.",
|
||||||
|
"Hello, %(name)s": "Bonjour, %(name)s",
|
||||||
|
"Hello": "Bonjour",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Une conversation sur %(server)s, avec les modèles et les fichiers de votre compte.",
|
||||||
|
"Your files on the server, as the chats see them.": "Vos fichiers sur le serveur, tels que les conversations les voient.",
|
||||||
|
"No chat yet. Start one with New chat.": "Aucune conversation pour l’instant. Commencez-en une avec Nouvelle conversation.",
|
||||||
|
"Today": "Aujourd’hui",
|
||||||
|
"Earlier": "Plus tôt",
|
||||||
|
"A scheduled task has a result.": "Une tâche planifiée a un résultat.",
|
||||||
|
"A scheduled task failed.": "Une tâche planifiée a échoué.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Les tâches planifiées vous signalent aussi un résultat tant que l’application tourne.",
|
||||||
|
"Nothing to report": "Rien à signaler",
|
||||||
|
"Missed": "Manquée",
|
||||||
|
"Skipped": "Ignorée",
|
||||||
|
"Waiting for a free model": "En attente d’un modèle libre",
|
||||||
|
"Always": "Toujours",
|
||||||
|
"When it matters": "Quand c’est important",
|
||||||
|
"A new chat for each run": "Une nouvelle conversation à chaque exécution",
|
||||||
|
"One chat that grows": "Une conversation qui grandit",
|
||||||
|
"Not run yet": "Pas encore exécutée",
|
||||||
|
"Paused": "En pause",
|
||||||
|
"No more runs": "Plus d’exécution prévue",
|
||||||
|
"This server has no scheduled tasks yet.": "Ce serveur n’a pas encore de tâches planifiées.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Les tâches planifiées demandent un serveur LLeMbas plus récent. Demandez à son administrateur de le mettre à jour.",
|
||||||
|
"Nothing is scheduled.": "Rien n’est planifié.",
|
||||||
|
"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.": "Dites ce qui doit se passer et quand — « chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture » — et le serveur s’en charge pour vous.",
|
||||||
|
"Pause %(title)s": "Mettre %(title)s en pause",
|
||||||
|
"Resume %(title)s": "Reprendre %(title)s",
|
||||||
|
"Next run": "Prochaine exécution",
|
||||||
|
"Last run": "Dernière exécution",
|
||||||
|
"Tell me": "Me prévenir",
|
||||||
|
"Runs": "Exécutions",
|
||||||
|
"What it does": "Ce qu’elle fait",
|
||||||
|
"You asked: “%(text)s”": "Votre demande : « %(text)s »",
|
||||||
|
"The next runs": "Les prochaines exécutions",
|
||||||
|
"History": "Historique",
|
||||||
|
"It has not run yet.": "Elle n’a pas encore été exécutée.",
|
||||||
|
"late": "en retard",
|
||||||
|
"caught up": "rattrapée",
|
||||||
|
"Open the result": "Ouvrir le résultat",
|
||||||
|
"Delete this scheduled task?": "Supprimer cette tâche planifiée ?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "« %(title)s » s’arrête et son historique disparaît. Les conversations qu’elle a écrites restent.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture",
|
||||||
|
"What should happen, and when": "Ce qui doit se passer, et quand",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Avec vos propres mots. Le serveur en fait une règle et une instruction, et vous montre les prochaines exécutions avant que rien ne soit enregistré.",
|
||||||
|
"These words give no run in the future.": "Ces mots ne donnent aucune exécution à venir.",
|
||||||
},
|
},
|
||||||
"hu": {
|
"hu": {
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
@@ -796,27 +1016,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Új ügynökcsevegés",
|
"New agent chat": "Új ügynökcsevegés",
|
||||||
"No model is available to this account.": "Ennek a fióknak nem érhető el modell.",
|
"No model is available to this account.": "Ennek a fióknak nem érhető el modell.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Az ügynökcsevegés egy mappában dolgozik ezen a számítógépen, az alkalmazásba épített ügynökön keresztül.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Az ügynökcsevegés egy mappában dolgozik ezen a számítógépen, az alkalmazásba épített ügynökön keresztül.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Az ügynök még nem kapcsolódik a szerverhez: indítsa el, vagy kapcsolja össze ezt a számítógépet a Beállításokban.",
|
|
||||||
"Folder": "Mappa",
|
"Folder": "Mappa",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Csak az ügynök mappái: Beállítások, Projektmappák.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Még nincs mappa: adjon hozzá egyet a Beállítások, Projektmappák alatt.",
|
"No folder yet: add one under Settings, Project folders.": "Még nincs mappa: adjon hozzá egyet a Beállítások, Projektmappák alatt.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "A csevegés, amely többet kér, mint amit a mappa enged, erre lesz csökkentve.",
|
|
||||||
"Empty folder": "Üres mappa",
|
"Empty folder": "Üres mappa",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Még nincs projektmappa: az ügynök semmit sem nyithat meg ezen a számítógépen.",
|
"No project folder yet: the agent can open nothing on this computer.": "Még nincs projektmappa: az ügynök semmit sem nyithat meg ezen a számítógépen.",
|
||||||
"Add folder…": "Mappa hozzáadása…",
|
"Add folder…": "Mappa hozzáadása…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Még nincs ügynökcsevegés. Indítson egyet: egy mappában dolgozik ezen a számítógépen.",
|
|
||||||
"No chat yet.": "Még nincs csevegés.",
|
|
||||||
"Other devices": "Más eszközök",
|
|
||||||
"More": "Több",
|
"More": "Több",
|
||||||
"The reply is ready.": "A válasz kész.",
|
"The reply is ready.": "A válasz kész.",
|
||||||
"The agent has finished.": "Az ügynök befejezte.",
|
"The agent has finished.": "Az ügynök befejezte.",
|
||||||
"The agent is off.": "Az ügynök ki van kapcsolva.",
|
"The agent is off.": "Az ügynök ki van kapcsolva.",
|
||||||
"This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.",
|
|
||||||
"The agent is starting…": "Az ügynök indul…",
|
"The agent is starting…": "Az ügynök indul…",
|
||||||
"The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.",
|
"The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
|
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "Az ügynök csatlakozott, de még egy mappát sem nyithat meg: adjon hozzá egyet lent.",
|
|
||||||
"Signed in as %(who)s.": "Bejelentkezve: %(who)s.",
|
"Signed in as %(who)s.": "Bejelentkezve: %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.",
|
||||||
"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.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
|
"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.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
|
||||||
@@ -876,18 +1088,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét",
|
"Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét",
|
||||||
"The agent is connecting to the server…": "Az ügynök csatlakozik a szerverhez…",
|
"The agent is connecting to the server…": "Az ügynök csatlakozik a szerverhez…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "Az ügynök nincs csatlakozva: %(why)s. Tovább próbálkozik.",
|
"The agent is not connected: %(why)s. It keeps trying.": "Az ügynök nincs csatlakozva: %(why)s. Tovább próbálkozik.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "Az ügynök nem fog csatlakozni: %(why)s. Kapcsolja össze újra ezt a számítógépet.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Egy LLeMbas CLI szolgáltatás már fut itt (pid %(pid)s), de nincs csatlakozva: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Egy LLeMbas CLI szolgáltatás már fut itt (pid %(pid)s), de nincs csatlakozva: %(why)s",
|
||||||
"agent connecting…": "ügynök csatlakozik…",
|
"agent connecting…": "ügynök csatlakozik…",
|
||||||
"agent waiting": "ügynök vár",
|
"agent waiting": "ügynök vár",
|
||||||
"agent refused": "ügynök elutasítva",
|
"agent refused": "ügynök elutasítva",
|
||||||
"Agent: %(why)s": "Ügynök: %(why)s",
|
"Agent: %(why)s": "Ügynök: %(why)s",
|
||||||
"Server files": "Fájlok a szerveren",
|
|
||||||
"Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz",
|
"Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz",
|
||||||
"Continue in a terminal…": "Folytatás terminálban…",
|
"Continue in a terminal…": "Folytatás terminálban…",
|
||||||
"Continue in a terminal": "Folytatás terminálban",
|
"Continue in a terminal": "Folytatás terminálban",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Futtassa ezt egy terminálban: az ügynök munkamenete ott folytatódik, ennek a csevegésnek a mappájában, és a csevegés megtartja.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Futtassa ezt egy terminálban: az ügynök munkamenete ott folytatódik, ennek a csevegésnek a mappájában, és a csevegés megtartja.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Futtassa ezt egy terminálban azon a számítógépen, amelyen ez a csevegés dolgozik: az ügynök munkamenete ott folytatódik, és a csevegés megtartja.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Futtassa ezt egy terminálban azon a számítógépen, amelyen ez a csevegés dolgozik: az ügynök munkamenete ott folytatódik, és a csevegés megtartja.",
|
||||||
|
"Search chats": "Keresés a beszélgetésekben",
|
||||||
|
"This computer is not linked yet.": "Ez a számítógép még nincs összekapcsolva.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Kapcsolja össze a szerverével, és az ügynökbeszélgetések dolgozhatnak a mappáiban.",
|
||||||
|
"The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Kapcsolja össze újra, és az ügynök új tokent kap.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "Az ügynök csatlakozott, de még egy mappát sem nyithat meg.",
|
||||||
|
"Hello, %(name)s": "Üdv, %(name)s",
|
||||||
|
"Hello": "Üdv",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Beszélgetés itt: %(server)s, a fiókja modelljeivel és fájljaival.",
|
||||||
|
"Your files on the server, as the chats see them.": "Az Ön fájljai a szerveren, ahogy a beszélgetések látják őket.",
|
||||||
|
"No chat yet. Start one with New chat.": "Még nincs beszélgetés. Indítson egyet az Új beszélgetés gombbal.",
|
||||||
|
"Today": "Ma",
|
||||||
|
"Earlier": "Korábban",
|
||||||
|
"A scheduled task has a result.": "Egy ütemezett feladatnak eredménye van.",
|
||||||
|
"A scheduled task failed.": "Egy ütemezett feladat sikertelen volt.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Az ütemezett feladatok az eredményről is szólnak, amíg az alkalmazás fut.",
|
||||||
|
"Nothing to report": "Nincs jelentenivaló",
|
||||||
|
"Missed": "Kimaradt",
|
||||||
|
"Skipped": "Kihagyva",
|
||||||
|
"Waiting for a free model": "Szabad modellre vár",
|
||||||
|
"Always": "Mindig",
|
||||||
|
"When it matters": "Ha számít",
|
||||||
|
"A new chat for each run": "Minden futáshoz új beszélgetés",
|
||||||
|
"One chat that grows": "Egy beszélgetés, amely bővül",
|
||||||
|
"Not run yet": "Még nem futott",
|
||||||
|
"Paused": "Szüneteltetve",
|
||||||
|
"No more runs": "Nincs több futás",
|
||||||
|
"This server has no scheduled tasks yet.": "Ezen a szerveren még nincsenek ütemezett feladatok.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Az ütemezett feladatokhoz újabb LLeMbas-szerver kell. Kérje meg a rendszergazdáját, hogy frissítse.",
|
||||||
|
"Nothing is scheduled.": "Nincs semmi ütemezve.",
|
||||||
|
"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.": "Mondja el, mi történjen és mikor — „minden hétköznap 8:00-kor foglald össze az olvasólistám híreit” —, és a szerver elvégzi Ön helyett.",
|
||||||
|
"Pause %(title)s": "%(title)s szüneteltetése",
|
||||||
|
"Resume %(title)s": "%(title)s folytatása",
|
||||||
|
"Next run": "Következő futás",
|
||||||
|
"Last run": "Utolsó futás",
|
||||||
|
"Tell me": "Értesítés",
|
||||||
|
"Runs": "Futások",
|
||||||
|
"What it does": "Mit csinál",
|
||||||
|
"You asked: “%(text)s”": "Az Ön kérése: „%(text)s”",
|
||||||
|
"The next runs": "A következő futások",
|
||||||
|
"History": "Előzmények",
|
||||||
|
"It has not run yet.": "Még nem futott.",
|
||||||
|
"late": "késve",
|
||||||
|
"caught up": "pótolva",
|
||||||
|
"Open the result": "Az eredmény megnyitása",
|
||||||
|
"Delete this scheduled task?": "Törli ezt az ütemezett feladatot?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” leáll, és az előzményei törlődnek. Az általa írt beszélgetések megmaradnak.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Minden hétköznap 8:00-kor foglald össze az olvasólistám híreit",
|
||||||
|
"What should happen, and when": "Mi történjen, és mikor",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "A saját szavaival. A szerver szabályt és utasítást készít belőlük, és mentés előtt megmutatja a következő futásokat.",
|
||||||
|
"These words give no run in the future.": "Ezekből a szavakból nem következik jövőbeli futás.",
|
||||||
},
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -920,27 +1181,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nuova chat con agente",
|
"New agent chat": "Nuova chat con agente",
|
||||||
"No model is available to this account.": "Nessun modello è disponibile per questo account.",
|
"No model is available to this account.": "Nessun modello è disponibile per questo account.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Una chat con agente lavora in una cartella di questo computer, tramite l'agente integrato in questa app.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Una chat con agente lavora in una cartella di questo computer, tramite l'agente integrato in questa app.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "L'agente non è ancora connesso al server: lo avvii o colleghi questo computer nelle Impostazioni.",
|
|
||||||
"Folder": "Cartella",
|
"Folder": "Cartella",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Solo le cartelle dell'agente: Impostazioni, Cartelle di progetto.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Nessuna cartella ancora: ne aggiunga una in Impostazioni, Cartelle di progetto.",
|
"No folder yet: add one under Settings, Project folders.": "Nessuna cartella ancora: ne aggiunga una in Impostazioni, Cartelle di progetto.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Una chat che chiede più di quanto la cartella consente viene ridotta a quello.",
|
|
||||||
"Empty folder": "Cartella vuota",
|
"Empty folder": "Cartella vuota",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Nessuna cartella di progetto ancora: l'agente non può aprire nulla su questo computer.",
|
"No project folder yet: the agent can open nothing on this computer.": "Nessuna cartella di progetto ancora: l'agente non può aprire nulla su questo computer.",
|
||||||
"Add folder…": "Aggiungi cartella…",
|
"Add folder…": "Aggiungi cartella…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Nessuna chat con agente ancora. Ne inizi una: lavora in una cartella di questo computer.",
|
|
||||||
"No chat yet.": "Nessuna chat ancora.",
|
|
||||||
"Other devices": "Altri dispositivi",
|
|
||||||
"More": "Altro",
|
"More": "Altro",
|
||||||
"The reply is ready.": "La risposta è pronta.",
|
"The reply is ready.": "La risposta è pronta.",
|
||||||
"The agent has finished.": "L'agente ha finito.",
|
"The agent has finished.": "L'agente ha finito.",
|
||||||
"The agent is off.": "L'agente è spento.",
|
"The agent is off.": "L'agente è spento.",
|
||||||
"This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.",
|
|
||||||
"The agent is starting…": "L'agente si sta avviando…",
|
"The agent is starting…": "L'agente si sta avviando…",
|
||||||
"The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.",
|
"The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
|
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "L'agente è connesso, ma non può ancora aprire nessuna cartella: aggiungine una qui sotto.",
|
|
||||||
"Signed in as %(who)s.": "Accesso effettuato come %(who)s.",
|
"Signed in as %(who)s.": "Accesso effettuato come %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.",
|
||||||
"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.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
|
"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.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
|
||||||
@@ -1000,18 +1253,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Digiti un comando o il titolo di una chat",
|
"Type a command or a chat's title": "Digiti un comando o il titolo di una chat",
|
||||||
"The agent is connecting to the server…": "L'agente si sta collegando al server…",
|
"The agent is connecting to the server…": "L'agente si sta collegando al server…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "L'agente non è collegato: %(why)s. Continua a riprovare.",
|
"The agent is not connected: %(why)s. It keeps trying.": "L'agente non è collegato: %(why)s. Continua a riprovare.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "L'agente non si collegherà: %(why)s. Colleghi di nuovo questo computer.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s), ma non è collegato: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s), ma non è collegato: %(why)s",
|
||||||
"agent connecting…": "agente in collegamento…",
|
"agent connecting…": "agente in collegamento…",
|
||||||
"agent waiting": "agente in attesa",
|
"agent waiting": "agente in attesa",
|
||||||
"agent refused": "agente rifiutato",
|
"agent refused": "agente rifiutato",
|
||||||
"Agent: %(why)s": "Agente: %(why)s",
|
"Agent: %(why)s": "Agente: %(why)s",
|
||||||
"Server files": "File sul server",
|
|
||||||
"Drop the files to attach them": "Rilasci i file per allegarli",
|
"Drop the files to attach them": "Rilasci i file per allegarli",
|
||||||
"Continue in a terminal…": "Continua in un terminale…",
|
"Continue in a terminal…": "Continua in un terminale…",
|
||||||
"Continue in a terminal": "Continua in un terminale",
|
"Continue in a terminal": "Continua in un terminale",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Eseguilo in un terminale: la sessione dell'agente prosegue lì, nella cartella di questa chat, e la chat la conserva.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Eseguilo in un terminale: la sessione dell'agente prosegue lì, nella cartella di questa chat, e la chat la conserva.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Eseguilo in un terminale sul computer su cui lavora questa chat: la sessione dell'agente prosegue lì e la chat la conserva.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Eseguilo in un terminale sul computer su cui lavora questa chat: la sessione dell'agente prosegue lì e la chat la conserva.",
|
||||||
|
"Search chats": "Cerca nelle chat",
|
||||||
|
"This computer is not linked yet.": "Questo computer non è ancora collegato.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Collegalo al tuo server e le chat dell’agente potranno lavorare nelle sue cartelle.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Collegalo di nuovo per dare all’agente un nuovo token.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "L’agente è connesso, ma non può ancora aprire nessuna cartella.",
|
||||||
|
"Hello, %(name)s": "Ciao, %(name)s",
|
||||||
|
"Hello": "Ciao",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Una chat su %(server)s, con i modelli e i file del tuo account.",
|
||||||
|
"Your files on the server, as the chats see them.": "I tuoi file sul server, come li vedono le chat.",
|
||||||
|
"No chat yet. Start one with New chat.": "Ancora nessuna chat. Iniziane una con Nuova chat.",
|
||||||
|
"Today": "Oggi",
|
||||||
|
"Earlier": "Prima",
|
||||||
|
"A scheduled task has a result.": "Un’attività pianificata ha un risultato.",
|
||||||
|
"A scheduled task failed.": "Un’attività pianificata non è riuscita.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Le attività pianificate ti avvisano anche di un risultato, mentre l’app è in esecuzione.",
|
||||||
|
"Nothing to report": "Niente da segnalare",
|
||||||
|
"Missed": "Persa",
|
||||||
|
"Skipped": "Saltata",
|
||||||
|
"Waiting for a free model": "In attesa di un modello libero",
|
||||||
|
"Always": "Sempre",
|
||||||
|
"When it matters": "Quando conta",
|
||||||
|
"A new chat for each run": "Una nuova chat per ogni esecuzione",
|
||||||
|
"One chat that grows": "Una chat che cresce",
|
||||||
|
"Not run yet": "Non ancora eseguita",
|
||||||
|
"Paused": "In pausa",
|
||||||
|
"No more runs": "Nessun’altra esecuzione",
|
||||||
|
"This server has no scheduled tasks yet.": "Questo server non ha ancora attività pianificate.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Le attività pianificate richiedono un server LLeMbas più recente. Chiedi al suo amministratore di aggiornarlo.",
|
||||||
|
"Nothing is scheduled.": "Non c’è niente di pianificato.",
|
||||||
|
"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.": "Di’ cosa deve succedere e quando — «ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura» — e il server lo fa per te.",
|
||||||
|
"Pause %(title)s": "Metti in pausa %(title)s",
|
||||||
|
"Resume %(title)s": "Riprendi %(title)s",
|
||||||
|
"Next run": "Prossima esecuzione",
|
||||||
|
"Last run": "Ultima esecuzione",
|
||||||
|
"Tell me": "Avvisami",
|
||||||
|
"Runs": "Esecuzioni",
|
||||||
|
"What it does": "Cosa fa",
|
||||||
|
"You asked: “%(text)s”": "Hai chiesto: «%(text)s»",
|
||||||
|
"The next runs": "Le prossime esecuzioni",
|
||||||
|
"History": "Cronologia",
|
||||||
|
"It has not run yet.": "Non è ancora stata eseguita.",
|
||||||
|
"late": "in ritardo",
|
||||||
|
"caught up": "recuperata",
|
||||||
|
"Open the result": "Apri il risultato",
|
||||||
|
"Delete this scheduled task?": "Eliminare questa attività pianificata?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» si ferma e la sua cronologia viene eliminata. Le chat che ha scritto restano.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura",
|
||||||
|
"What should happen, and when": "Cosa deve succedere, e quando",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con parole tue. Il server ne ricava una regola e un’istruzione e ti mostra le prossime esecuzioni prima di salvare.",
|
||||||
|
"These words give no run in the future.": "Queste parole non danno nessuna esecuzione futura.",
|
||||||
},
|
},
|
||||||
"pl": {
|
"pl": {
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
@@ -1044,27 +1346,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nowy czat z agentem",
|
"New agent chat": "Nowy czat z agentem",
|
||||||
"No model is available to this account.": "Dla tego konta nie jest dostępny żaden model.",
|
"No model is available to this account.": "Dla tego konta nie jest dostępny żaden model.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Czat z agentem pracuje w folderze na tym komputerze, przez agenta wbudowanego w tę aplikację.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Czat z agentem pracuje w folderze na tym komputerze, przez agenta wbudowanego w tę aplikację.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent nie jest jeszcze połączony z serwerem: uruchom go lub połącz ten komputer w Ustawieniach.",
|
|
||||||
"Folder": "Folder",
|
"Folder": "Folder",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Tylko foldery agenta: Ustawienia, Foldery projektów.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Jeszcze nie ma folderu: dodaj go w Ustawieniach, Foldery projektów.",
|
"No folder yet: add one under Settings, Project folders.": "Jeszcze nie ma folderu: dodaj go w Ustawieniach, Foldery projektów.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Czat, który chce więcej, niż folder pozwala, zostaje do tego obniżony.",
|
|
||||||
"Empty folder": "Pusty folder",
|
"Empty folder": "Pusty folder",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Jeszcze nie ma folderu projektu: agent nie może niczego otworzyć na tym komputerze.",
|
"No project folder yet: the agent can open nothing on this computer.": "Jeszcze nie ma folderu projektu: agent nie może niczego otworzyć na tym komputerze.",
|
||||||
"Add folder…": "Dodaj folder…",
|
"Add folder…": "Dodaj folder…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Jeszcze nie ma czatu z agentem. Zacznij go: pracuje w folderze na tym komputerze.",
|
|
||||||
"No chat yet.": "Jeszcze nie ma czatu.",
|
|
||||||
"Other devices": "Inne urządzenia",
|
|
||||||
"More": "Więcej",
|
"More": "Więcej",
|
||||||
"The reply is ready.": "Odpowiedź jest gotowa.",
|
"The reply is ready.": "Odpowiedź jest gotowa.",
|
||||||
"The agent has finished.": "Agent skończył.",
|
"The agent has finished.": "Agent skończył.",
|
||||||
"The agent is off.": "Agent jest wyłączony.",
|
"The agent is off.": "Agent jest wyłączony.",
|
||||||
"This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.",
|
|
||||||
"The agent is starting…": "Agent się uruchamia…",
|
"The agent is starting…": "Agent się uruchamia…",
|
||||||
"The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.",
|
"The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
|
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu: dodaj go poniżej.",
|
|
||||||
"Signed in as %(who)s.": "Zalogowano jako %(who)s.",
|
"Signed in as %(who)s.": "Zalogowano jako %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.",
|
||||||
"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.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
|
"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.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
|
||||||
@@ -1124,18 +1418,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu",
|
"Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu",
|
||||||
"The agent is connecting to the server…": "Agent łączy się z serwerem…",
|
"The agent is connecting to the server…": "Agent łączy się z serwerem…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie jest połączony: %(why)s. Próbuje dalej.",
|
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie jest połączony: %(why)s. Próbuje dalej.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent się nie połączy: %(why)s. Połącz ten komputer ponownie.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Usługa LLeMbas CLI już tu działa (pid %(pid)s), ale nie jest połączona: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Usługa LLeMbas CLI już tu działa (pid %(pid)s), ale nie jest połączona: %(why)s",
|
||||||
"agent connecting…": "agent łączy się…",
|
"agent connecting…": "agent łączy się…",
|
||||||
"agent waiting": "agent czeka",
|
"agent waiting": "agent czeka",
|
||||||
"agent refused": "agent odrzucony",
|
"agent refused": "agent odrzucony",
|
||||||
"Agent: %(why)s": "Agent: %(why)s",
|
"Agent: %(why)s": "Agent: %(why)s",
|
||||||
"Server files": "Pliki na serwerze",
|
|
||||||
"Drop the files to attach them": "Upuść pliki, aby je dołączyć",
|
"Drop the files to attach them": "Upuść pliki, aby je dołączyć",
|
||||||
"Continue in a terminal…": "Kontynuuj w terminalu…",
|
"Continue in a terminal…": "Kontynuuj w terminalu…",
|
||||||
"Continue in a terminal": "Kontynuuj w terminalu",
|
"Continue in a terminal": "Kontynuuj w terminalu",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Uruchom to w terminalu: sesja agenta będzie tam kontynuowana, w folderze tego czatu, a czat ją zachowa.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Uruchom to w terminalu: sesja agenta będzie tam kontynuowana, w folderze tego czatu, a czat ją zachowa.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Uruchom to w terminalu na komputerze, na którym pracuje ten czat: sesja agenta będzie tam kontynuowana, a czat ją zachowa.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Uruchom to w terminalu na komputerze, na którym pracuje ten czat: sesja agenta będzie tam kontynuowana, a czat ją zachowa.",
|
||||||
|
"Search chats": "Szukaj w czatach",
|
||||||
|
"This computer is not linked yet.": "Ten komputer nie jest jeszcze połączony.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Połącz go ze swoim serwerem, a czaty agenta będą mogły pracować w jego folderach.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Połącz go ponownie, aby agent dostał nowy token.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu.",
|
||||||
|
"Hello, %(name)s": "Cześć, %(name)s",
|
||||||
|
"Hello": "Cześć",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Czat na %(server)s, z modelami i plikami Twojego konta.",
|
||||||
|
"Your files on the server, as the chats see them.": "Twoje pliki na serwerze, tak jak widzą je czaty.",
|
||||||
|
"No chat yet. Start one with New chat.": "Jeszcze nie ma czatu. Zacznij go przyciskiem Nowy czat.",
|
||||||
|
"Today": "Dzisiaj",
|
||||||
|
"Earlier": "Wcześniej",
|
||||||
|
"A scheduled task has a result.": "Zaplanowane zadanie ma wynik.",
|
||||||
|
"A scheduled task failed.": "Zaplanowane zadanie nie powiodło się.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Zaplanowane zadania informują też o wyniku, dopóki aplikacja działa.",
|
||||||
|
"Nothing to report": "Nic do zgłoszenia",
|
||||||
|
"Missed": "Opuszczone",
|
||||||
|
"Skipped": "Pominięte",
|
||||||
|
"Waiting for a free model": "Czeka na wolny model",
|
||||||
|
"Always": "Zawsze",
|
||||||
|
"When it matters": "Gdy to ważne",
|
||||||
|
"A new chat for each run": "Nowy czat dla każdego uruchomienia",
|
||||||
|
"One chat that grows": "Jeden czat, który rośnie",
|
||||||
|
"Not run yet": "Jeszcze nie uruchomione",
|
||||||
|
"Paused": "Wstrzymane",
|
||||||
|
"No more runs": "Brak kolejnych uruchomień",
|
||||||
|
"This server has no scheduled tasks yet.": "Ten serwer nie ma jeszcze zaplanowanych zadań.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Zaplanowane zadania wymagają nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.",
|
||||||
|
"Nothing is scheduled.": "Nic nie jest zaplanowane.",
|
||||||
|
"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.": "Powiedz, co ma się stać i kiedy — „w każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania” — a serwer zrobi to za Ciebie.",
|
||||||
|
"Pause %(title)s": "Wstrzymaj %(title)s",
|
||||||
|
"Resume %(title)s": "Wznów %(title)s",
|
||||||
|
"Next run": "Następne uruchomienie",
|
||||||
|
"Last run": "Ostatnie uruchomienie",
|
||||||
|
"Tell me": "Powiadamiaj",
|
||||||
|
"Runs": "Uruchomienia",
|
||||||
|
"What it does": "Co robi",
|
||||||
|
"You asked: “%(text)s”": "Twoja prośba: „%(text)s”",
|
||||||
|
"The next runs": "Następne uruchomienia",
|
||||||
|
"History": "Historia",
|
||||||
|
"It has not run yet.": "Jeszcze nie było uruchomione.",
|
||||||
|
"late": "z opóźnieniem",
|
||||||
|
"caught up": "nadrobione",
|
||||||
|
"Open the result": "Otwórz wynik",
|
||||||
|
"Delete this scheduled task?": "Usunąć to zaplanowane zadanie?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” zatrzyma się, a jego historia zniknie. Czaty, które napisało, zostaną.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "W każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania",
|
||||||
|
"What should happen, and when": "Co ma się stać i kiedy",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Własnymi słowami. Serwer zamieni je w regułę i polecenie i pokaże następne uruchomienia, zanim cokolwiek zostanie zapisane.",
|
||||||
|
"These words give no run in the future.": "Z tych słów nie wynika żadne przyszłe uruchomienie.",
|
||||||
},
|
},
|
||||||
"sk": {
|
"sk": {
|
||||||
"Menu": "Ponuka",
|
"Menu": "Ponuka",
|
||||||
@@ -1168,27 +1511,19 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"New agent chat": "Nová agentská konverzácia",
|
"New agent chat": "Nová agentská konverzácia",
|
||||||
"No model is available to this account.": "Tomuto účtu nie je dostupný žiadny model.",
|
"No model is available to this account.": "Tomuto účtu nie je dostupný žiadny model.",
|
||||||
"An agent chat works in a folder on this computer, through the agent built into this app.": "Agentská konverzácia pracuje v priečinku na tomto počítači cez agenta vstavaného v tejto aplikácii.",
|
"An agent chat works in a folder on this computer, through the agent built into this app.": "Agentská konverzácia pracuje v priečinku na tomto počítači cez agenta vstavaného v tejto aplikácii.",
|
||||||
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent ešte nie je pripojený k serveru: spustite ho alebo prepojte tento počítač v Nastaveniach.",
|
|
||||||
"Folder": "Priečinok",
|
"Folder": "Priečinok",
|
||||||
"Only the agent's folders: Settings, Project folders.": "Len priečinky agenta: Nastavenia, Priečinky projektov.",
|
|
||||||
"No folder yet: add one under Settings, Project folders.": "Zatiaľ žiadny priečinok: pridajte ho v Nastaveniach, Priečinky projektov.",
|
"No folder yet: add one under Settings, Project folders.": "Zatiaľ žiadny priečinok: pridajte ho v Nastaveniach, Priečinky projektov.",
|
||||||
"A chat asking for more than the folder allows is lowered to it.": "Konverzácia, ktorá chce viac, než priečinok dovoľuje, je znížená na to.",
|
|
||||||
"Empty folder": "Prázdny priečinok",
|
"Empty folder": "Prázdny priečinok",
|
||||||
"No project folder yet: the agent can open nothing on this computer.": "Zatiaľ žiadny priečinok projektu: agent na tomto počítači nemôže nič otvoriť.",
|
"No project folder yet: the agent can open nothing on this computer.": "Zatiaľ žiadny priečinok projektu: agent na tomto počítači nemôže nič otvoriť.",
|
||||||
"Add folder…": "Pridať priečinok…",
|
"Add folder…": "Pridať priečinok…",
|
||||||
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatiaľ žiadna agentská konverzácia. Začnite ju: pracuje v priečinku na tomto počítači.",
|
|
||||||
"No chat yet.": "Zatiaľ žiadna konverzácia.",
|
|
||||||
"Other devices": "Iné zariadenia",
|
|
||||||
"More": "Viac",
|
"More": "Viac",
|
||||||
"The reply is ready.": "Odpoveď je hotová.",
|
"The reply is ready.": "Odpoveď je hotová.",
|
||||||
"The agent has finished.": "Agent dokončil prácu.",
|
"The agent has finished.": "Agent dokončil prácu.",
|
||||||
"The agent is off.": "Agent je vypnutý.",
|
"The agent is off.": "Agent je vypnutý.",
|
||||||
"This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.",
|
|
||||||
"The agent is starting…": "Agent sa spúšťa…",
|
"The agent is starting…": "Agent sa spúšťa…",
|
||||||
"The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.",
|
"The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.",
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.",
|
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.",
|
||||||
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
|
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
|
||||||
"The agent is connected, but may open no folder yet: add one below.": "Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok: pridajte ho nižšie.",
|
|
||||||
"Signed in as %(who)s.": "Prihlásený ako %(who)s.",
|
"Signed in as %(who)s.": "Prihlásený ako %(who)s.",
|
||||||
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.",
|
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.",
|
||||||
"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.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
|
"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.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
|
||||||
@@ -1248,18 +1583,67 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie",
|
"Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie",
|
||||||
"The agent is connecting to the server…": "Agent sa pripája k serveru…",
|
"The agent is connecting to the server…": "Agent sa pripája k serveru…",
|
||||||
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie je pripojený: %(why)s. Skúša to ďalej.",
|
"The agent is not connected: %(why)s. It keeps trying.": "Agent nie je pripojený: %(why)s. Skúša to ďalej.",
|
||||||
"The agent will not connect: %(why)s. Link this computer again.": "Agent sa nepripojí: %(why)s. Prepojte tento počítač znova.",
|
|
||||||
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI tu už beží (pid %(pid)s), ale nie je pripojená: %(why)s",
|
"A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s": "Služba LLeMbas CLI tu už beží (pid %(pid)s), ale nie je pripojená: %(why)s",
|
||||||
"agent connecting…": "agent sa pripája…",
|
"agent connecting…": "agent sa pripája…",
|
||||||
"agent waiting": "agent čaká",
|
"agent waiting": "agent čaká",
|
||||||
"agent refused": "agent odmietnutý",
|
"agent refused": "agent odmietnutý",
|
||||||
"Agent: %(why)s": "Agent: %(why)s",
|
"Agent: %(why)s": "Agent: %(why)s",
|
||||||
"Server files": "Súbory na serveri",
|
|
||||||
"Drop the files to attach them": "Pustite súbory a priložia sa",
|
"Drop the files to attach them": "Pustite súbory a priložia sa",
|
||||||
"Continue in a terminal…": "Pokračovať v termináli…",
|
"Continue in a terminal…": "Pokračovať v termináli…",
|
||||||
"Continue in a terminal": "Pokračovať v termináli",
|
"Continue in a terminal": "Pokračovať v termináli",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spustite to v termináli: relácia agenta tam pokračuje, v priečinku tejto konverzácie, a konverzácia si ju ponechá.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.": "Spustite to v termináli: relácia agenta tam pokračuje, v priečinku tejto konverzácie, a konverzácia si ju ponechá.",
|
||||||
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spustite to v termináli na počítači, na ktorom táto konverzácia pracuje: relácia agenta tam pokračuje a konverzácia si ju ponechá.",
|
"Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it.": "Spustite to v termináli na počítači, na ktorom táto konverzácia pracuje: relácia agenta tam pokračuje a konverzácia si ju ponechá.",
|
||||||
|
"Search chats": "Hľadať v konverzáciách",
|
||||||
|
"This computer is not linked yet.": "Tento počítač ešte nie je prepojený.",
|
||||||
|
"Link it to your server, and agent chats can work in its folders.": "Prepojte ho so svojím serverom a agentné konverzácie budú môcť pracovať v jeho priečinkoch.",
|
||||||
|
"The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s",
|
||||||
|
"Link it again to give the agent a new token.": "Prepojte ho znova a agent dostane nový token.",
|
||||||
|
"The agent is connected, but may open no folder yet.": "Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok.",
|
||||||
|
"Hello, %(name)s": "Dobrý deň, %(name)s",
|
||||||
|
"Hello": "Dobrý deň",
|
||||||
|
"A chat on %(server)s, with your account's models and files.": "Konverzácia na %(server)s s modelmi a súbormi vášho účtu.",
|
||||||
|
"Your files on the server, as the chats see them.": "Vaše súbory na serveri, tak ako ich vidia konverzácie.",
|
||||||
|
"No chat yet. Start one with New chat.": "Zatiaľ žiadna konverzácia. Začnite ju tlačidlom Nová konverzácia.",
|
||||||
|
"Today": "Dnes",
|
||||||
|
"Earlier": "Staršie",
|
||||||
|
"A scheduled task has a result.": "Naplánovaná úloha má výsledok.",
|
||||||
|
"A scheduled task failed.": "Naplánovaná úloha zlyhala.",
|
||||||
|
"Scheduled tasks tell you of a result, too, while the app runs.": "Naplánované úlohy vám dajú vedieť aj o výsledku, kým aplikácia beží.",
|
||||||
|
"Nothing to report": "Nič na hlásenie",
|
||||||
|
"Missed": "Zmeškané",
|
||||||
|
"Skipped": "Preskočené",
|
||||||
|
"Waiting for a free model": "Čaká na voľný model",
|
||||||
|
"Always": "Vždy",
|
||||||
|
"When it matters": "Keď na tom záleží",
|
||||||
|
"A new chat for each run": "Nová konverzácia pre každé spustenie",
|
||||||
|
"One chat that grows": "Jedna konverzácia, ktorá rastie",
|
||||||
|
"Not run yet": "Ešte nebežala",
|
||||||
|
"Paused": "Pozastavené",
|
||||||
|
"No more runs": "Žiadne ďalšie spustenia",
|
||||||
|
"This server has no scheduled tasks yet.": "Tento server zatiaľ nemá naplánované úlohy.",
|
||||||
|
"Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.": "Naplánované úlohy potrebujú novší server LLeMbas. Požiadajte jeho správcu o aktualizáciu.",
|
||||||
|
"Nothing is scheduled.": "Nič nie je naplánované.",
|
||||||
|
"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.": "Povedzte, čo sa má stať a kedy — „každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie“ — a server to urobí za vás.",
|
||||||
|
"Pause %(title)s": "Pozastaviť %(title)s",
|
||||||
|
"Resume %(title)s": "Obnoviť %(title)s",
|
||||||
|
"Next run": "Ďalšie spustenie",
|
||||||
|
"Last run": "Posledné spustenie",
|
||||||
|
"Tell me": "Dať vedieť",
|
||||||
|
"Runs": "Spustenia",
|
||||||
|
"What it does": "Čo robí",
|
||||||
|
"You asked: “%(text)s”": "Požiadali ste: „%(text)s“",
|
||||||
|
"The next runs": "Ďalšie spustenia",
|
||||||
|
"History": "História",
|
||||||
|
"It has not run yet.": "Ešte nebežala.",
|
||||||
|
"late": "neskoro",
|
||||||
|
"caught up": "dobehnuté",
|
||||||
|
"Open the result": "Otvoriť výsledok",
|
||||||
|
"Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?",
|
||||||
|
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ sa zastaví a jej história zmizne. Konverzácie, ktoré napísala, zostanú.",
|
||||||
|
"Every weekday at 8:00, summarise the news on my reading list": "Každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie",
|
||||||
|
"What should happen, and when": "Čo sa má stať a kedy",
|
||||||
|
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastnými slovami. Server z nich vytvorí pravidlo a pokyn a pred uložením vám ukáže ďalšie spustenia.",
|
||||||
|
"These words give no run in the future.": "Z týchto slov nevychádza žiadne budúce spustenie.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-11
@@ -45,8 +45,9 @@ pub enum State {
|
|||||||
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||||
/// the server cannot be reached, the link dropped).
|
/// the server cannot be reached, the link dropped).
|
||||||
Waiting(String),
|
Waiting(String),
|
||||||
/// The CLI will not try again by itself: the server refused this computer's token, or the
|
/// The server refused this computer's link, in its words (a token it no longer takes, or a
|
||||||
/// login is missing. Linking again is the way out.
|
/// 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),
|
Refused(String),
|
||||||
/// Another `lembas service` on this computer holds the link.
|
/// Another `lembas service` on this computer holds the link.
|
||||||
Elsewhere(Elsewhere),
|
Elsewhere(Elsewhere),
|
||||||
@@ -108,6 +109,32 @@ pub fn from_link(link: &cli::Link, before: &State) -> State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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`.
|
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||||
fn from_output(line: &str) -> Option<State> {
|
fn from_output(line: &str) -> Option<State> {
|
||||||
@@ -252,15 +279,12 @@ impl Agent {
|
|||||||
Some(RESTART_FOR_UPDATE) => continue,
|
Some(RESTART_FOR_UPDATE) => continue,
|
||||||
Some(0) => {
|
Some(0) => {
|
||||||
// It ended by itself without an error: the CLI's word for "starting again
|
// It ended by itself without an error: the CLI's word for "starting again
|
||||||
// will not help" -- the server refused the token, or there is no login.
|
// will not help". Waiting a long while (linking again starts it at once).
|
||||||
// Waiting a long while (linking again starts it at once).
|
let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
|
||||||
let why = cli::link_of(pid)
|
let next = ended_cleanly(stopped.as_ref(), said.as_deref());
|
||||||
.filter(|l| l.state == "stopped")
|
match next {
|
||||||
.and_then(|l| l.detail)
|
Some(state) => {
|
||||||
.or_else(|| said.map(|s| strip_prefix(&s)));
|
if !self.set(generation, state) {
|
||||||
match why {
|
|
||||||
Some(why) => {
|
|
||||||
if !self.set(generation, State::Refused(why)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.pause(generation, REFUSED_WAIT);
|
self.pause(generation, REFUSED_WAIT);
|
||||||
@@ -436,6 +460,31 @@ fn strip_prefix(line: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||||
cli::Link {
|
cli::Link {
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
|
|||||||
+106
-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;
|
||||||
@@ -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.
|
||||||
@@ -286,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>();
|
||||||
@@ -306,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()
|
||||||
@@ -346,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);
|
||||||
}
|
}
|
||||||
@@ -779,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)?;
|
||||||
@@ -885,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,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())
|
||||||
@@ -1317,7 +1368,8 @@ fn on_agent_state(handle: &AppHandle, state: &AgentState) {
|
|||||||
announce(handle);
|
announce(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look at the chat list every so often, and say when a reply finished out of sight.
|
/// Look at the chat list every so often, and say when a reply finished out of sight; and at the
|
||||||
|
/// scheduled tasks, and say when one has a result the server tells of.
|
||||||
fn watch_chats(handle: AppHandle) {
|
fn watch_chats(handle: AppHandle) {
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
std::thread::sleep(watch::EVERY);
|
std::thread::sleep(watch::EVERY);
|
||||||
@@ -1335,7 +1387,11 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let done = app.watch.look(&list);
|
let done = app.watch.look(&list);
|
||||||
if done.is_empty() || !app.settings().notify {
|
// Scheduled tasks: a server without the routes (404) has none to tell of.
|
||||||
|
let ran = server::request(&app_url, &token, "GET", "/schedules", None)
|
||||||
|
.map(|v| app.watch.look_schedules(&v))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if (done.is_empty() && ran.is_empty()) || !app.settings().notify {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let in_front = main_window(&handle)
|
let in_front = main_window(&handle)
|
||||||
@@ -1358,6 +1414,24 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
.body(if d.agent { &words.agent } else { &words.reply })
|
.body(if d.agent { &words.agent } else { &words.reply })
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
// A scheduled result is said even with the window in front: it ran on its own.
|
||||||
|
for r in ran {
|
||||||
|
let title = if r.title.is_empty() {
|
||||||
|
"LLeMbas".to_string()
|
||||||
|
} else {
|
||||||
|
r.title
|
||||||
|
};
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(if r.failed {
|
||||||
|
&words.scheduled_failed
|
||||||
|
} else {
|
||||||
|
&words.scheduled
|
||||||
|
})
|
||||||
|
.show();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,6 +1534,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,
|
||||||
@@ -1496,6 +1571,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),
|
||||||
|
|||||||
+74
-14
@@ -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()
|
||||||
@@ -509,22 +539,32 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
|
|||||||
|
|
||||||
/// The most a model's picture may be: the server's own limit for one (2 MB), and a little room.
|
/// 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;
|
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
|
/// 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.
|
/// upload. Nothing else is shown, whatever the server says it is.
|
||||||
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||||
|
|
||||||
/// A model's picture path as `/api/v2/models` names it (`/api/v2/models/images/<file>`), on the
|
/// A picture's path on the app's door, or None for anything else: a model's picture as
|
||||||
/// app's door: `/models/images/<file>`, or None for anything else. One plain file name, no more.
|
/// `/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> {
|
pub fn image_path(path: &str) -> Option<String> {
|
||||||
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
||||||
let name = rest.strip_prefix("/models/images/")?;
|
let plain = |name: &str| {
|
||||||
let plain = !name.is_empty()
|
!name.is_empty()
|
||||||
&& name
|
&& name
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||||
&& !name.starts_with('.');
|
&& !name.starts_with('.')
|
||||||
plain.then(|| format!("/models/images/{name}"))
|
};
|
||||||
|
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
|
/// A model's picture, as a `data:` URL the page can show without a request of its own (its
|
||||||
@@ -532,7 +572,7 @@ pub fn image_path(path: &str) -> Option<String> {
|
|||||||
/// token, as the page reads nothing itself.
|
/// token, as the page reads nothing itself.
|
||||||
pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
||||||
let Some(path) = image_path(path) else {
|
let Some(path) = image_path(path) else {
|
||||||
return Err(ApiError::new(400, format!("Not a model's picture: {path}")));
|
return Err(ApiError::new(400, format!("Not a picture's path: {path}")));
|
||||||
};
|
};
|
||||||
let res = agent(Duration::from_secs(30))
|
let res = agent(Duration::from_secs(30))
|
||||||
.get(&format!("{app}{path}"))
|
.get(&format!("{app}{path}"))
|
||||||
@@ -548,12 +588,17 @@ pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiEr
|
|||||||
if !IMAGE_TYPES.contains(&kind.as_str()) {
|
if !IMAGE_TYPES.contains(&kind.as_str()) {
|
||||||
return Err(ApiError::new(415, format!("Not a picture: {kind}")));
|
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();
|
let mut bytes = Vec::new();
|
||||||
res.into_reader()
|
res.into_reader()
|
||||||
.take(IMAGE_LIMIT)
|
.take(limit)
|
||||||
.read_to_end(&mut bytes)
|
.read_to_end(&mut bytes)
|
||||||
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||||
if bytes.len() as u64 >= IMAGE_LIMIT {
|
if bytes.len() as u64 >= limit {
|
||||||
return Err(ApiError::new(413, "The picture is too large."));
|
return Err(ApiError::new(413, "The picture is too large."));
|
||||||
}
|
}
|
||||||
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||||
@@ -793,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!({
|
||||||
@@ -874,12 +927,19 @@ mod tests {
|
|||||||
image_path("/models/images/a-b_c.webp").as_deref(),
|
image_path("/models/images/a-b_c.webp").as_deref(),
|
||||||
Some("/models/images/a-b_c.webp")
|
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 [
|
for bad in [
|
||||||
"/api/v2/models/images/",
|
"/api/v2/models/images/",
|
||||||
"/api/v2/models/images/../me",
|
"/api/v2/models/images/../me",
|
||||||
"/api/v2/models/images/a/b.png",
|
"/api/v2/models/images/a/b.png",
|
||||||
"/api/v2/models/images/.hidden",
|
"/api/v2/models/images/.hidden",
|
||||||
"/api/v2/chats/x",
|
"/api/v2/chats/x",
|
||||||
|
"/api/v2/attachments/",
|
||||||
|
"/api/v2/attachments/a/b",
|
||||||
|
"/api/v2/attachments/..",
|
||||||
"/uploads/models/a.png",
|
"/uploads/models/a.png",
|
||||||
"https://elsewhere/models/images/a.png",
|
"https://elsewhere/models/images/a.png",
|
||||||
] {
|
] {
|
||||||
|
|||||||
+107
-1
@@ -1,11 +1,17 @@
|
|||||||
//! 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 asks `/api/v2/schedules` as often, and a schedule whose last run is a
|
||||||
|
//! new one, with the server's word that it is worth telling (`notified`: its notify policy, and a
|
||||||
|
//! `[SILENT]` run not), is said once -- by the schedule's title, never the result's words. A
|
||||||
|
//! server without the routes answers 404, and nothing is said.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -23,6 +29,20 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_word() -> String {
|
||||||
|
"A scheduled task has a result.".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_failed_word() -> String {
|
||||||
|
"A scheduled task failed.".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Words {
|
impl Default for Words {
|
||||||
@@ -30,6 +50,8 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +71,15 @@ 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 (None before the first look).
|
||||||
|
runs: Mutex<Option<HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scheduled task's new run that the server says is worth telling of.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Ran {
|
||||||
|
pub title: String,
|
||||||
|
pub failed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Watch {
|
impl Watch {
|
||||||
@@ -89,9 +120,50 @@ impl Watch {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note the schedules as the server gave them; the new runs to tell of since the last look.
|
||||||
|
/// The first look only notes, as with the chats.
|
||||||
|
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 before = seen.as_ref().and_then(|s| s.get(id));
|
||||||
|
let told = run.get("notified").and_then(Value::as_bool) == Some(true);
|
||||||
|
if !first && before.map(String::as_str) != Some(run_id) && told {
|
||||||
|
out.push(Ran {
|
||||||
|
title: row
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
failed: run.get("status").and_then(Value::as_str) == Some("failed"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
now.insert(id.to_string(), run_id.to_string());
|
||||||
|
}
|
||||||
|
*seen = Some(now);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +172,40 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A schedule's last run in a test: its id, status, and whether the server tells of it.
|
||||||
|
type LastRun<'a> = Option<(&'a str, &'a str, bool)>;
|
||||||
|
|
||||||
|
fn schedules(rows: &[(&str, LastRun)]) -> Value {
|
||||||
|
json!({"schedules": rows.iter().map(|(id, run)| json!({
|
||||||
|
"id": id, "title": format!("Task {id}"),
|
||||||
|
"last_run": run.map(|(r, status, told)| json!({"id": r, "status": status, "notified": told})),
|
||||||
|
})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_run_the_server_tells_of_is_said_once_and_a_silent_one_never() {
|
||||||
|
let w = Watch::default();
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[("a", Some(("r1", "ok", true))), ("b", None)]))
|
||||||
|
.is_empty());
|
||||||
|
let ran = w.look_schedules(&schedules(&[
|
||||||
|
("a", Some(("r2", "ok", true))),
|
||||||
|
("b", Some(("r3", "silent", false))),
|
||||||
|
]));
|
||||||
|
assert_eq!(
|
||||||
|
ran,
|
||||||
|
vec![Ran {
|
||||||
|
title: "Task a".into(),
|
||||||
|
failed: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[("a", Some(("r2", "ok", true)))]))
|
||||||
|
.is_empty());
|
||||||
|
let failed = w.look_schedules(&schedules(&[("a", Some(("r4", "failed", true)))]));
|
||||||
|
assert!(failed[0].failed);
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+812
-415
File diff suppressed because it is too large.
Load diff
+536
-96
File diff suppressed because it is too large.
Load diff
+80
-54
@@ -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,79 +41,105 @@
|
|||||||
</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">
|
||||||
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
<p class="thread__note is-error" id="thread-note" hidden></p>
|
||||||
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
<div class="cards" id="cards" hidden></div>
|
||||||
<ul class="composer__files" id="composer-files" hidden></ul>
|
<div class="notice" id="link-notice" hidden></div>
|
||||||
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
||||||
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
||||||
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
<ul class="composer__files" id="composer-files" hidden></ul>
|
||||||
<div class="composer__actions">
|
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
||||||
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
|
<div class="composer__bar">
|
||||||
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
|
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||||
</div>
|
<div class="composer__chips" id="composer-chips"></div>
|
||||||
</form>
|
<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">
|
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||||
<header class="viewer__head activity__head">
|
<header class="viewer__head activity__head">
|
||||||
<button type="button" class="pane__btn" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||||
<h2 class="viewer__name" id="activity-title"></h2>
|
<h2 class="viewer__name" id="activity-title"></h2>
|
||||||
<button type="button" class="pane__btn" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
<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>
|
</header>
|
||||||
<div class="activity__body" id="activity-body"></div>
|
<div class="activity__body" id="activity-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -125,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>
|
||||||
|
|||||||
+17
-7
@@ -273,16 +273,26 @@ async function read(withContext) {
|
|||||||
|
|
||||||
function renderStrip() {
|
function renderStrip() {
|
||||||
const strip = $("activity-strip")
|
const strip = $("activity-strip")
|
||||||
// While something runs, as the server's strip: a chat whose helpers all finished long ago does
|
// A chip for every open chat: what runs, while something does, as the server's strip says it (a
|
||||||
// not carry "12 finished" above its message box for ever (the panel still lists them).
|
// chat whose helpers all finished long ago does not carry "12 finished" for ever: the panel
|
||||||
const text = chat && summary?.active ? summaryText(summary) : ""
|
// still lists them); else only the way into the panel.
|
||||||
strip.hidden = !text
|
strip.hidden = !chat
|
||||||
if (!text) return clear(strip)
|
if (!chat) return clear(strip)
|
||||||
|
const active = summary?.active ? summaryText(summary) : ""
|
||||||
|
const text = active || t("Activity")
|
||||||
strip.replaceChildren(
|
strip.replaceChildren(
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
|
{
|
||||||
h("span", { class: "dot is-busy", "aria-hidden": "true" }),
|
type: "button",
|
||||||
|
id: "activity-open",
|
||||||
|
class: `strip__btn${active ? " is-active" : ""}`,
|
||||||
|
"aria-label": `${text} — ${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 }),
|
h("span", { class: "strip__text", text }),
|
||||||
icon(ICONS.chevron),
|
icon(ICONS.chevron),
|
||||||
),
|
),
|
||||||
|
|||||||
+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()
|
||||||
|
})
|
||||||
|
}
|
||||||
+37
-8
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { invoke, listen, del, why } from "./bridge.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? }
|
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
|
||||||
let seq = 0
|
let seq = 0
|
||||||
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||||
|
|
||||||
@@ -45,15 +46,21 @@ function remove(p) {
|
|||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One file as the composer shows it: the same three tracks for every row. */
|
/**
|
||||||
|
* 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) {
|
function row(p) {
|
||||||
const status =
|
const status =
|
||||||
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||||
return h(
|
const tile = h(
|
||||||
"li",
|
"li",
|
||||||
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||||
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
|
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
|
||||||
h("span", { class: "file__state", text: status }),
|
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(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
@@ -67,10 +74,12 @@ function row(p) {
|
|||||||
icon(ICONS.close),
|
icon(ICONS.close),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return tile
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const list = clear($("composer-files"))
|
const list = clear($("composer-files"))
|
||||||
|
list.className = "tiles composer__files"
|
||||||
list.hidden = !pending.length
|
list.hidden = !pending.length
|
||||||
for (const p of pending) list.append(row(p))
|
for (const p of pending) list.append(row(p))
|
||||||
}
|
}
|
||||||
@@ -90,11 +99,31 @@ async function track(names, call) {
|
|||||||
for (const r of results || []) {
|
for (const r of results || []) {
|
||||||
pending.push(
|
pending.push(
|
||||||
r.attachment
|
r.attachment
|
||||||
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
|
? { key: ++seq, name: r.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))() },
|
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
render()
|
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. */
|
/** Choose files in the system's dialog. */
|
||||||
@@ -118,7 +147,7 @@ async function paste(e) {
|
|||||||
track([name], async () => {
|
track([name], async () => {
|
||||||
const data = new Uint8Array(await f.arrayBuffer())
|
const data = new Uint8Array(await f.arrayBuffer())
|
||||||
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||||
return [{ name, size: f.size, attachment }]
|
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+319
-192
@@ -1,6 +1,7 @@
|
|||||||
// 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 --
|
||||||
@@ -11,10 +12,12 @@ import { api, get, getText, post, patch, del, follow, why, invoke, openLink } fr
|
|||||||
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 } from "./popup.js"
|
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||||
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
|
import { avatar, agentsControl, teamPossible, levelsOf, levelWord, picture } from "./models.js"
|
||||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
|
import { watchActivity, activityChanged, planList } 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"]
|
||||||
|
|
||||||
@@ -50,18 +53,34 @@ function copyText(text) {
|
|||||||
navigator.clipboard?.writeText(text).catch(() => {})
|
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) {
|
||||||
const el = 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
|
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
|
||||||
? h(
|
|
||||||
"ul",
|
|
||||||
{ class: "msg__files" },
|
|
||||||
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
|
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).
|
// A background job's report is not the person's to rewrite (the server refuses it too).
|
||||||
@@ -168,7 +187,7 @@ function assistantMessage(m, chat) {
|
|||||||
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
||||||
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
||||||
),
|
),
|
||||||
model ? h("span", { class: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
model ? h("span", { class: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
||||||
meta,
|
meta,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -186,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":
|
||||||
@@ -242,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) {
|
||||||
@@ -377,6 +398,7 @@ class Live {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
@@ -504,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"))
|
||||||
@@ -511,47 +534,25 @@ 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(
|
|
||||||
"select",
|
|
||||||
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
|
|
||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
|
||||||
)
|
|
||||||
tools.append(mode)
|
|
||||||
if (chat.device_session_id) {
|
if (chat.device_session_id) {
|
||||||
tools.append(
|
tools.append(
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: "pane__btn", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
||||||
icon(ICONS.terminal),
|
icon(ICONS.terminal),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const slot = h("span", { class: "head__model" })
|
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||||
tools.append(slot)
|
|
||||||
chatModels(chat).then((models) => {
|
|
||||||
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
|
|
||||||
})
|
|
||||||
tools.append(
|
|
||||||
h(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
class: "pane__btn",
|
|
||||||
id: "activity-open",
|
|
||||||
"aria-label": t("Activity"),
|
|
||||||
title: t("Activity"),
|
|
||||||
"aria-controls": "activity",
|
|
||||||
"aria-expanded": String(panelOpen()),
|
|
||||||
onclick: togglePanel,
|
|
||||||
},
|
|
||||||
icon(ICONS.bolt),
|
|
||||||
),
|
|
||||||
h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
||||||
@@ -573,70 +574,189 @@ async function teamOffered(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- The chips under the text -------------------------------------------------------------------
|
||||||
* The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the
|
//
|
||||||
* Thinking switch, or the selector); and the team's size where a team is offered.
|
// 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
|
||||||
async function headModel(slot, chat, models) {
|
// 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 team's size: a chip saying it, opening the two fields. `value` { min, max } or empty. */
|
||||||
|
function agentsChip(value, onChange) {
|
||||||
|
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.users,
|
||||||
|
label: `${t("Agents")} ${said}`,
|
||||||
|
title: t("Its own agent included. Above one, it can add members that do the work while it leads them."),
|
||||||
|
onclick: (e) => {
|
||||||
|
const fields = agentsControl({ value, id: "agents-min" })
|
||||||
|
popover(
|
||||||
|
e.currentTarget,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "pop__body" },
|
||||||
|
h("label", { class: "pop__title", for: "agents-min", text: t("Agents") }),
|
||||||
|
fields,
|
||||||
|
h("p", { class: "hint", text: t("Its own agent included. Above one, it can add members that do the work while it leads them.") }),
|
||||||
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
||||||
|
),
|
||||||
|
{ up: true, label: t("Agents") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
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())))
|
||||||
|
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 current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||||
const parts = []
|
const parts = []
|
||||||
if (mayPick && models.length > 1) {
|
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||||
parts.push(
|
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||||
modelPicker({
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
models,
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
value: chat.model,
|
if (!(await teamOffered(chat)) || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
label: t("Model"),
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a })))
|
||||||
onChange: async (id) => {
|
}
|
||||||
try {
|
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
|
/**
|
||||||
if (open?.chat?.id === chat.id) {
|
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
|
||||||
open.chat = r.chat
|
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
|
||||||
headModel(slot, r.chat, models)
|
*/
|
||||||
activityChanged()
|
function renderNotice() {
|
||||||
}
|
const box = $("link-notice")
|
||||||
} catch (err) {
|
const s = ctx.status()
|
||||||
alertIn(why(err))
|
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||||
headModel(slot, chat, models)
|
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||||
}
|
box.hidden = !show
|
||||||
},
|
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||||
}),
|
else box.replaceChildren()
|
||||||
)
|
}
|
||||||
} else if (chat.model) {
|
|
||||||
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
|
||||||
|
export function statusChanged() {
|
||||||
|
renderNotice()
|
||||||
|
if (draft) {
|
||||||
|
renderChips()
|
||||||
|
renderHello()
|
||||||
}
|
}
|
||||||
const level = effortControl({
|
|
||||||
model: current,
|
|
||||||
value: chat.effort || levelsOf(current).start,
|
|
||||||
compact: true,
|
|
||||||
onChange: async (value) => {
|
|
||||||
try {
|
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value })
|
|
||||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
|
||||||
} catch (err) {
|
|
||||||
alertIn(why(err))
|
|
||||||
level.value = open?.chat?.effort || levelsOf(current).start
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (level) parts.push(level)
|
|
||||||
slot.replaceChildren(...parts)
|
|
||||||
if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return
|
|
||||||
const team = agentsControl({
|
|
||||||
value: open.chat.agents || chat.agents,
|
|
||||||
compact: true,
|
|
||||||
onChange: async (wanted) => {
|
|
||||||
try {
|
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted })
|
|
||||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
|
||||||
alertIn("")
|
|
||||||
} catch (err) {
|
|
||||||
alertIn(why(err))
|
|
||||||
team.value = open?.chat?.agents || chat.agents
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
slot.append(team)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
||||||
@@ -735,9 +855,14 @@ export async function openChat(id) {
|
|||||||
if (open?.chat?.id !== id) discard()
|
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("")
|
||||||
@@ -755,6 +880,8 @@ export async function openChat(id) {
|
|||||||
open.more = r.more
|
open.more = r.more
|
||||||
watchActivity(r.chat)
|
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) {
|
||||||
@@ -805,121 +932,119 @@ export function closeChat() {
|
|||||||
invoke("set_open_chat", { chat: null })
|
invoke("set_open_chat", { chat: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- A new chat ------------------------------------------------------------------------------------
|
// --- A new chat ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
let draft = null // { kind, level (the effort control), agents (the team's size) }
|
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()
|
||||||
discard()
|
discard()
|
||||||
draft = { kind }
|
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 start = (models.find((m) => m.default) || models[0])?.id || ""
|
renderNotice()
|
||||||
// How hard it thinks, and the team's size: drawn for the chosen model, again when it changes.
|
|
||||||
// Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown.
|
|
||||||
const effortField = field(t("Reasoning effort"), h("span"), "")
|
|
||||||
const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them."))
|
|
||||||
const showLevels = (id) => {
|
|
||||||
const model = models.find((m) => m.id === id)
|
|
||||||
const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" })
|
|
||||||
effortField.children[0].htmlFor = "new-effort"
|
|
||||||
effortField.children[1].replaceWith(control || h("span", { id: "new-effort" }))
|
|
||||||
effortField.hidden = !control
|
|
||||||
draft.level = control
|
|
||||||
const team = kind === "chat" && teamPossible(model)
|
|
||||||
draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null
|
|
||||||
agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" }))
|
|
||||||
agentsField.children[0].htmlFor = "new-agents"
|
|
||||||
agentsField.hidden = !team
|
|
||||||
}
|
|
||||||
const modelField = field(
|
|
||||||
t("Model"),
|
|
||||||
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }),
|
|
||||||
models.length ? "" : t("No model is available to this account."),
|
|
||||||
)
|
|
||||||
showLevels(start)
|
|
||||||
if (kind === "agent") {
|
|
||||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
|
||||||
const devices = ctx.thisDevices()
|
|
||||||
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,
|
|
||||||
effortField,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
form.append(h("div", { class: "fields" }, modelField, effortField, agentsField))
|
|
||||||
}
|
|
||||||
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. */
|
|
||||||
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, attachments) {
|
async function createChat(text, attachments) {
|
||||||
const body = { text }
|
const body = { text }
|
||||||
if (attachments.length) body.attachments = attachments
|
if (attachments.length) body.attachments = attachments
|
||||||
const model = $("new-model")?.value
|
if (draft.model) body.model = draft.model
|
||||||
if (model) body.model = model
|
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
|
||||||
const effort = draft.level?.value
|
const agents = draft.agents
|
||||||
if (typeof effort === "string" && effort) body.effort = effort
|
|
||||||
const agents = draft.agents?.value
|
|
||||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = 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)
|
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)
|
||||||
@@ -973,6 +1098,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()
|
||||||
@@ -996,6 +1123,6 @@ export function initChat(context) {
|
|||||||
alertIn(why(err))
|
alertIn(why(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onAgentChange(renderNotice)
|
||||||
showComposer(false)
|
showComposer(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +84,19 @@ export const ICONS = {
|
|||||||
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||||
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||||
|
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. */
|
||||||
|
|||||||
+23
-14
@@ -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
|
||||||
}
|
}
|
||||||
@@ -155,17 +155,26 @@ export async function openServerFile(path, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initExplorer() {
|
let tab = "local"
|
||||||
for (const pane of document.querySelectorAll(".pane")) {
|
let serverRead = false
|
||||||
const toggle = pane.querySelector(".pane__toggle")
|
|
||||||
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
|
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
|
||||||
toggle.addEventListener("click", () => {
|
export function showFilesTab(which) {
|
||||||
const open = pane.dataset.open !== "true"
|
tab = which === "server" ? "server" : "local"
|
||||||
pane.dataset.open = String(open)
|
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
|
||||||
toggle.setAttribute("aria-expanded", String(open))
|
$("pane-local").hidden = tab !== "local"
|
||||||
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
|
$("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))
|
||||||
|
|||||||
+79
-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(), menu(anchor, chat) }
|
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,16 +45,30 @@ 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.
|
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||||
b.addEventListener("contextmenu", (e) => {
|
b.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -57,30 +77,56 @@ function row(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()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+125
-69
@@ -1,30 +1,36 @@
|
|||||||
// 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, chatMenu } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind } from "./chat.js"
|
||||||
import { initAttach, allowAttach } from "./attach.js"
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
import { initPalette, openPalette } from "./palette.js"
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
|
import { initAgent, link } from "./agent.js"
|
||||||
|
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||||
|
import { closePopover } from "./popup.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") || "[]")
|
||||||
@@ -71,13 +77,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +102,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
|
||||||
allowAttach(me?.permissions?.["files.upload"] !== false)
|
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||||
setKind(kind)
|
accountButton()
|
||||||
renderServer()
|
refreshList()
|
||||||
|
if (!openChatId()) startNew()
|
||||||
invoke("remote_refresh")
|
invoke("remote_refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,61 +132,87 @@ 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 of `k`'s kind, in the main view. */
|
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
|
||||||
function startNew(k) {
|
function startNew(kind) {
|
||||||
showSettings(false)
|
showPage("chat")
|
||||||
if (k !== kind) setKind(k)
|
|
||||||
currentChat = null
|
currentChat = null
|
||||||
if (narrow()) sideOpen(false)
|
newChat(kind || draftKind())
|
||||||
newChat(k)
|
|
||||||
markCurrent()
|
markCurrent()
|
||||||
localPane()
|
localPane()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
|
/** Open a chat, from the list or the palette. */
|
||||||
function showChat(id, k) {
|
function showChat(id) {
|
||||||
showSettings(false)
|
showPage("chat")
|
||||||
if (k && k !== kind) setKind(k)
|
|
||||||
if (narrow()) sideOpen(false)
|
|
||||||
openChat(id).then(markCurrent)
|
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. */
|
/** The palette's commands: what the menus and buttons do, by name. */
|
||||||
function commands() {
|
function commands() {
|
||||||
const out = [
|
const out = [
|
||||||
{ label: t("New agent chat"), run: () => startNew("agent") },
|
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||||
{ label: t("New chat"), run: () => startNew("chat") },
|
{ 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) },
|
{ 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 (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 }) })
|
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||||
out.push(
|
out.push(
|
||||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
|
{ 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") },
|
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -185,37 +222,33 @@ 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) {
|
|
||||||
if (on) showPanel(false)
|
|
||||||
$("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) => showChat(id),
|
open: (id) => {
|
||||||
|
if (narrow()) sideOpen(false)
|
||||||
|
showChat(id)
|
||||||
|
},
|
||||||
menu: (anchor, c) => chatMenu(anchor, c),
|
menu: (anchor, c) => chatMenu(anchor, c),
|
||||||
})
|
})
|
||||||
initChat({
|
initChat({
|
||||||
@@ -233,14 +266,15 @@ async function boot() {
|
|||||||
gone: (chat) => {
|
gone: (chat) => {
|
||||||
if (!openChatId() || openChatId() === chat.id) {
|
if (!openChatId() || openChatId() === chat.id) {
|
||||||
currentChat = null
|
currentChat = null
|
||||||
newChat(kind)
|
newChat()
|
||||||
localPane()
|
localPane()
|
||||||
}
|
}
|
||||||
refreshList()
|
refreshList()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||||
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
|
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)
|
||||||
})
|
})
|
||||||
@@ -250,33 +284,55 @@ async function boot() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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", () => startNew(kind))
|
$("new-chat-btn").addEventListener("click", () => {
|
||||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
if (narrow()) sideOpen(false)
|
||||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
startNew()
|
||||||
|
})
|
||||||
|
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
|
||||||
|
$("nav-scheduled").addEventListener("click", () => {
|
||||||
|
showPage("scheduled")
|
||||||
|
showScheduled()
|
||||||
|
})
|
||||||
|
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||||
|
$("nav-files").addEventListener("click", () => showFiles())
|
||||||
|
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
|
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||||
const mod = e.ctrlKey || e.metaKey
|
const mod = e.ctrlKey || e.metaKey
|
||||||
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
|
const key = e.key.toLowerCase()
|
||||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
// 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()
|
e.preventDefault()
|
||||||
openPalette()
|
openPalette()
|
||||||
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
|
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
startNew(kind)
|
startNew()
|
||||||
} else if (mod && e.key === ",") {
|
} else if (mod && e.key === ",") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
showSettings(true)
|
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.") } })
|
||||||
listen("status", (e) => {
|
listen("status", (e) => {
|
||||||
const before = status
|
const before = status
|
||||||
status = e.payload
|
status = e.payload
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import { menu } from "./popup.js"
|
|||||||
// so a missing picture is not asked for again with every redraw).
|
// so a missing picture is not asked for again with every redraw).
|
||||||
const pictures = new Map()
|
const pictures = new Map()
|
||||||
|
|
||||||
function picture(path) {
|
export function picture(path) {
|
||||||
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
||||||
return pictures.get(path)
|
return pictures.get(path)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-3
@@ -21,11 +21,12 @@ function outside(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
* 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.
|
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||||
*/
|
*/
|
||||||
export function menu(anchor, items) {
|
export function menu(anchor, items) {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
|
closePopover()
|
||||||
const plain = !items.some((i) => i?.lead)
|
const plain = !items.some((i) => i?.lead)
|
||||||
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -50,7 +51,9 @@ export function menu(anchor, items) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
h("span", { class: "menu__lead" }, item.lead || null),
|
h("span", { class: "menu__lead" }, item.lead || null),
|
||||||
h("span", { class: "menu__label", text: item.label }),
|
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 ? "✓" : "" }),
|
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -76,7 +79,9 @@ export function menu(anchor, items) {
|
|||||||
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
// 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.width = `${Math.ceil(r.width)}px`
|
||||||
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||||
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
// 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 }
|
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||||
document.addEventListener("pointerdown", outside, true)
|
document.addEventListener("pointerdown", outside, true)
|
||||||
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||||
@@ -166,3 +171,68 @@ export function showLine({ title, text, code, copy, copied, close }) {
|
|||||||
copyBtn.focus()
|
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,397 @@
|
|||||||
|
// 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, how it tells, its history, Run now), and a new one said in
|
||||||
|
// words: the server turns them into a rule and a standalone instruction, the next five runs are
|
||||||
|
// shown, and only then is it saved. The server runs them; this page only reads and asks.
|
||||||
|
//
|
||||||
|
// Its routes are the server's /api/v2 for scheduling:
|
||||||
|
//
|
||||||
|
// GET /schedules { schedules: [Schedule] }
|
||||||
|
// GET /schedules/{id} { schedule, runs: [Run], more }
|
||||||
|
// POST /schedules/compile { text } -> { title, instruction, summary, rule, next: [stamp] }
|
||||||
|
// POST /schedules { request, title, instruction, rule, model?, notify?, mode? } -> { schedule }
|
||||||
|
// PATCH /schedules/{id} { enabled?, notify?, mode?, model? } -> { schedule }
|
||||||
|
// POST /schedules/{id}/run -> { run }
|
||||||
|
// DELETE /schedules/{id}
|
||||||
|
//
|
||||||
|
// 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 error) is the person's or a model's
|
||||||
|
// words, set as text.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { get, post, patch, del, why } from "./bridge.js"
|
||||||
|
import { ask, menu } from "./popup.js"
|
||||||
|
import { avatar } from "./models.js"
|
||||||
|
|
||||||
|
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
||||||
|
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "new" }
|
||||||
|
let tasks = []
|
||||||
|
let missing = false // the server has no scheduling routes
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
always: () => t("Always"),
|
||||||
|
matters: () => t("When it matters"),
|
||||||
|
never: () => t("Never"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE = {
|
||||||
|
fresh: () => t("A new chat for each run"),
|
||||||
|
continue: () => t("One chat that grows"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
|
function statusChip(run) {
|
||||||
|
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||||
|
const word = STATUS[run.status]?.() || run.status
|
||||||
|
const n = run.status === "missed" && run.missed > 1 ? ` ×${run.missed}` : ""
|
||||||
|
return h("span", { class: `schip is-${run.status}`, 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 (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_at) return t("No more runs")
|
||||||
|
return `${when(task.next_run_at)} · ${stamp(task.next_run_at)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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"))
|
||||||
|
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
try {
|
||||||
|
if (view.name === "task") await drawTask(view.id)
|
||||||
|
else if (view.name === "new") drawNew()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 btn--primary", onclick: () => go({ name: "new" }) }, 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: "new" }) }, 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),
|
||||||
|
})
|
||||||
|
const row = h(
|
||||||
|
"li",
|
||||||
|
{ class: `task${task.enabled ? "" : " is-paused"}` },
|
||||||
|
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.summary || "" })),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "task__next", title: task.enabled && task.next_run_at ? stamp(task.next_run_at) : "" },
|
||||||
|
h("span", { class: "task__label", text: t("Next run") }),
|
||||||
|
h("span", { class: "task__value", text: task.enabled && task.next_run_at ? when(task.next_run_at) : nextWords(task) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "task__last" }, statusChip(task.last_run)),
|
||||||
|
),
|
||||||
|
h("label", { class: "toggle task__toggle" }, toggle),
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnabled(task, on) {
|
||||||
|
try {
|
||||||
|
const r = await patch(`/schedules/${enc(task.id)}`, { enabled: on })
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(next) {
|
||||||
|
view = next
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- One task -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function drawTask(id) {
|
||||||
|
const r = await get(`/schedules/${enc(id)}`)
|
||||||
|
const task = r.schedule
|
||||||
|
const runs = r.runs || []
|
||||||
|
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", onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
|
||||||
|
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) },
|
||||||
|
null,
|
||||||
|
{ label: t("Delete"), danger: true, run: () => remove(task) },
|
||||||
|
]),
|
||||||
|
}, icon(ICONS.more)),
|
||||||
|
)
|
||||||
|
const models = ctx.models()
|
||||||
|
const model = models.find((m) => m.id === task.model)
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
body.append(
|
||||||
|
h(
|
||||||
|
"section",
|
||||||
|
{ class: "sched" },
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__facts" },
|
||||||
|
fact(t("When"), h("span", { text: task.summary || "" })),
|
||||||
|
fact(t("Next run"), h("span", { text: nextWords(task) })),
|
||||||
|
fact(t("Last run"), statusChip(task.last_run)),
|
||||||
|
fact(t("Model"), model ? h("span", { class: "sched__model" }, avatar(model), h("span", { text: model.name })) : h("span", { text: task.model || t("default") })),
|
||||||
|
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 || []),
|
||||||
|
),
|
||||||
|
history(runs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 its result. */
|
||||||
|
function history(runs) {
|
||||||
|
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 models = ctx.models()
|
||||||
|
const list = h("ul", { class: "runs" })
|
||||||
|
for (const run of runs) {
|
||||||
|
const m = models.find((x) => x.id === run.model)
|
||||||
|
list.append(
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{ class: "run" },
|
||||||
|
h("span", { class: "run__status" }, statusChip(run)),
|
||||||
|
h("span", { class: "run__when", title: run.started_at || run.due_at || "" }, h("span", { text: stamp(run.started_at || run.due_at) }), run.timing === "late" || run.timing === "catch_up" ? h("span", { class: "run__late", text: run.timing === "late" ? t("late") : t("caught up") }) : null),
|
||||||
|
h("span", { class: "run__model", title: run.model_reason || "" }, m ? avatar(m) : null, h("span", { text: m?.name || run.model || "" })),
|
||||||
|
run.chat_id
|
||||||
|
? h("button", { type: "button", class: "btn btn--sm", text: t("Open the result"), onclick: () => ctx.openChat(run.chat_id) })
|
||||||
|
: h("span", { class: "run__error", title: run.error || "", text: run.error || "" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
box.append(list)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`)
|
||||||
|
go({ name: "list" })
|
||||||
|
} catch (err) {
|
||||||
|
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A new task, said in words ------------------------------------------------------------------
|
||||||
|
|
||||||
|
let compiled = null // the server's reading of the words: { title, instruction, summary, rule, next }
|
||||||
|
|
||||||
|
function drawNew() {
|
||||||
|
header(t("New scheduled task"), () => go({ name: "list" }))
|
||||||
|
clear($("scheduled-tools"))
|
||||||
|
compiled = null
|
||||||
|
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "4", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when") })
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const preview = h("div", { class: "sched__preview", hidden: true })
|
||||||
|
const read = h("button", { type: "submit", class: "btn btn--primary", text: t("Preview") })
|
||||||
|
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" }, read),
|
||||||
|
said,
|
||||||
|
preview,
|
||||||
|
)
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const text = words.value.trim()
|
||||||
|
if (!text) return words.focus()
|
||||||
|
read.disabled = true
|
||||||
|
said.hidden = true
|
||||||
|
try {
|
||||||
|
compiled = await post("/schedules/compile", { text })
|
||||||
|
drawPreview(preview, text)
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
} finally {
|
||||||
|
read.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
words.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
clear($("scheduled-body")).append(form)
|
||||||
|
words.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server made of the words: the rule in words, the instruction, the next five runs. */
|
||||||
|
function drawPreview(box, text) {
|
||||||
|
const c = compiled
|
||||||
|
box.hidden = false
|
||||||
|
const choice = { notify: "matters", mode: "fresh" }
|
||||||
|
const select = (key, table) =>
|
||||||
|
h(
|
||||||
|
"select",
|
||||||
|
{ "aria-label": key === "notify" ? t("Tell me") : t("Runs"), onchange: (e) => (choice[key] = e.target.value) },
|
||||||
|
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: choice[key] === v })),
|
||||||
|
)
|
||||||
|
const save = h("button", { type: "button", class: "btn btn--primary", text: t("Save") })
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
save.addEventListener("click", async () => {
|
||||||
|
save.disabled = true
|
||||||
|
try {
|
||||||
|
const r = await post("/schedules", { request: text, title: c.title, instruction: c.instruction, rule: c.rule, notify: choice.notify, mode: choice.mode })
|
||||||
|
go({ name: "task", id: r.schedule.id })
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
save.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
box.replaceChildren(
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__card" },
|
||||||
|
h("p", { class: "sched__title", text: c.title || "" }),
|
||||||
|
h("div", { class: "sched__facts" }, fact(t("When"), h("span", { text: c.summary || "" })), fact(t("What it does"), h("span", { text: c.instruction || "" }))),
|
||||||
|
upcoming(c.next || []) || h("p", { class: "status is-error", text: t("These words give no run in the future.") }),
|
||||||
|
h("div", { class: "sched__facts" }, fact(t("Tell me"), select("notify", NOTIFY)), fact(t("Runs"), select("mode", MODE))),
|
||||||
|
said,
|
||||||
|
h("div", { class: "set__buttons" }, save),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
save.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The palette's commands for scheduled tasks. */
|
||||||
|
export function scheduledCommands() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("New scheduled task"),
|
||||||
|
run: () => {
|
||||||
|
ctx.show()
|
||||||
|
go({ name: "new" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScheduled(context) {
|
||||||
|
ctx = context
|
||||||
|
}
|
||||||
+89
-112
@@ -1,96 +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…"), ""],
|
|
||||||
connecting: () => [t("The agent is connecting to the server…"), ""],
|
|
||||||
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
|
||||||
waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
|
|
||||||
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
|
|
||||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
|
||||||
}
|
|
||||||
|
|
||||||
let folderError = ""
|
let folderError = ""
|
||||||
let linking = null // { user_code, uri } while the computer is being linked
|
let current = () => null
|
||||||
let linkError = ""
|
|
||||||
|
|
||||||
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
|
const SECTIONS = [
|
||||||
export function agentWords(s) {
|
["account", ICONS.user, () => t("Account")],
|
||||||
const a = s.agent
|
["agent", ICONS.terminal, () => t("Agent")],
|
||||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
["folders", ICONS.folder, () => t("Project folders")],
|
||||||
if (a.state === "elsewhere") {
|
["notify", ICONS.bell, () => t("Notifications")],
|
||||||
const pid = a.detail?.pid
|
["about", ICONS.info, () => t("About")],
|
||||||
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
|
]
|
||||||
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
|
||||||
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
|
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
|
||||||
}
|
export function scrollToSection(id) {
|
||||||
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
const el = $(`set-${id}`)
|
||||||
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
|
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
|
||||||
@@ -102,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")],
|
||||||
@@ -117,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(
|
||||||
|
|||||||
+53
-5
@@ -1,9 +1,12 @@
|
|||||||
// 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"),
|
||||||
@@ -19,8 +22,11 @@ const WORDS = {
|
|||||||
no_folders: () => t("agent: no folders"),
|
no_folders: () => t("agent: no folders"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ctx = null // { status(), openSettings() }
|
||||||
|
let pop = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
* 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.
|
* into no folder is said too -- a green light would say it works when it cannot.
|
||||||
*/
|
*/
|
||||||
export function pillState(s) {
|
export function pillState(s) {
|
||||||
@@ -53,6 +59,46 @@ export function renderBar(s) {
|
|||||||
$("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) {
|
||||||
@@ -68,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))
|
||||||
@@ -78,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))
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user