11 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 216e3a20d1 Helpers on another model, chosen by logic rather than by the model
A helper still runs on the chat's own model by default. Other models are
designated per main model -- offered to it, or by hand only -- by the
instance or, with helpers.designate, by a person. subagent_run gains a
model argument whose enum is exactly the candidates that passed two
checks: capacity (a model that serves one request at a time cannot be
its own helper; a connection that holds one model at a time cannot serve
a helper on another of its models) and the model rules. A helper on
another model takes that model's own effort and data group. The composer
gains a Helpers picker; the model page, the connection form and Settings
gain their switches and lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:37:53 +00:00
HomerandClaude Opus 5.5 c27fe47d4d Model rules: who a chat's model may bring into a conversation
Rules read from the main model decide who it is offered as a crowd member,
a friend and on its roster; any-to-any with denies by default, or
none-to-none with allows. The crowd picker names what it holds back and
why, and a model held back only by a person's own rule -- or by anything,
with the new rules.override -- can still be added by hand. Another data
group is now a deny that an explicit rule opens. Admin -> Model rules and
a card in Settings, each with a matrix drawn by the enforcing function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:20:40 +00:00
HomerandClaude Opus 5.5 9970bb43c6 Data groups: a provider's models read only their own group's data
Every connection is in a data group. Its models are handed, and can find,
only that group's memories, notes, skills, knowledge, reports and
personality -- by search and by id. A chat stays in the group it was
started in: switching its model, the endpoint fallback, the crowd, friends,
bases and the @ menu all stay inside it, and a chat whose model has moved
is refused rather than sent. A group may name its own embedder and image
reviewer. data.manage lets a person make personal groups, remap
connections for themselves and move their own records.

Also: a search no longer mixes two embedders of the same width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:55 +00:00
HomerandClaude Opus 5.5 e65ea90fe6 A temporary chat that keeps the model it was started on
The new-chat screen keeps its preselections in the query string, and the
three ways off it each rebuilt the URL with only their own key: the
Temporary button went to /chat?temporary=1, the model picker to
/chat?model=<id>, and /temp to /chat?temporary=1. So each undid the other,
and a temporary chat could only be started on the default model. The
folder from "New chat here" and the agent kind were dropped the same way.

chat_index now builds both URLs from one set of carried values and changes
only its own key; /temp keeps location.search when run on /chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:07:57 +00:00
HomerandClaude Opus 5.5 cb8a223fa4 A dot on the loaded model, and a new chat that matches its model
The model menu asks each connection's /v1/models for the load state
llama-swap reports there and marks the loaded model; endpoints that state
nothing (a hosted API) get no dot. The new-chat screen offered the generic
three efforts whatever the model took, so Bonsai's xhigh default showed as
off. The composer was as wide as its widest hint. And the Doors of Durin are
a riddle: Speak friend and enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:58 +00:00
HomerandClaude Opus 5.5 dfd8418d95 A tab bar's edge fade that stays hidden until something is off the edge
The local-attached covers were as wide as the scroll shadows and solid for
only 40% of that, so most of each shadow showed at rest -- invisible on Moria,
a grey sliver at both ends of every tab bar on Shire. The covers are now twice
the shadow's width and solid across all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:53:45 +00:00
HomerandClaude Opus 5.5 db22962164 A reload offer only for a page that is older, and a tab switch that moves one scroller
The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:17:18 +00:00
HomerandClaude Opus 5.5 793f9c8cad A model menu that stays on the phone
Inside a chat the picker sits mid-bar with the panel buttons to its right,
and its menu opened from the picker's right edge -- at 390px it spanned
x = -132..226, cutting every model's name off. Below 48rem the bar is now
the containing block and the menu is pinned between its edges (capped at
24rem). Opening it on a touchscreen no longer focuses the filter, which
raised the keyboard over half the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:36:45 +00:00
HomerandClaude Opus 5.5 da43bc1459 Model lists you can read
The chat's model picker shows name, context window (CTX 131K) and an eye
for vision, on shared column tracks; the capability tags are gone from it.
Settings -> Models gives the name its own row and wraps the tags beneath.
The picker's tick now follows an in-place choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:54:31 +00:00
HomerandClaude Opus 5 8503c6c775 A crowd that does not agree with whoever spoke last
Three fixes to how a round behaves, found by reading one real round on the live
instance rather than by testing it.

A member asked "what would you have done differently" answered the person's
original question again instead of critiquing what was already there. Fine on a
question with one answer; on a request to *make* something it is an invitation.
`crowd.turn` now says to respond to what is above and not to re-answer.

The model that opened the round, told to write the final answer and take what
the others got right, abandoned its own good answer and adopted the newcomer's
position with no argument anywhere for why. Both closing fragments now say that
an answer is not the worse one for having been written first, and that agreement
with no argument behind it is not a reason to change.

That second one is not cosmetic: all three answers from the observed round were
compiled. The original and the critic's alternative both build; the merged
answer that was actually delivered does not. A crowd's failure mode is not
looping -- the caps handle that -- it is converging on the last thing said.

Third, the reply that opens a round now carries a chip like every other one. It
is the single contribution the crowd does not start, so there was nothing to
stamp it with until the round began, and a two-model round rendered as an
unmarked reply followed by one saying "2 of 2". The stamp is display state and
never scheduling state: `crowd.scheduling_state` hides it from everything that
decides what happens next, because fed to the scheduler it would inherit the
round's clock -- regenerating the opening an hour later would end the round with
"out of time" before anybody spoke -- and would hand that reply a member's tools
and a member's instruction.

And the chip was never translated. It is now, with the count as placeholders
rather than three t() calls around one sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 21:10:25 +00:00
HomerandClaude Opus 5 ab32c68a8f A crowd you can find, and a phone 65px too narrow
Two reports against 1.6.0 and 1.7.0, both correct.

The crowd worked end to end and was, in practice, not there: the picker was
behind the ⋯ menu of a chat that already existed, and the switch was a card on
the Agents page, which made it read as an agent-chat feature. The picker is now
a button in the composer toolbar on both screens that include it, and on the
new-chat screen the choice rides along with the first message, so a chat can
start as a crowd instead of having to be converted into one. The instance
switch has its own page.

The width bug was the suggestion cards, exactly as reported. `.suggestions`
rendered 455px inside a 366px column, and the tree's standing rule applied on
its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`,
which is a min-content floor, and a floor beats `width: 100%`; the floor is
measured while the percentage is indefinite, so `min(100%, …)` alone sends the
track to a card's max-content. Both halves now go on all four auto-fit grids,
and a test refuses either alone.

It survived four releases of narrow-width checking because the harness never
rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block,
so the startup-seeded cards were missing from every shot ever taken of it. And
its overflow check skipped anything inside a scroller -- right for a table in
its own scroller, blind to the scroller itself, which `overflow-y: auto` makes
scroll sideways too. Both fixed; it now names the box and the child to blame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 20:08:10 +00:00
106 changed files with 8395 additions and 493 deletions
+333
View File
@@ -16,6 +16,339 @@ for 1.0.0 have something to be assembled from.
## Unreleased
## 1.12.0
Helpers on another model. This is the last of the three releases.
- **A helper can run on a different model.** A helper is still the chat's own
model by default. On each model's page, **Helpers** lists other models this
one may send helpers to. Each is either *offered to the model*, so it can
choose that model itself, or *by hand only*, so it is used once somebody adds
it to a chat. When there is more than one choice, `subagent_run` gains a
`model` argument limited to exactly those models, and the model is told what
each is for. A helper on another model runs with that model's own default
reasoning effort, never the parent's, which it might refuse. It reads its own
data group's memories and notes.
- **The composer's Helpers button** lists the models designated for the chat's
model. Tick one to add it to this chat, on the new-chat screen as well.
- **Capacity is decided by logic, not by the model.** Two new switches, both off
by default, so nothing changes until they are set:
- **Serves one request at a time**, on a model. It cannot be its own helper,
because the helper would wait behind the reply that is waiting for it.
- **Holds one model at a time**, on a connection (llama-swap in front of one
GPU). Its models may still be their own helpers, but never send one to
another model on the same connection, because loading it would unload the
model whose reply is waiting.
A model with no helper it can use is no longer offered `subagent_run` at all,
instead of being offered a tool that refuses every call. Asking a friend and
the crowd are not affected: both are sequential, and the wait for a model to
load is accepted there.
- **The model rules apply to helpers too.** A designation the model chooses
itself must be allowed by the rules. One added to a chat by hand needs what a
crowd member added by hand needs. A model in another data group needs a rule
that allows it.
- **Your own helper models.** With the new *Choose their own helper models*
permission (off by default), Settings → Models lets a person designate helpers
for their models. These are added to the instance's, and a row of theirs for
the same pair replaces the instance's.
## 1.11.0
Rules for which model may talk to which. This is the second of three releases;
the third adds helpers on a different model.
- **Model rules.** The new **Admin → Model rules** page sets who a chat's model
may bring into the conversation: as a crowd member, as a friend it asks, and
on its list of other models. A rule names two models, or *any model* on either
side, and allows or forbids. The starting point is either *any model may talk
to any other* (the default, so nothing changes) or *no model may talk to
another*. The most specific rule wins. The page ends with a table of every
model against every other, drawn by the same rules that are enforced.
- **A rule is read from the chat's own model.** If gpt-oss may not talk to
qwen38, then in a gpt-oss chat qwen38 is not on its list of other models, it
cannot be asked as a friend, and the crowd picker does not offer it. Members
of a crowd are not checked against each other.
- **The crowd picker names what it holds back, and why.** Models the rules do
not offer are listed under *Not offered to this model* with the reason. You
can still tick one by hand when the rule holding it back is your own, or when
you may override the instance's rules. A member added by hand keeps speaking
when the round runs; one the instance later forbids is skipped and shown
crossed out, as a member that cannot be reached always was.
- **Your own rules.** Settings → Models has a card for your own starting point
and rules, and the same table for you. Anybody can narrow the instance's
rules for themselves. With the new *Override the model rules for themselves*
permission (off by default), your rules and starting point win over the
instance's, and you can add any model to a crowd by hand.
- **Another data group is a rule, not a wall.** In 1.10.0 a model from another
data group could never join a conversation. Now it is not offered unless a
rule explicitly allows it: the instance's, or your own with the override.
When it does join, it reads its own group's memories and notes, never the
chat's.
## 1.10.0
Data groups: a provider's models read only the data of the group their
connection is in. This is the first of three releases. The next two add rules
for which model may talk to which, and helpers on a different model.
- **Every connection is in a data group, and its models read only that group.**
This covers memories, notes, skills, knowledge bases and their documents,
reports, and the personality and impression a model keeps with you. It applies
both to what a model is handed at the start of a turn and to what its tools
can find. A tool can no longer open a record from another group by its id.
**Admin → Data groups** makes groups, puts connections in them, and shows
**where each group's data goes**, flagging a service whose connection sits in
a different group. Every instance starts with one group, called Default, with
every connection and every existing record in it. Until you make a second
group nothing changes, and nothing about groups is shown in the library.
- **A chat stays in the group it was started in.**
- Inside a chat, the model menu offers only models in the chat's group and
names the others underneath, with the reason.
- Switching to one is refused, because it would be sent the whole
conversation.
- The crowd picker, `ask_friend`, the list of other models, attaching a
knowledge base and the `@` menu all stay within the chat's group.
- If a chat's model is later moved into another group, the next reply is
refused with an explanation rather than sent.
- When a chat's own connection has gone, the fallback to "any connection
offering the same model" now only considers connections in the chat's
group. Before, it could silently move a conversation to another provider.
- **A group can have its own embedding model and image reviewer.** The embedder
is sent the full text of everything it indexes, and the reviewer every picture
with its prompt. A group that names neither uses the instance's.
- **Schedules run in their model's group.** A schedule's reports land in that
group. A schedule whose model is in another group than Messages cannot post
into Messages, and says so. The model that works out a schedule's timing from
plain words is now picked from the schedule's own group, not simply the first
pinned model.
- **Your own arrangement, with a new permission.** With *Manage their own data
groups* (off by default), a person can:
- make personal groups nobody else sees;
- choose which group each connection reads for them alone;
- move their own notes, skills and knowledge bases between groups.
Everybody can see which group each connection reads, on the new **Data** tab
in Settings, once there is more than one group. New notes, skills, knowledge
bases and memories can be put in any group you can use.
- **A search no longer mixes two embedding models of the same width.** It
checked only a vector's width, so two different 1024-wide models scored against
each other and returned confident nonsense. That used to need a model change
with a rebuild pending; with an embedder per group it would have been ordinary.
A query now carries the model that made it, and only that model's pieces are
scored.
- The crowd picker on the new-chat screen now leaves out the model the chat is
being started on. It was offering the chat's own model as a member.
- Four lines on the crowd page and in the crowd picker were still in English on
a Slovak instance. They are translated now.
- Known limits:
- A skill name and a knowledge base name are still unique per person across
all groups. The database constraint cannot be changed without rebuilding the
table.
- Speech to text and text to speech are not grouped: audio is sent to the
speech server and not kept.
- Moving a whole knowledge base to another group leaves its documents indexed
for the old group's embedder until the index is rebuilt.
## 1.9.1
- **A temporary chat can be started on any model.** On the new-chat screen,
turning on Temporary switched the model back to the default, and choosing a
model switched Temporary off, so a temporary chat could only ever be started
on the default model. The Temporary button, the model menu and `/temp` now
keep each other's choice, and also keep the folder a chat was started in
("New chat here") and whether it is an agent chat.
## 1.9.0
The model menu says which model is loaded, and a new chat now matches the
model it is about to talk to.
- **A dot on the model that is loaded.** Opening the model menu asks each
connection which of its models is in memory. llama-swap says so in its
ordinary model list, so the one it is holding gets a green dot, and one being
loaded gets a pulsing amber one. Picking a model without a dot means waiting
for it to load first. A hosted API such as DeepSeek never unloads anything and
does not report it, so its models show no dot, not a false "not loaded". Each
connection is asked once per menu opening, at most every five seconds. One
that reports nothing is asked again only after ten minutes, and one that is
slow or down just leaves the menu without dots.
- **A new chat offers the model's own effort levels.** The new-chat screen
offered low, medium and high whatever the model took. On a model like Bonsai,
which takes low, medium and xhigh with xhigh as its default, the menu offered a
`high` it rejects. It had no xhigh, so it showed "off" while the chat it
created used xhigh. It now shows the same levels, and the same default, as the
chat will have.
- **The message box is the same width for every model.** It was sized by its
widest content, so the "has no vision, so images will not be sent" line made
it wider for models without vision than for models with it. It is now always
the width of the conversation column.
- **"Speak friend and enter."** The line under an empty chat (and on the "not
yours" error page) lost its commas. On the Doors of Durin it is a riddle: the
answer is to say *friend*, not to be greeted as one. Only the shipped wording
changed. An instance that has overridden the line keeps its own.
## 1.8.5
- **No more grey slivers at the ends of the tab bars.** Tab bars fade at an edge
to show there are more tabs to scroll to. The fade was only partly hidden
when there was nothing to scroll, so a shadow always showed at both ends. It
was invisible on the dark theme and a grey sliver on Shire, on Administration
→ Prompts, Settings and every other tabbed page. The fade now appears only
on the side where tabs are actually hidden.
## 1.8.4
Two things that kept showing up after they should have gone away.
- **"A new version is ready" no longer appears on a page that is already the
new version.** After an update the toast showed up on every page, even one just
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
whenever a new service worker was waiting. But a page loaded after the update
already *is* the update: pages always come from the server, and every
stylesheet and script they name carries the release in its address. The
worker that waits is nearly always the one from the previous release, still
holding the tab, because a reload opens the new page before the old one goes
away. The toast now compares the waiting worker's release with the page's
own, so it only appears in a tab that was opened before the update. For the
same reason, pressing Reload in one tab no longer reloads the other tabs that
are already up to date. That matters when one of them has a reply streaming
into it.
- **Switching tabs on Administration → Prompts no longer lifts the page.**
Choosing any tab but the first pushed the whole window up by the height of
the title bar and left a blank strip along the bottom, under the sidebar too.
The prompt cards' hidden labels were positioned against the page instead of
the panel. That made the page 6,771px tall behind a window that cannot scroll
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
contains what is inside it, and a tab switch moves only the panel that
scrolls. Administration → General on a small phone had the same leak and is
fixed with it.
## 1.8.3
The model picker on a phone, which could not be read once a chat was open.
- **The model menu no longer runs off the left of the screen.** Inside a chat
the picker sits in the middle of the top bar, with the panel buttons to its
right, and its menu opened from the picker's right edge — so on a phone most
of it was off the screen and every model's name was cut off. On a narrow
screen the menu now hangs from the bar itself, edge to edge, and every name is
whole. Wider screens are unchanged.
- **Opening it on a touchscreen no longer raises the keyboard.** With more than
eight models the menu has a filter box, and it took the focus on opening — so
the keyboard came up and covered half the list you had opened it to choose
from. On a touchscreen the chosen model takes the focus instead; the filter is
one tap away. With a mouse, typing straight into the filter works as before.
## 1.8.2
The model lists, made readable. Both printed every capability switch as a tag —
reasoning, vision, tools and then seventeen `tool_*` names — for every model.
- **The model picker in a chat is name, context window and an eye.** One line per
model: its name, its context window shortened the way it is quoted (`CTX 131K`,
`CTX 1M`), and an eye if it can see images — nothing if it cannot. The tags and
the description are gone from it; a menu whose one job is choosing does not
need twenty badges per row. The context sizes and the eyes line up as columns
whatever a name's length, and a model with no context length set shows nothing
rather than `CTX 0`.
- **The tick follows the model you picked.** It stayed on the model the page was
loaded with until the next reload, while the highlight moved.
- **Settings → Models no longer runs the tags over the names.** The tags sat
beside the name, squeezed it to a word per line on a phone and drew over it at
every width. The name now has the row to itself, with the same context size and
eye as the picker, and the capability tags wrap underneath at the card's full
width. A long name wraps rather than being cut off.
## 1.8.1
Three fixes to how a crowd behaves, found by reading one real round on the live
instance rather than by testing: two models, one round, a question that asked for
something to be *made*.
- **A member no longer answers the question again.** Asked to pick a language and
write an example, the main model wrote Python; the second model gave a genuinely
useful critique of it — and then answered the original question itself, in a
different language. Nothing in its instruction said not to. It now says so:
*respond to what is above you; do not answer the person's original request again
yourself.* A member that produces a rival answer is not a second opinion, it is
a second first opinion, and it is what takes a round off the question.
- **The model that opened the round no longer capitulates.** Told to write the
final answer and take what the others got right, it abandoned its own perfectly
good answer, wrote *"I agree that Rust is the superior choice"* with no argument
anywhere for why, and rewrote everything in the newcomer's language. Both
closing instructions now carry: *your own answer is not automatically the worse
one for having been written first; change your position where somebody gave you
a reason, and say what the reason was.*
This mattered more than it reads. All three answers were compiled: the original
Python was fine, the critic's Rust compiled and ran — and **the merged answer
that was actually delivered did not compile at all**. A crowd that ends by
agreeing with whoever spoke last can be worse than the model that started it.
- **The bubble that opens a round now says `1 of 3` like every other one.** It was
the single contribution with no chip, because the crowd does not start it — the
composer does, and a round only begins when it finishes. So a two-model round
read as an ordinary reply followed by one labelled `2 of 2`, with no 1 anywhere.
It is stamped when the round begins, and that stamp is deliberately invisible to
everything that decides what happens next: fed to the scheduler it would inherit
the round's clock, so regenerating the opening an hour later would end the round
with "out of time" before anybody spoke.
- Fixed: **the crowd chip was never translated.** `1 of 3`, `on the way back`,
`closing`, `no rounds left` and the rest were English on a Slovak instance.
**Worth knowing, and not a bug:** with **two** models there is no backward pass at
all. The way back would contain only the model that opened the round, whose turn
*is* the close — so `crowd.disagree` never fires. You need at least three models
before a single "do you disagree" bubble can exist.
## 1.8.0
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
model to a chat was the Chat settings panel — behind the ⋯ menu, inside a chat
that already existed — and the switch that turns the feature on was a card on
the Agents page. Somebody who enabled it went looking and found nothing, which
is the correct outcome of that arrangement.
Now there is a **crowd button in the composer**, beside the attachment and
scope buttons, on both the chat screen and Messages. It carries a count when
the chat has a crowd, it lists the models you can reach, and it says what the
turn will cost before you tick anything. On the new-chat screen the choice
**rides along with the first message**, so a chat can start as a crowd rather
than having to be converted into one.
The instance switch and its bounds have moved to their own page, **Admin →
Crowd**.
- Fixed: **the new-chat screen was wider than a phone.** Before the first
message, the suggestion cards pushed the conversation 65px past the edge of a
390px screen and it could be dragged sideways; after the first message it
looked right, because the cards were gone. Reported from a phone.
Two things were true at once. The cards' grid asked for a minimum column width
it could not give up — the ordinary version of this bug — and it was *also* a
grid item, which means it carried a min-content floor that beats `width: 100%`
outright. Fixing only the first made it 27px worse. Both are fixed, on all four
grids in the stylesheets that could have it, and a test now refuses either half
of the pair on its own.
The reason this survived four releases of narrow-width checking is worth
recording: the screenshot harness built its client without running the
application's startup, so the suggestion cards were **absent from every shot
ever taken of that screen**, and its overflow check deliberately ignored
anything inside a scrolling box — correct for a wide table in its own scroller,
blind to a box that scrolls sideways when nobody asked it to. Both are fixed,
and the harness now names the offending element and the child responsible.
## 1.7.0
- **The interface speaks Slovak.** Pick a language under **Appearance** in your
+178 -2
View File
@@ -26,6 +26,7 @@ import shutil
import subprocess
import sys
import tempfile
from functools import cache
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -128,8 +129,65 @@ window.__measure = function () {
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
}
/* --- A box that scrolls sideways when nobody asked it to -----------------
The blind spot that hid the suggestions bug through forty measurements.
`.suggestions` rendered 455px wide inside a 390px `.thread-scroll`, and
every check above looked straight past it: `culprits('x')` skips anything
with a scrollable ancestor -- correct for a table inside its own scroller,
wrong for the scroller itself -- and `scrollsSideways` stayed false because
`.thread-scroll` absorbed the overflow instead of the document.
"Authored" is the distinction that makes this reportable rather than noise.
The tree's rule is that anything wide gets its OWN scroller, so a wrapper
carrying `overflow-x: auto` in a stylesheet is right. A box given only
`overflow-y: auto` scrolls sideways as well, because the other axis then
computes to `auto` -- and that is always a bug. Computed style cannot tell
those apart, both being `auto`, so the rules that say it are read off the
stylesheets -- in Python, by `authored_sideways()` below, and not from the
CSSOM here: a stylesheet loaded over `file://` is a foreign origin for
`cssRules` even with `--allow-file-access-from-files`, and every sheet
throws. That silently found *nothing authored*, which turns this check into
"every vertical scroller is a bug" -- so the list arriving empty is a hard
error rather than a clean run. */
var sidewaysAuthors = __SIDEWAYS_AUTHORS__;
function authoredSideways(el) {
if (el.style.overflowX || el.style.overflow) return true;
for (var i = 0; i < sidewaysAuthors.length; i++) {
try { if (el.matches(sidewaysAuthors[i])) return true; } catch (e) { /* :has() etc */ }
}
return false;
}
var sideways = [];
document.querySelectorAll('body, body *').forEach(function (el) {
var ox = getComputedStyle(el).overflowX;
if (ox !== 'auto' && ox !== 'scroll') return;
if (el.scrollWidth <= el.clientWidth + 1) return;
if (authoredSideways(el)) return;
/* Which child is doing it. "`.thread-scroll` scrolls sideways" is not
actionable; "`.suggestions` is 455px inside its 390px" is. */
var worst = null;
el.querySelectorAll('*').forEach(function (kid) {
var over = kid.getBoundingClientRect().width - el.clientWidth;
if (over > 1 && (!worst || over > worst.over)) {
worst = {tag: kid.tagName.toLowerCase(),
cls: (kid.className && kid.className.toString().slice(0, 50)) || '',
w: Math.round(kid.getBoundingClientRect().width),
over: Math.round(over)};
}
});
sideways.push({tag: el.tagName.toLowerCase(),
cls: (el.className && el.className.toString().slice(0, 50)) || '',
scrollW: el.scrollWidth, clientW: el.clientWidth,
widest: worst});
});
var shell = document.querySelector('.shell');
return {
sidewaysScrollers: sideways.slice(0, 8),
sidewaysCount: sideways.length,
docScrollH: de.scrollHeight,
innerH: window.innerHeight,
docScrollW: de.scrollWidth,
@@ -167,6 +225,46 @@ window.__measure = function () {
"""
@cache
def authored_sideways() -> tuple[str, ...]:
"""Selectors whose rules really do ask for horizontal scrolling.
The tree's rule is that anything wide gets its own scroller, so these are
the correct ones: a table wrapper, a code block, the tab bar. Everything
else that scrolls sideways is `overflow-y: auto` dragging the other axis
along with it, which is always a bug and is what `.suggestions` did.
"""
selectors: list[str] = []
for path in sorted((STATIC / "css").glob("*.css")):
text = re.sub(r"/\*.*?\*/", "", path.read_text(), flags=re.S)
# Innermost blocks only: `[^{}]*` cannot cross a brace, so an `@media`
# prelude never matches and the rules inside it do.
for prelude, body in re.findall(r"([^{}]*)\{([^{}]*)\}", text):
wants = False
for declaration in body.split(";"):
name, _, value = declaration.partition(":")
name, value = name.strip().lower(), value.strip().lower()
if name not in ("overflow", "overflow-x") or not value:
continue
# `overflow: hidden auto` is x then y, so the first word is ours;
# `overflow: auto` is both.
wants = wants or value.split()[0] in ("auto", "scroll")
if not wants:
continue
selectors += [
part.strip()
for part in prelude.split(",")
if part.strip() and not part.strip().startswith("@")
]
if not selectors:
raise SystemExit("read no horizontal-overflow rules -- the sideways check would cry wolf")
return tuple(selectors)
# The chat `build_client` seeds, so a run can name `/chat/<SHOOT_CHAT>`.
SHOOT_CHAT = "5" * 32
def build_client():
import lembas.config as config_mod
@@ -198,6 +296,62 @@ def build_client():
db.flush()
for name in ("gemma4-moe", "qwen3-coder"):
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
# A second data group with a hosted model in it, a note in each group and a
# chat with a fixed id (`SHOOT_CHAT`). Without them every grouped screen --
# the chips, the Data tab, the models named under the picker as being in
# another group -- is rendered with nothing in it, which is the "a screen the
# test never renders is unchecked" lesson again.
from lembas.db.models import Chat, DataGroup, Note, User
with session_scope() as db:
db.add(DataGroup(id="hosted", name="Hosted providers"))
hosted = Connection(
name="hosted",
base_url="http://127.0.0.1:2",
api_key_encrypted="",
data_group_id="hosted",
)
db.add(hosted)
db.flush()
db.add(
Model(
connection_id=hosted.id,
model_id="deepseek-flash",
display_name="DeepSeek Flash",
)
)
owner = db.query(User).first()
db.add(Note(owner_id=owner.id, title="A note at home", body="x", data_group_id="default"))
db.add(Note(owner_id=owner.id, title="A hosted note", body="x", data_group_id="hosted"))
# A rule and a designation, so the rules page, the matrix and the
# model page's Helpers card render with something in them.
from lembas.db.models import HelperDesignation, TalkRule
db.add(TalkRule(from_model="gemma4-moe", to_model="qwen3-coder", effect="deny"))
db.add(HelperDesignation(main_model="gemma4-moe", helper_model="qwen3-coder"))
local = db.query(Connection).filter_by(name="local").first()
db.add(
Chat(
id=SHOOT_CHAT,
user_id=owner.id,
model_id="gemma4-moe",
connection_id=local.id,
data_group_id="default",
title="A chat to measure",
)
)
# 🚨 The suggestion cards are seeded by the startup hook, and `TestClient(app)`
# runs a lifespan only inside a `with` block -- so every shot of the new-chat
# screen ever taken by this script was of a page with its cards missing. That
# is how a grid 65px wider than a phone survived forty measurements. Seeded
# here rather than by entering the lifespan, which would also start the
# schedule ticker and rehydrate background jobs inside a screenshot run.
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
seed_suggestions(db)
return client
@@ -219,6 +373,20 @@ def rewrite(html: str, client, assets: Path) -> str:
html,
)
# Anything else the *application* serves rather than mounts. Model avatars live
# under `/uploads/models/…`, which is a route behind auth -- so they cannot be
# pointed at a file on disk and have to be fetched through the client like
# `/branding.css` above. A real instance has them and a fixture does not, which
# is exactly the difference that makes a page measured here unlike the page
# somebody is looking at.
for url in sorted({*re.findall(r'\bsrc="(/(?:uploads|branding)/[^"?]+)"', html)}):
response = client.get(url)
if response.status_code != 200:
continue
name = "fetched-" + url.strip("/").replace("/", "-")
(assets / name).write_bytes(response.content)
html = html.replace(f'src="{url}"', f'src="file://{assets}/{name}"')
# Fail loudly, and only about things that decide how the page LOOKS: every
# `src`, and `href` on a <link>. An `href` on an anchor is a destination,
# not an asset -- flagging those makes the guard cry wolf on every page and
@@ -249,14 +417,15 @@ def rewrite(html: str, client, assets: Path) -> str:
if missing:
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
# The one-time notifications offer is a modal over the very page we came
# The one-time notifications offer is a modal over the very page we came
# to measure, and it is gated on a localStorage key. Set it in the head, so
# it runs before the deferred script that reads it.
quiet = (
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
"catch(e){}</script>"
)
return html.replace("</head>", quiet + MEASURE + "</head>", 1)
measure = MEASURE.replace("__SIDEWAYS_AUTHORS__", json.dumps(list(authored_sideways())))
return html.replace("</head>", quiet + measure + "</head>", 1)
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
@@ -391,6 +560,13 @@ def main() -> None:
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
if r["scrollsSideways"]:
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
for s in r.get("sidewaysScrollers", []):
widest = s["widest"]
blame = f"<{widest['tag']}.{widest['cls']} {widest['w']}px" if widest else ""
flags.append(
f"SCROLLER-SIDEWAYS({s['tag']}.{s['cls']} "
f"{s['scrollW']}>{s['clientW']}{blame})"
)
if r["overflowCount"]:
flags.append(f"overflow:{r['overflowCount']}")
if r["smallCount"]:
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.7.0"
__version__ = "1.12.0"
+14 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model, User
from lembas.services import settings_store
from lembas.services import data_groups, settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
from lembas.web import i18n
@@ -109,6 +109,7 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s
},
"message": message,
"unchanged": UNCHANGED_SENTINEL,
"data_group_choices": data_groups.instance_groups(db),
},
)
@@ -175,8 +176,19 @@ async def update_connection(
unload_url: str = Form(""),
unload_method: str = Form("POST"),
extra_headers: str = Form(""),
data_group_id: str = Form(""),
one_model_at_a_time: bool = Form(False),
) -> Response:
connection = _connection(db, connection_id)
connection.one_model_at_a_time = one_model_at_a_time
# Which of the instance's data groups this provider reads. Empty is "not
# submitted" -- an older page -- and leaves it alone; a personal group is
# somebody else's arrangement and cannot be chosen here.
chosen = data_group_id.strip()
if chosen:
group = data_groups.get(db, chosen)
if group is not None and group.owner_id is None:
connection.data_group_id = group.id
connection.name = name.strip()[:120] or connection.name
connection.base_url = base_url.strip().rstrip("/")
connection.enabled = enabled
@@ -227,6 +239,7 @@ async def test_connection(
"message": message,
"message_kind": "error" if error else "success",
"unchanged": UNCHANGED_SENTINEL,
"data_group_choices": data_groups.instance_groups(db),
},
)
-33
View File
@@ -66,10 +66,6 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
# 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),
# And a third group on the same page, for the same reason: a crowd is
# not an agent-chat feature either, but this is where somebody comes to
# find out what one turn is allowed to set going.
"crowd": settings_store.crowd(db),
"saved": saved,
},
)
@@ -115,35 +111,6 @@ async def save_subagents(
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/crowd")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""Its own route, for the reason `save_subagents` gives above."""
settings_store.update(
db,
{
"enabled": enabled,
# Clamped here as well as on read. Every floor is one: a zero would be
# the feature switched off wearing the switch's clothes, and that is a
# thing to answer in one place.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %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,
+1 -1
View File
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
# Read out by the speech test. Short, and the one line this project would pick.
TEST_PHRASE = "Speak, friend, and enter."
TEST_PHRASE = "Speak friend and enter."
def _page_context(db: Db) -> dict:
+68
View File
@@ -0,0 +1,68 @@
"""The crowd: several models answering one turn, in any chat.
Its own module because it is its own page, and it is its own page because as a card
on `/admin/agents` it read as an agent-chat feature. It is not one: a crowd works in
an ordinary conversation, and the owner reasonably concluded otherwise from where
the switch was sitting.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/crowd", tags=["admin-crowd"])
@router.get("")
async def crowd_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
"""Its own page, for the reason its template records: as a card on the Agents
screen it read as an agent-chat feature, which it is not."""
return render(
request,
"admin/crowd.html",
{"crowd": settings_store.crowd(db), "saved": saved},
)
@router.post("")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""One group, one form, one route.
The bounds are clamped here as well as in `settings_store.crowd`, which is the
same belt-and-braces `save_subagents` in `admin_agents.py` uses: a value posted
past this route -- by an older page, or by hand -- still reads back sane.
"""
settings_store.update(
db,
{
"enabled": enabled,
# Every floor is one: a zero would be the feature switched off
# wearing the switch's clothes.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/crowd?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+263
View File
@@ -0,0 +1,263 @@
"""Data groups: which provider may read which part of the people's data.
List plus detail, the shape every admin list here follows. The list says what
each group holds; the detail says which providers read it, which services send
its data somewhere else, and lets an administrator change both.
The one sentence this page exists to make answerable is "which provider has
seen this?". So the detail page lists every place a group's data can leave by --
its connections, its embedder and its reviewer -- and flags the ones whose
connection is in a *different* group, because those are the ones nobody would
think to check.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
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 Connection, DataGroup, Model, User
from lembas.services import data_groups, settings_store
from lembas.web.i18n import t
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/data-groups", tags=["admin-data-groups"])
@dataclass(frozen=True)
class Exit:
"""One way a group's data leaves it: a provider, and whether it is outside."""
what: str
model: str
connection: str
elsewhere: str # the other group's name, or "" when it is this group's own
def labels() -> dict[str, str]:
"""The words `data_groups.COUNTED` and `exits` produce, in the reader's language.
Written out as literal `t()` calls because the templates look them up by
key, and a key that exists only as data is one the catalogue extractor never
finds -- so it would stay English forever, silently. Called per request, not
at import, because the language is the request's.
"""
return {
"chats": t("chats"),
"memories": t("memories"),
"notes": t("notes"),
"skills": t("skills"),
"knowledge bases": t("knowledge bases"),
"reports": t("reports"),
"connections": t("connections"),
"Chat models": t("Chat models"),
"Embedding": t("Embedding"),
"Image review": t("Image review"),
}
def _group(db, group_id: str) -> DataGroup:
group = data_groups.get(db, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such data group.")
return group
def _service_model(db, model_id: str, connection_id: str) -> Model | None:
if not model_id:
return None
return db.scalar(
select(Model)
.join(Connection)
.where(Model.model_id == model_id, Connection.enabled.is_(True))
.order_by(Model.connection_id != (connection_id or ""), Connection.position)
)
def exits(db, group: DataGroup) -> list[Exit]:
"""Every provider this group's data is sent to, as the instance has it set.
Personal remaps are not here: they belong to one person, change what that
person's providers read, and are listed on that person's own settings page.
"""
found: list[Exit] = []
for connection in db.scalars(select(Connection).order_by(Connection.position)):
if data_groups.for_connection(db, None, connection.id) == group.id:
found.append(Exit("Chat models", "", connection.name, ""))
extraction = settings_store.extraction(db)
images = settings_store.images(db)
services = (
(
"Embedding",
group.embedding_model_id or str(extraction.get("embedding_model_id") or ""),
group.embedding_connection_id if group.embedding_model_id else "",
),
(
"Image review",
group.review_model_id
or (str(images.get("review_model_id") or "") if images.get("review_enabled") else ""),
group.review_connection_id if group.review_model_id else "",
),
)
for what, model_id, connection_id in services:
model = _service_model(db, model_id, connection_id)
if model is None:
continue
other = data_groups.for_connection(db, None, model.connection_id)
found.append(
Exit(
what,
model.label,
model.connection.name if model.connection else "",
data_groups.name_of(db, other) if other != group.id else "",
)
)
return found
def _capable(db, capability: str) -> list[Model]:
rows = db.scalars(
select(Model)
.join(Connection)
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
.order_by(Model.position, Model.model_id)
)
return [m for m in rows if (m.capabilities_json or {}).get(capability)]
@router.get("")
async def data_groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
groups = data_groups.instance_groups(db)
personal = [g for g in data_groups.all_groups(db) if g.owner_id is not None]
owners = {
u.id: u
for u in db.scalars(select(User).where(User.id.in_([g.owner_id for g in personal])))
}
connections = {
group.id: db.scalar(
select(func.count())
.select_from(Connection)
.where(data_groups.condition(Connection, group.id))
)
or 0
for group in groups
}
return render(
request,
"admin/data_groups.html",
{
"groups": groups,
"personal": personal,
"owners": owners,
"connections": connections,
"counts": {g.id: data_groups.counts(db, g.id) for g in [*groups, *personal]},
"labels": labels(),
"saved": saved,
},
)
@router.post("")
async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response:
name = " ".join(name.split())[:120]
if not name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A data group needs a name.")
position = db.scalar(select(func.coalesce(func.max(DataGroup.position), 0))) + 1
group = DataGroup(name=name, position=position)
db.add(group)
db.commit()
return RedirectResponse(f"/admin/data-groups/{group.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/{group_id}")
async def data_group_detail(
request: Request, db: Db, user: AdminUser, group_id: str, saved: str = "", error: str = ""
):
group = _group(db, group_id)
all_connections = list(db.scalars(select(Connection).order_by(Connection.position)))
return render(
request,
"admin/data_group_detail.html",
{
"group": group,
"owner": db.get(User, group.owner_id) if group.owner_id else None,
"connections": all_connections,
"member_ids": {
c.id
for c in all_connections
if data_groups.for_connection(db, None, c.id) == group.id
},
"embedders": _capable(db, "embeddings"),
"reviewers": _capable(db, "vision"),
"exits": exits(db, group),
"counts": data_groups.counts(db, group.id),
"in_use": data_groups.in_use(db, group.id),
"labels": labels(),
"saved": saved,
"error": error,
},
)
def _pair(value: str) -> tuple[str, str]:
"""`model_id|connection_id` from a select, or two blanks for the fallback."""
model_id, _, connection_id = (value or "").partition("|")
return model_id.strip()[:300], connection_id.strip()[:32]
@router.post("/{group_id}")
async def save_group(request: Request, db: Db, user: AdminUser, group_id: str) -> Response:
"""Name, description, services and -- for an instance group -- its connections.
Connections are read from a list that is always submitted, so unticking the
last one is a signal and not an absence. A connection taken out of a group
goes back to the default one, never to "no group": there is no such thing.
"""
group = _group(db, group_id)
form = await request.form()
name = " ".join(str(form.get("name") or "").split())[:120]
if name:
group.name = name
group.description = str(form.get("description") or "").strip()[:2000]
group.embedding_model_id, group.embedding_connection_id = _pair(
str(form.get("embedding") or "")
)
group.review_model_id, group.review_connection_id = _pair(str(form.get("reviewer") or ""))
if group.owner_id is None and "connections_sent" in form:
wanted = {str(v) for v in form.getlist("connection_ids") if v}
for connection in db.scalars(select(Connection)):
current = connection.data_group_id or data_groups.DEFAULT_GROUP
if connection.id in wanted:
connection.data_group_id = group.id
elif current == group.id and not group.is_default:
connection.data_group_id = data_groups.DEFAULT_GROUP
db.commit()
return RedirectResponse(
f"/admin/data-groups/{group.id}?saved=1", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/{group_id}/delete")
async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
group = _group(db, group_id)
try:
data_groups.delete(db, group)
except ValueError as exc:
from urllib.parse import quote
return RedirectResponse(
f"/admin/data-groups/{group_id}?error={quote(str(exc))}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
"/admin/data-groups?saved=deleted", status_code=status.HTTP_303_SEE_OTHER
)
+42
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
from lembas.services import chat as chat_service
from lembas.services import helpers as helpers_service
from lembas.services import personas as personas_service
from lembas.services import settings_store, uploads
from lembas.services.llm.openai_client import MAX_CONTEXT
@@ -209,6 +210,16 @@ async def model_detail(
# and undo what a model wrote *before* they switched it off, which is
# exactly when they would come looking.
"persona": personas_service.get(db, model.model_id, None),
# Helpers designated for this model by the instance, and every other
# model id that could be one.
"designations": [
d for d in helpers_service.own_designations(db, None)
if d.main_model == model.model_id
],
"helper_choices": sorted(
{m.model_id for m in chat_service.available_models(db, user)}
- {model.model_id}
),
"persona_limit": personas_service.MAX_PERSONA_CHARS,
"position_of": index + 1,
"total": len(ordered),
@@ -261,6 +272,7 @@ async def update_model(
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
single_session: bool = Form(False),
position: str = Form(""),
context_length: str = Form(""),
default_effort: str = Form(""),
@@ -284,6 +296,7 @@ async def update_model(
model.enabled = enabled
model.pinned = pinned
model.public = public
model.single_session = single_session
# Merged rather than rebuilt, unlike the capabilities below: params_json
# holds whatever sampling defaults an administrator has set and this form
@@ -338,6 +351,35 @@ async def update_model(
)
@router.post("/admin/models/{model_id}/helpers")
async def add_helper(
db: Db,
user: AdminUser,
model_id: str,
helper_model: str = Form(""),
offer: bool = Form(False),
) -> Response:
"""Designate a model this one may send helpers to, for everybody.
Its own form, for the persona's reason: a list edited row by row, not a field
carried by the big save.
"""
model = _model(db, model_id)
helpers_service.set_designation(db, None, model.model_id, helper_model, offer=offer)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/helpers/{designation_id}/delete")
async def remove_helper(db: Db, user: AdminUser, model_id: str, designation_id: str) -> Response:
model = _model(db, model_id)
helpers_service.delete_designation(db, None, designation_id)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/persona")
async def update_persona(
db: Db,
+85
View File
@@ -0,0 +1,85 @@
"""Model rules: which model may bring which into a conversation.
The instance's layer. A person's own rules and mode are on their settings page
(`api/preferences.py`), and `services/talk.py` is where the two are combined.
The page ends in a matrix -- every main model against every other -- drawn by
`talk.matrix`, which calls the same `decide` that enforces the rules. A preview
computed any other way would be a second copy of the logic, and a preview that
disagrees with enforcement is worse than none: it is believed.
"""
from __future__ import annotations
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.api.pages import describe_verdict
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import chat as chat_service
from lembas.services import settings_store, talk
from lembas.web.templating import render
router = APIRouter(prefix="/admin/rules", tags=["admin-rules"])
def model_ids(db, user) -> list[str]:
"""Every model id this person can reach, once each, in the admin's order."""
seen: list[str] = []
for model in chat_service.available_models(db, user):
if model.model_id not in seen:
seen.append(model.model_id)
return seen
@router.get("")
async def rules_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
models = chat_service.available_models(db, user)
return render(
request,
"admin/rules.html",
{
"mode": settings_store.rules(db)["mode"],
"rules": talk.rules_of(db, None),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"matrix_models": models,
# The instance's own view: no person's layer and no override, which
# is what somebody without `rules.override` gets unless they narrow.
"matrix": talk.matrix(db, None, models),
"describe_verdict": describe_verdict,
"saved": saved,
},
)
@router.post("/mode")
async def save_mode(db: Db, user: AdminUser, mode: str = Form(talk.MODE_OPEN)) -> Response:
settings_store.update(
db,
{"mode": talk.MODE_CLOSED if mode == talk.MODE_CLOSED else talk.MODE_OPEN},
key=settings_store.RULES,
)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("")
async def add_rule(
db: Db,
user: AdminUser,
from_model: str = Form(ANY_MODEL),
to_model: str = Form(ANY_MODEL),
effect: str = Form(EFFECT_DENY),
both: bool = Form(False),
) -> Response:
talk.set_rule(db, None, from_model, to_model, effect, both=both)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/{rule_id}/delete")
async def delete_rule(db: Db, user: AdminUser, rule_id: str) -> Response:
talk.delete_rule(db, None, rule_id)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+94 -38
View File
@@ -34,9 +34,10 @@ from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import data_groups, interaction, settings_store, sse
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 helpers as helpers_service
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import reports as reports_service
@@ -222,6 +223,14 @@ def _new_chat(
folder_id=folder.id if folder is not None else None,
model_id=chosen[0] if chosen else "",
connection_id=chosen[1] if chosen else None,
# The chat's data group is its model's, fixed now. It is what every
# later turn reads memories and notes from, and what decides which models
# this chat may be switched to -- see services/data_groups.py.
data_group_id=(
data_groups.for_pair(db, user, chosen[0], chosen[1])
if chosen
else data_groups.DEFAULT_GROUP
),
temporary=temporary,
kind=KIND_AGENT if profile is not None else KIND_CHAT,
ssh_profile_id=profile.id if profile is not None else None,
@@ -296,6 +305,12 @@ async def start_chat(
scope_on: list[str] = Form(default=[]),
scope_skill_all: list[str] = Form(default=[]),
scope_skill_on: list[str] = Form(default=[]),
# Who else answers, as the crowd menu stood before the first word. There is no
# chat row yet to attach members to, so the choice rides along with the message
# -- the same mechanism the scope switches above use, and the reason the control
# lives inside the composer's form rather than in the topbar.
crowd_model_ids: list[str] = Form(default=[]),
helper_model_ids: list[str] = Form(default=[]),
) -> Response:
"""Create a chat from its first message.
@@ -329,6 +344,9 @@ async def start_chat(
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
)
_apply_crowd(db, chat, user, crowd_model_ids)
helpers_service.apply_chat_helpers(db, chat, user, helper_model_ids)
_adopt_draft(db, user, draft_id, chat)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -650,8 +668,12 @@ async def attach_base(
if not permissions.has(db, user, "library.use"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
# Only a base in the chat's own data group: its documents are what this
# chat's model would search, and another group's are not its to read.
base = db.scalar(
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
documents_service.visible_bases(db, user, data_groups.for_chat(db, chat)).where(
KnowledgeBase.id == base_id
)
)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
@@ -1510,6 +1532,50 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
}
def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> None:
"""Replace a chat's crowd with the models named, in the order named.
One implementation for both the composer (where the choice rides along with
the first message) and the settings panel, because two would be two places to
forget a rule -- and there are three:
* **Checked against what this person can reach**, never against what exists.
A control checked only in the template is advisory, and a crafted request
walks past it. Same reasoning as the model branch in `update_chat`.
* **Never the chat's own model**, which would answer twice in a row.
* **Capped by `crowd.max_models`**, on the way in as well as on the way out.
The connection is stored beside the id because `Model` is unique on the pair,
and a model offered by two connections is two rows with different capabilities.
"""
from lembas.db.models import CrowdMember
from lembas.services import talk
settings = settings_store.crowd(db)
# What this person may add by hand, by the talk rules from the chat's main
# model. A member is sent the whole conversation, so one in another data
# group comes in only when a rule explicitly lets it.
reachable = {
model.model_id: model
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
wanted: list[str] = []
for value in values:
value = str(value).strip()
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=reachable[model_id].connection_id,
position=index,
)
for index, model_id in enumerate(wanted)
]
def _messages_after(db: DBSession, message: Message) -> list[Message]:
"""Everything later in this chat than one message.
@@ -2079,11 +2145,28 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
)
# Checked against what this user can reach, not merely what exists --
# otherwise the picker is advisory and a crafted request bypasses it.
# And within the chat's own data group: the new model would be sent the
# whole history, which is exactly what a group keeps from its provider.
group = data_groups.for_chat(db, chat)
match = next(
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
(
m
for m in chat_service.available_models(db, user, group)
if m.model_id == model_id
),
None,
)
if match is None:
elsewhere = any(
m.model_id == model_id for m in chat_service.available_models(db, user)
)
if elsewhere:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"That model is in another data group than this chat "
f"({data_groups.name_of(db, group)}), so it cannot be given this "
f"chat's history. Start a new chat with it instead.",
)
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
chat.model_id = model_id
chat.connection_id = match.connection_id
@@ -2106,9 +2189,9 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
chat.knowledge_bases = (
list(
db.scalars(
documents_service.visible_bases(db, user).where(
KnowledgeBase.id.in_(wanted)
)
documents_service.visible_bases(
db, user, data_groups.for_chat(db, chat)
).where(KnowledgeBase.id.in_(wanted))
)
)
if wanted
@@ -2117,39 +2200,12 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
if "crowd_model_ids" in form:
# The same shape as the bases above: one field always sent, so clearing
# every box clears the crowd. Checked against what this person can reach
# rather than against what exists, or the picker is advisory and a crafted
# request walks past it -- the reasoning the model branch carries.
from lembas.db.models import CrowdMember
# every box clears the crowd.
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
settings = settings_store.crowd(db)
reachable = {
model.model_id for model in chat_service.available_models(db, user)
}
wanted: list[str] = []
for value in form.getlist("crowd_model_ids"):
value = str(value).strip()
# Never the chat's own model: it would answer twice in a row, which is
# nobody's idea of a second opinion.
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=next(
(
model.connection_id
for model in chat_service.available_models(db, user)
if model.model_id == model_id
),
None,
),
position=index,
)
for index, model_id in enumerate(wanted)
]
if "helper_model_ids" in form:
# The helpers picker, the same shape again.
helpers_service.apply_chat_helpers(db, chat, user, form.getlist("helper_model_ids"))
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
if submitted_params:
+57 -18
View File
@@ -21,8 +21,8 @@ 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.security import permissions
from lembas.services import data_groups, settings_store
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
@@ -119,7 +119,7 @@ async def attach_link(
@router.post("/from-knowledge", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_knowledge(
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
chat_id: str = Form(""),
chat_id: str = Form(""), model_id: str = Form(""),
) -> Response:
"""Attach a library document to the message being composed.
@@ -127,7 +127,8 @@ async def attach_from_knowledge(
conversation because a document was later edited or deleted -- the same
reason a PDF's text is extracted once at upload rather than per request.
"""
document = documents_service.get(db, document_id, user)
group = data_groups.for_composer(db, user, chat_id, model_id)
document = documents_service.get(db, document_id, user, group)
if document is None:
return templates.TemplateResponse(
request,
@@ -163,7 +164,12 @@ def _not_available(request: Request, what: str) -> Response:
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_note(
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
request: Request,
db: Db,
user: RequiredUser,
note_id: str = Form(""),
chat_id: str = Form(""),
model_id: str = Form(""),
) -> Response:
"""Attach a note the model wrote earlier.
@@ -171,7 +177,9 @@ async def attach_from_note(
document, and a transcript that changes underneath itself because somebody
tidied a note later is the thing all of this is arranged to prevent.
"""
note = notes_service.get(db, note_id, user)
note = notes_service.get(
db, note_id, user, data_groups.for_composer(db, user, chat_id, model_id)
)
if note is None:
return _not_available(request, "note")
@@ -226,7 +234,12 @@ async def attach_from_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("")
request: Request,
db: Db,
user: RequiredUser,
skill_id: str = Form(""),
chat_id: str = Form(""),
model_id: str = Form(""),
) -> Response:
"""Hand a skill over directly, rather than hoping the model fetches it.
@@ -234,7 +247,9 @@ async def attach_from_skill(
a body on demand -- but only if the model decides to. `@` is the reader
saying "use this one", which is a different act and deserves a way to say it.
"""
skill = skills_service.get(db, skill_id, user)
skill = skills_service.get(
db, skill_id, user, data_groups.for_composer(db, user, chat_id, model_id)
)
if skill is None:
return _not_available(request, "skill")
@@ -259,6 +274,7 @@ async def attach_from_attachment(
user: RequiredUser,
attachment_id: str = Form(""),
chat_id: str = Form(""),
model_id: str = Form(""),
) -> Response:
"""Point at something already in this conversation, without uploading again.
@@ -269,6 +285,12 @@ async def attach_from_attachment(
original = db.get(Attachment, attachment_id)
if original is None or original.user_id != user.id:
return _not_available(request, "attachment")
# Another chat's attachment is that chat's data, in that chat's group.
source = db.get(Chat, original.chat_id) if original.chat_id else None
if source is not None and data_groups.for_chat(db, source) != data_groups.for_composer(
db, user, chat_id, model_id
):
return _not_available(request, "attachment")
return _chip(
request,
@@ -280,15 +302,24 @@ async def attach_from_attachment(
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
async def knowledge_picker(
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
chat_id: str = "",
model_id: str = "",
) -> Response:
"""The list of documents shown by the composer's Knowledge option."""
"""The list of documents shown by the composer's Knowledge option.
Only the composer's own data group: see `data_groups.for_composer`.
"""
group = data_groups.for_composer(db, user, chat_id, model_id)
if q.strip():
found = documents_service.search(db, user, q, limit=20)
found = documents_service.search(db, user, q, limit=20, group=group)
else:
found = list(
db.scalars(
documents_service.visible(db, user)
documents_service.visible(db, user, group=group)
.order_by(Document.created_at.desc())
.limit(20)
)
@@ -311,6 +342,7 @@ async def mention_picker(
chat_id: str = "",
profile_id: str = "",
project_dir: str = "",
model_id: str = "",
) -> Response:
"""What `@` offers: files under the project directory, and the library.
@@ -348,24 +380,29 @@ async def mention_picker(
skills: list = []
bases: list = []
if permissions.has(db, user, "library.use"):
# The library half offers only the composer's own data group: whatever is
# picked becomes part of the conversation and goes to the chat's model.
group = data_groups.for_composer(db, user, chat_id, model_id)
if needle:
documents = documents_service.search(db, user, q, limit=10)
notes = notes_service.search(db, user, q, limit=5)
skills = skills_service.search(db, user, q, limit=5)
documents = documents_service.search(db, user, q, limit=10, group=group)
notes = notes_service.search(db, user, q, limit=5, group=group)
skills = skills_service.search(db, user, q, limit=5, group=group)
else:
documents = list(
db.scalars(
documents_service.visible(db, user)
documents_service.visible(db, user, group=group)
.order_by(Document.created_at.desc())
.limit(10)
)
)
notes = list(
db.scalars(
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
notes_service.visible(db, user, group)
.order_by(Note.updated_at.desc())
.limit(5)
)
)
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
skills = list(db.scalars(skills_service.visible(db, user, group).limit(5)))
# A whole base is a *reference*, not a copy: attaching one scopes the
# chat to it and the model searches inside it. Dumping the contents of
@@ -377,7 +414,9 @@ async def mention_picker(
bases = [
base
for base in db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
documents_service.visible_bases(db, user, group).order_by(
KnowledgeBase.name
)
)
if not needle or needle in base.name.lower()
][:5]
+146 -16
View File
@@ -33,8 +33,8 @@ from lembas.db.models import (
User,
)
from lembas.security import permissions
from lembas.services import data_groups, settings_store, sharing
from lembas.services import files as files_service
from lembas.services import settings_store, sharing
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
@@ -60,6 +60,46 @@ def _page(db: DBSession, query, page: int):
return rows, {"page": page, "pages": pages, "total": total}
def _groups(db: DBSession, user: User) -> dict:
"""What `library/_group.html` needs on every library page.
`several_groups` false is the ordinary instance, and then nothing about
groups is rendered anywhere in the library.
"""
usable = data_groups.usable(db, user)
return {
"several_groups": len(usable) > 1,
"data_groups": usable,
"group_names": {group.id: group.name for group in data_groups.all_groups(db)},
"may_move_groups": data_groups.may_manage(db, user),
}
def _chosen_group(db: DBSession, user: User, value: str) -> str:
"""A submitted group, if this person may use it; otherwise the default."""
value = (value or "").strip()
if value and data_groups.may_use(db, user, value):
return value
return data_groups.DEFAULT_GROUP
def _move(db: DBSession, user: User, row, value) -> None:
"""Move a record into another group, when that was asked and is allowed.
`None` is a form that did not carry the field -- a single-group instance,
or somebody without `data.manage` -- and leaves the record where it is.
"""
if value is None or not data_groups.may_manage(db, user):
return
wanted = str(value).strip()
if wanted and data_groups.may_use(db, user, wanted):
row.data_group_id = wanted
def _group_filter(query, model, group: str):
return query.where(data_groups.condition(model, group)) if group else query
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
"""What the share placeholder needs, which is now three facts.
@@ -87,7 +127,12 @@ async def library_home(user: RequiredUser):
# 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
request: Request,
db: Db,
user: RequiredUser,
error: str = "",
shared: bool = False,
group: str = "",
):
"""The bases, not the documents. A library is a set of places first.
@@ -100,6 +145,7 @@ async def knowledge_list(
if shared
else documents_service.visible_bases(db, user)
)
query = _group_filter(query, KnowledgeBase, group)
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
counts = {
base.id: db.scalar(
@@ -117,6 +163,8 @@ async def knowledge_list(
"counts": counts,
"shared": shared,
"error": error,
"group": group,
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -124,11 +172,19 @@ async def knowledge_list(
@router.post("/api/library/bases")
async def create_base(
db: Db, user: RequiredUser, name: str = Form(""), description: str = Form("")
db: Db,
user: RequiredUser,
name: str = Form(""),
description: str = Form(""),
data_group_id: str = Form(""),
) -> Response:
try:
base = documents_service.create_base(
db, owner=user, name=name, description=description
db,
owner=user,
name=name,
description=description,
group=_chosen_group(db, user, data_group_id),
)
except ValueError as exc:
from urllib.parse import quote
@@ -163,6 +219,7 @@ async def knowledge_detail(request: Request, db: Db, user: RequiredUser, documen
.order_by(KnowledgeBase.name)
)
),
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -202,6 +259,7 @@ async def base_detail(
"q": q,
"pager": pager,
**_shared_context(db, user, base, "base"),
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -220,6 +278,8 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
if name:
base.name = name
base.description = str(form.get("description", "")).strip()[:2000]
# A base moves with every document in it: they have no group of their own.
_move(db, user, base, form.get("data_group_id"))
db.commit()
return RedirectResponse(
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
@@ -302,7 +362,17 @@ async def update_document(
wanted = str(form.get("base_id", "")).strip()
if wanted and wanted != document.base_id:
destination = documents_service.get_base(db, wanted, user)
if destination is not None and sharing.can_write(destination, user):
current = db.get(KnowledgeBase, document.base_id) if document.base_id else None
# Into a base in another data group is a move between groups, which
# changes which providers may read it -- `data.manage`, like any move.
crosses = current is not None and destination is not None and (
data_groups.group_of(current) != data_groups.group_of(destination)
)
if (
destination is not None
and sharing.can_write(destination, user)
and (not crosses or data_groups.may_manage(db, user))
):
document.base_id = destination.id
db.commit()
@@ -352,6 +422,7 @@ async def notes_list(
q: str = "",
page: int = 1,
shared: bool = False,
group: str = "",
):
"""`shared=1` narrows to what other people have given this reader.
@@ -363,7 +434,12 @@ async def notes_list(
"""
if q.strip():
rows = notes_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
db,
user,
q,
limit=PAGE_SIZE,
vector=await retrieval.embed_query(db, q),
group=group or None,
)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
@@ -372,6 +448,7 @@ async def notes_list(
if shared
else notes_service.visible(db, user)
)
query = _group_filter(query, Note, group)
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
return render(
request,
@@ -382,17 +459,25 @@ async def notes_list(
"q": q,
"shared": shared,
"pager": pager,
"group": group,
**_groups(db, user),
**sidebar_context(db, user),
},
)
@router.get("/library/notes/new")
async def new_note(request: Request, db: Db, user: RequiredUser):
async def new_note(request: Request, db: Db, user: RequiredUser, group: str = ""):
return render(
request,
"library/note_detail.html",
{"section": "notes", "note": None, **sidebar_context(db, user)},
{
"section": "notes",
"note": None,
"group": group,
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -409,6 +494,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
"note": note,
"body_html": render_markdown(note.body),
**_shared_context(db, user, note, "note"),
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -416,9 +502,20 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
@router.post("/api/library/notes")
async def create_note(
db: Db, user: RequiredUser, title: str = Form(""), body: str = Form("")
db: Db,
user: RequiredUser,
title: str = Form(""),
body: str = Form(""),
data_group_id: str = Form(""),
) -> Response:
note = notes_service.create(db, owner=user, title=title, body=body, author=AUTHOR_USER)
note = notes_service.create(
db,
owner=user,
title=title,
body=body,
author=AUTHOR_USER,
group=_chosen_group(db, user, data_group_id),
)
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
@@ -431,6 +528,7 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
raise HTTPException(status.HTTP_403_FORBIDDEN, "That note is not yours to change.")
form = await request.form()
_move(db, user, note, form.get("data_group_id"))
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
@@ -453,6 +551,7 @@ async def skills_list(
q: str = "",
page: int = 1,
shared: bool = False,
group: str = "",
):
"""`shared=1` narrows to what other people have given this reader.
@@ -464,7 +563,12 @@ async def skills_list(
"""
if q.strip():
rows = skills_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
db,
user,
q,
limit=PAGE_SIZE,
vector=await retrieval.embed_query(db, q),
group=group or None,
)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
@@ -473,6 +577,7 @@ async def skills_list(
if shared
else skills_service.visible(db, user)
)
query = _group_filter(query, Skill, group)
rows, pager = _page(db, query.order_by(Skill.name), page)
return render(
request,
@@ -483,17 +588,25 @@ async def skills_list(
"q": q,
"shared": shared,
"pager": pager,
"group": group,
**_groups(db, user),
**sidebar_context(db, user),
},
)
@router.get("/library/skills/new")
async def new_skill(request: Request, db: Db, user: RequiredUser):
async def new_skill(request: Request, db: Db, user: RequiredUser, group: str = ""):
return render(
request,
"library/skill_detail.html",
{"section": "skills", "skill": None, **sidebar_context(db, user)},
{
"section": "skills",
"skill": None,
"group": group,
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -510,6 +623,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
"skill": skill,
"revisions": skill.revisions,
**_shared_context(db, user, skill, "skill"),
**_groups(db, user),
**sidebar_context(db, user),
},
)
@@ -522,10 +636,17 @@ async def create_skill(
name: str = Form(""),
description: str = Form(""),
body: str = Form(""),
data_group_id: str = Form(""),
) -> Response:
try:
skill = skills_service.create(
db, owner=user, name=name, description=description, body=body, author=AUTHOR_USER
db,
owner=user,
name=name,
description=description,
body=body,
author=AUTHOR_USER,
group=_chosen_group(db, user, data_group_id),
)
except skills_service.SkillError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
@@ -541,6 +662,7 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
raise HTTPException(status.HTTP_403_FORBIDDEN, "That skill is not yours to change.")
form = await request.form()
_move(db, user, skill, form.get("data_group_id"))
skills_service.update(
db,
skill,
@@ -581,9 +703,17 @@ async def delete_skill(db: Db, user: RequiredUser, skill_id: str) -> Response:
# Lives in Settings rather than in the library: it is a set of short facts about
# the reader, not content they collected.
@router.post("/api/library/memories")
async def add_memory(db: Db, user: RequiredUser, content: str = Form("")) -> Response:
async def add_memory(
db: Db, user: RequiredUser, content: str = Form(""), data_group_id: str = Form("")
) -> Response:
try:
memories_service.add(db, owner=user, content=content, author=AUTHOR_USER)
memories_service.add(
db,
owner=user,
content=content,
author=AUTHOR_USER,
group=_chosen_group(db, user, data_group_id),
)
except ValueError as exc:
from urllib.parse import quote
+23
View File
@@ -0,0 +1,23 @@
"""What the model menu asks for when it opens."""
from __future__ import annotations
from fastapi import APIRouter
from lembas.api.deps import Db, RequiredUser
from lembas.services import chat as chat_service
from lembas.services import model_state
router = APIRouter(prefix="/api/models", tags=["models"])
@router.get("/state")
async def model_states(db: Db, user: RequiredUser) -> dict:
"""`{"states": {model_id: "loaded" | "loading" | "unloaded"}}`.
Only models this reader may use, so the answer never names a model the
menu would not show. Only those whose endpoint reports a state, so a hosted
API's models are simply absent. See `services/model_state.py`.
"""
models = chat_service.available_models(db, user)
return {"states": await model_state.states_for(models)}
+263 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from urllib.parse import urlencode
from zoneinfo import available_timezones
from fastapi import APIRouter, HTTPException, Request, Response, status
@@ -17,6 +18,7 @@ from lembas.db.models import (
KIND_TASK,
KINDS,
Chat,
Connection,
Folder,
KnowledgeBase,
Message,
@@ -28,8 +30,8 @@ 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 data_groups, settings_store
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
@@ -63,8 +65,18 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""
models = chat_service.available_models(db, user)
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
# A chat that exists may only be switched to a model in its own data group:
# the new model would be sent the whole history. The rest are named below
# the picker rather than silently missing from it, so somebody looking for
# one learns where it went -- and that a new chat is how to reach it.
group = data_groups.for_chat(db, chat) if chat is not None else None
in_group = chat_service.available_models(db, user, group) if group is not None else models
in_group_ids = {m.id for m in in_group}
return {
"models": models,
"models": in_group,
"models_elsewhere": [m for m in models if m.id not in in_group_ids],
"chat_group_name": data_groups.name_of(db, group) if group is not None else "",
"several_groups": data_groups.several(db, user),
"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
@@ -76,14 +88,19 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"knowledge_bases": (
list(
db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
documents_service.visible_bases(db, user, group).order_by(
KnowledgeBase.name
)
)
)
if permissions.has(db, user, "library.use")
else []
),
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
**_crowd_context(db, user, chat, models),
**_crowd_context(
db, user, chat, in_group, current.model_id if current is not None else ""
),
**_helper_context(db, user, chat, current.model_id if current is not None else ""),
# What *this* model takes, not the three every model used to be assumed
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
# Bonsai has no `high`, and sending the wrong one does not degrade, it
@@ -197,12 +214,154 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
}
def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -> dict:
def _talk_settings(db: DBSession, user: User) -> dict:
"""The person's own talk rules, and the matrix as it applies to them."""
from lembas.api.admin_rules import model_ids
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import talk
models = chat_service.available_models(db, user)
return {
"talk_mode": talk.user_mode(user),
"talk_override": talk.may_override(db, user),
"talk_rules": talk.rules_of(db, user),
"talk_matrix_models": models,
"talk_matrix": talk.matrix(db, user, models),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"describe_verdict": describe_verdict,
**_helper_settings(db, user),
}
def _helper_settings(db: DBSession, user: User) -> dict:
"""The person's own helper designations, and whether they may add any."""
from lembas.services import helpers as helpers_service
may = helpers_service.may_designate(db, user)
shown = bool(settings_store.subagents(db).get("enabled")) and permissions.has(
db, user, "tools.subagent"
)
return {
"helper_settings_shown": shown,
"may_designate": may,
"own_designations": helpers_service.own_designations(db, user),
}
def _data_group_settings(db: DBSession, user: User) -> dict:
"""What the Data tab on /settings shows: which group each connection reads.
Every connection this person can reach a model on, with the instance's
choice beside their own. Their own is only in force while they hold
`data.manage`; without it the tab still says what applies to them, because
"which provider can read my notes?" is a question anybody may ask.
"""
from lembas.api import admin_data_groups
usable = data_groups.usable(db, user)
reachable = {m.connection_id for m in chat_service.available_models(db, user)}
connections = [
connection
for connection in db.scalars(select(Connection).order_by(Connection.position))
if connection.id in reachable
]
in_force = data_groups.connection_groups(db, user)
return {
"data_groups": usable,
"group_names": {group.id: group.name for group in data_groups.all_groups(db)},
"may_manage_groups": data_groups.may_manage(db, user),
"group_labels": admin_data_groups.labels(),
"group_counts": {
group.id: data_groups.counts(db, group.id, owner=user) for group in usable
},
"group_connections": [
{
"connection": connection,
"instance": connection.data_group_id or data_groups.DEFAULT_GROUP,
"chosen": data_groups.personal_map(user).get(connection.id, ""),
"in_force": in_force.get(connection.id, data_groups.DEFAULT_GROUP),
}
for connection in connections
],
}
def describe_verdict(verdict) -> str:
"""Why a model is not offered, in the reader's language.
`talk.Verdict` carries a code so this can be said here with `t()`, while a
model refused a friend is told the same thing in English.
"""
from lembas.services import talk
rule = verdict.rule
if verdict.why in (talk.WHY_INSTANCE_RULE, talk.WHY_YOUR_RULE) and rule is not None:
frm = i18n.t("any model") if rule.from_model == "*" else rule.from_model
to = i18n.t("any model") if rule.to_model == "*" else rule.to_model
if verdict.why == talk.WHY_INSTANCE_RULE:
if rule.effect == "allow":
return i18n.t("The instance's rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("The instance's rule %(a)s → %(b)s forbids it.", a=frm, b=to)
if rule.effect == "allow":
return i18n.t("Your rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("Your rule %(a)s → %(b)s forbids it.", a=frm, b=to)
return {
talk.WHY_GROUP: i18n.t("It is in another data group."),
talk.WHY_INSTANCE_CLOSED: i18n.t("The instance lets no model talk to another."),
talk.WHY_YOUR_CLOSED: i18n.t("Your setting lets no model talk to another."),
}.get(verdict.why, "")
def _helper_context(db: DBSession, user: User, chat: Chat | None, own: str) -> dict:
"""The helpers picker: models designated for this chat's model, to add by hand.
Only when helpers are on and this person may send one, and only when
something is designated -- with nothing designated the model is its own
helper and there is nothing to choose. On the new-chat screen the choice
rides along with the first message, exactly as the crowd's does.
"""
from lembas.services import helpers as helpers_service
empty = {"helper_choices": [], "helper_member_ids": []}
if not settings_store.subagents(db).get("enabled"):
return empty
if not permissions.has(db, user, "tools.subagent"):
return empty
main = chat if chat is not None else own
if not main:
return empty
choices = helpers_service.picker(db, main, user)
return {
"helper_choices": choices,
"helper_reasons": {
choice.model.model_id: (
# One literal, not two adjacent ones: the catalogue extractor
# reads the first string of a `t()` call only, so a sentence split
# across two would be looked up under a key that exists nowhere.
i18n.t("It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.") # noqa: E501
if choice.capacity
else (describe_verdict(choice.verdict) if not choice.addable else "")
)
for choice in choices
},
"helper_member_ids": [row.model_id for row in chat.helpers] if chat else [],
}
def _crowd_context(
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
) -> dict:
"""Who else could answer in this chat, and what that would cost.
Empty — and the panel then shows nothing rather than an empty control — when
the feature is off, when there is nobody else to add, or on the new-chat
screen, where there is no chat to attach anybody to yet.
Offered on the **new-chat screen as well**, where there is no chat row yet: the
choice rides along with the first message, the way the scope switches do. The
first version of this was per-chat only and therefore invisible to anybody
setting a conversation up — which is how the feature shipped switched on and
unreachable. Empty only when the feature is off or there is nobody else to add,
and then the control is absent rather than being an empty menu.
The cost is spelled out because it is the thing somebody will not have thought
about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint
@@ -211,22 +370,59 @@ def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -
from lembas.services import crowd as crowd_service
settings = settings_store.crowd(db)
if chat is None or not settings["enabled"]:
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
if not settings["enabled"]:
return {
"crowd_available": [],
"crowd_held_back": [],
"crowd_member_ids": [],
"crowd_skipped": [],
}
others = [model for model in models if model.model_id != chat.model_id]
members = [
row.model_id
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
# On the new-chat screen the "own" model is whichever one the picker is
# showing, so the list excludes it for the same reason it does in a chat:
# adding it would have it answer twice in a row.
own = chat.model_id if chat is not None else default_model_id
# The talk rules, evaluated from the main model -- on the new-chat screen the
# one the picker shows, whose data group the chat will be pinned to. Offered
# models are the main list; the rest are named below it with the reason, and
# may still be ticked by hand where the rules let this person do that.
from lembas.services import talk
group = (
data_groups.for_chat(db, chat)
if chat is not None
else (data_groups.for_pair(db, user, own) if own else None)
)
if own and group is not None:
pairs = talk.candidates(db, user, own, group)
else:
pairs = [(model, talk.Verdict(offered=True, addable=True)) for model in models]
pairs = [(model, verdict) for model, verdict in pairs if model.model_id != own]
others = [model for model, verdict in pairs if verdict.offered]
held_back = [
{"model": model, "addable": verdict.addable, "reason": describe_verdict(verdict)}
for model, verdict in pairs
if not verdict.offered
]
reachable = {model.model_id for model in others}
members = (
[
row.model_id
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
]
if chat is not None
else []
)
reachable = {model.model_id for model, verdict in pairs if verdict.addable}
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
rounds = int(settings["max_rounds"])
return {
"crowd_available": others,
"crowd_held_back": held_back,
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
"crowd_skipped": crowd_service.unreachable_members(db, chat, user),
# One round is out and back: everybody answers, everybody but the last is
"crowd_skipped": (
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
),
# One round is out and back: everybody answers, everybody but the last is
# asked whether they disagree, and the main model closes.
"crowd_replies": max(1, speakers * 2 - 1),
"crowd_rounds": rounds,
@@ -710,6 +906,24 @@ async def chat_index(
if preselected is None and context["models"]:
preselected = context["models"][0]
# Every preselection lives in the URL, so every link that changes one of
# them has to carry the rest. The temporary toggle used to link to a bare
# `/chat?temporary=1` and the model picker to a bare `/chat?model=`, so
# each undid the other: temporary chats could only ever be started on the
# default model. The model goes last in the picker's URL because ui.js
# appends the chosen id to it.
carried = {
"model": model if model and preselected and preselected.model_id == model else "",
"temporary": "1" if temporary else "",
"kind": kind if kind in KINDS and kind != KIND_CHAT else "",
"folder": starting_folder.id if starting_folder is not None else "",
}
def new_chat_url(**changes: str) -> str:
query = urlencode({k: v for k, v in {**carried, **changes}.items() if v})
return f"/chat?{query}" if query else "/chat"
without_model = new_chat_url(model="")
return render(
request,
"chat/index.html",
@@ -719,7 +933,34 @@ async def chat_index(
"bodies": {},
**context,
"current_model": preselected,
# `_chat_context` reads the efforts off the *chat's* model, and there
# is no chat here -- so every new chat was offered the generic three
# whatever it was about to talk to. On Bonsai (low, medium, xhigh)
# the configured `xhigh` was not among them, and the picker fell
# through to "off". The chat created from this screen then got
# `xhigh` anyway, so the control said one thing and the first reply
# did another.
"efforts": (
chat_service.efforts_for(preselected)
if preselected
else chat_service.DEFAULT_EFFORTS
),
# `_chat_context` had no chat and so no main model to build the crowd
# list from: the list offered here included the model it would be
# added to, and ignored which data group the new chat is going into.
**_crowd_context(
db,
user,
None,
context["models"],
preselected.model_id if preselected is not None else "",
),
**_helper_context(
db, user, None, preselected.model_id if preselected is not None else ""
),
"starting_temporary": temporary,
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
"starting_kind": kind if kind in KINDS else KIND_CHAT,
"starting_folder": starting_folder,
"suggestions": suggestions_service.visible(db),
@@ -908,6 +1149,11 @@ async def settings_page(
# would leave no way to delete them.
"personalities": personas_service.personas_of(db, user),
"impressions": personas_service.impressions_for(db, user),
# A person's key carries the data group after the model id; the
# template shows the two apart rather than printing the raw key.
"split_key": personas_service.split_key,
**_data_group_settings(db, user),
**_talk_settings(db, user),
# Sorted rather than left in set order, because a list of six
# hundred zones that is not alphabetical is one nobody can use.
"languages": i18n.LANGUAGES,
+168
View File
@@ -293,3 +293,171 @@ async def change_password(
path="/",
)
return response
# --- Data groups -------------------------------------------------------------
# A person's own arrangement of which provider may read which of their data.
# Everything here needs `data.manage`: it changes what a provider can see, and an
# instance that never granted it keeps the arrangement its administrator made.
def _refuse_without_manage(db, user) -> Response | None:
from lembas.services import data_groups
if data_groups.may_manage(db, user):
return None
return RedirectResponse(
"/settings?error=You+may+not+manage+your+own+data+groups.", status_code=303
)
@router.post("/data-groups")
async def set_data_groups(request: Request, db: Db, user: RequiredUser) -> Response:
"""Which group each connection reads, for this person.
One select per connection, named `group__<connection id>`; empty means
"follow the instance", which removes the entry rather than storing a copy of
the administrator's choice -- a copy would stop following it the day it
changed.
"""
from lembas.services import data_groups
refused = _refuse_without_manage(db, user)
if refused is not None:
return refused
form = await request.form()
chosen: dict[str, str] = {}
for key, value in form.items():
if not key.startswith("group__"):
continue
connection_id, group_id = key.removeprefix("group__"), str(value).strip()
if group_id and data_groups.may_use(db, user, group_id):
chosen[connection_id] = group_id
user.settings_json = {**(user.settings_json or {}), data_groups.SETTING_KEY: chosen}
db.commit()
return RedirectResponse("/settings?saved=Data+groups+updated.", status_code=303)
@router.post("/data-groups/new")
async def create_personal_group(
db: Db, user: RequiredUser, name: str = Form("")
) -> Response:
from lembas.db.models import DataGroup
refused = _refuse_without_manage(db, user)
if refused is not None:
return refused
name = " ".join(name.split())[:120]
if not name:
return RedirectResponse("/settings?error=A+data+group+needs+a+name.", status_code=303)
db.add(DataGroup(name=name, owner_id=user.id))
db.commit()
return RedirectResponse("/settings?saved=Data+group+created.", status_code=303)
@router.post("/data-groups/{group_id}/delete")
async def delete_personal_group(db: Db, user: RequiredUser, group_id: str) -> Response:
"""Remove one of this person's own groups -- only once nothing is in it."""
from urllib.parse import quote
from lembas.services import data_groups
refused = _refuse_without_manage(db, user)
if refused is not None:
return refused
group = data_groups.get(db, group_id)
if group is None or group.owner_id != user.id:
return RedirectResponse("/settings?error=No+such+data+group.", status_code=303)
try:
data_groups.delete(db, group)
except ValueError as exc:
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
return RedirectResponse("/settings?saved=Data+group+deleted.", status_code=303)
# --- Talk rules ----------------------------------------------------------------
# A person's own layer of who may talk to whom. Anybody may keep one: without
# `rules.override` it can only narrow what the instance allows, which is theirs
# to decide; with it, it wins. See services/talk.py.
@router.post("/talk-mode")
async def set_talk_mode(db: Db, user: RequiredUser, mode: str = Form("")) -> Response:
from lembas.services import talk
settings_map = {**(user.settings_json or {})}
if mode in talk.MODES:
settings_map[talk.SETTING_KEY] = mode
else:
settings_map.pop(talk.SETTING_KEY, None)
user.settings_json = settings_map
db.commit()
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules")
async def add_talk_rule(
db: Db,
user: RequiredUser,
from_model: str = Form("*"),
to_model: str = Form("*"),
effect: str = Form("deny"),
both: bool = Form(False),
) -> Response:
from lembas.services import talk
talk.set_rule(db, user, from_model, to_model, effect, both=both)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules/{rule_id}/delete")
async def delete_talk_rule(db: Db, user: RequiredUser, rule_id: str) -> Response:
from lembas.services import talk
talk.delete_rule(db, user, rule_id)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
# --- Helper models -------------------------------------------------------------
# A person's own designations: for each of their models, others it may send
# helpers to. Added to the instance's, for them alone. Needs `helpers.designate`.
def _refuse_without_designate(db, user) -> Response | None:
from lembas.services import helpers
if helpers.may_designate(db, user):
return None
return RedirectResponse(
"/settings?error=You+may+not+choose+your+own+helper+models.", status_code=303
)
@router.post("/helpers")
async def add_own_helper(
db: Db,
user: RequiredUser,
main_model: str = Form(""),
helper_model: str = Form(""),
offer: bool = Form(False),
) -> Response:
from lembas.security import permissions
from lembas.services import helpers
refused = _refuse_without_designate(db, user)
if refused is not None:
return refused
# Only models this person can reach, on both sides: a designation naming one
# they cannot use would never be offered and would sit there unexplained.
if main_model == helper_model or not (
permissions.can_use_model(db, user, main_model)
and permissions.can_use_model(db, user, helper_model)
):
return RedirectResponse("/settings?error=Choose+two+different+models.", status_code=303)
helpers.set_designation(db, user, main_model, helper_model, offer=offer)
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
@router.post("/helpers/{designation_id}/delete")
async def delete_own_helper(db: Db, user: RequiredUser, designation_id: str) -> Response:
from lembas.services import helpers
refused = _refuse_without_designate(db, user)
if refused is not None:
return refused
helpers.delete_designation(db, user, designation_id)
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
+1 -1
View File
@@ -237,7 +237,7 @@ async def describe_schedule(request: Request, db: Db, user: RequiredUser):
described = str(form.get("request") or "").strip()
template = prompts_service.resolve(db, "task.schedule_compile")
resolved = compile_service.endpoint_for(db, user)
resolved = compile_service.endpoint_for(db, user, str(form.get("model_id") or "").strip())
if resolved is None:
compiled = compile_service.Compiled(
instruction=described,
+14
View File
@@ -31,11 +31,14 @@ from lembas.db.models.chat import (
ROLE_TOOL,
ROLE_USER,
Chat,
ChatHelper,
CrowdMember,
Folder,
Message,
)
from lembas.db.models.connection import Connection, Model, model_groups
from lembas.db.models.data_group import DEFAULT_GROUP, DataGroup, InDataGroup
from lembas.db.models.helper import HelperDesignation
from lembas.db.models.image import ImageWorkflow
from lembas.db.models.library import (
AUTHOR_MODEL,
@@ -83,6 +86,7 @@ from lembas.db.models.schedule import (
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.talk import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY, EFFECTS, TalkRule
from lembas.db.models.tool import (
RESPONSE_JSON,
RESPONSE_MODES,
@@ -164,9 +168,19 @@ __all__ = [
"Report",
"Schedule",
"Chat",
"ChatHelper",
"CrowdMember",
"HelperDesignation",
"Job",
"Connection",
"DEFAULT_GROUP",
"DataGroup",
"ANY_MODEL",
"EFFECT_ALLOW",
"EFFECT_DENY",
"EFFECTS",
"TalkRule",
"InDataGroup",
"CustomTool",
"CHUNK_DOCUMENT",
"CHUNK_KINDS",
+32 -1
View File
@@ -17,6 +17,7 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.models.data_group import InDataGroup
from lembas.db.types import JSONDict, JSONList
if TYPE_CHECKING:
@@ -173,7 +174,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
return f"<Folder {self.name}>"
class Chat(UUIDPrimaryKey, Timestamps, Base):
class Chat(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
__tablename__ = "chats"
user_id: Mapped[str] = mapped_column(
@@ -324,6 +325,12 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
cascade="all, delete-orphan",
order_by="CrowdMember.position",
)
# Helper models somebody added to this chat by hand. See `ChatHelper`.
helpers: Mapped[list[ChatHelper]] = relationship(
back_populates="chat",
cascade="all, delete-orphan",
order_by="ChatHelper.position",
)
def __repr__(self) -> str:
return f"<Chat {self.title!r}>"
@@ -371,6 +378,30 @@ class CrowdMember(UUIDPrimaryKey, Timestamps, Base):
return f"<CrowdMember {self.model_id} at {self.position}>"
class ChatHelper(UUIDPrimaryKey, Timestamps, Base):
"""A model added to this chat by hand, for its model to send helpers to.
`CrowdMember`'s shape and `CrowdMember`'s reasoning: the model is text with
no foreign key, so a "Test & refresh" cannot silently empty the list. A
helper that no longer resolves is simply not offered.
"""
__tablename__ = "chat_helpers"
__table_args__ = (UniqueConstraint("chat_id", "model_id"),)
chat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
)
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
connection_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="helpers")
def __repr__(self) -> str:
return f"<ChatHelper {self.model_id} at {self.position}>"
class Message(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "messages"
+16 -1
View File
@@ -19,6 +19,7 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.models.data_group import InDataGroup
from lembas.db.types import JSONDict, JSONList
if TYPE_CHECKING:
@@ -36,7 +37,7 @@ model_groups = Table(
)
class Connection(UUIDPrimaryKey, Timestamps, Base):
class Connection(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""A configured upstream endpoint speaking the OpenAI HTTP API.
Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's
@@ -70,6 +71,14 @@ class Connection(UUIDPrimaryKey, Timestamps, Base):
unload_url: Mapped[str] = mapped_column(String(500), default="")
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
# Whether this endpoint holds one model at a time -- llama-swap in front of
# one GPU, which swaps the model out to serve another. A model here may then
# be its own helper, never another model from this connection: the helper
# would evict the model whose reply is waiting on it. Named for the
# *restrictive* state on purpose: `sync_schema` backfills a NOT NULL boolean
# with False, so False has to mean "as before" (any number of models at once).
one_model_at_a_time: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# 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="")
@@ -118,6 +127,12 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Pinned models are offered first, before the full list.
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Whether this model serves one request at a time, so it cannot be its own
# helper: the helper's request would queue behind the reply that is waiting
# for it. The restrictive state, for the backfill reason on
# `Connection.one_model_at_a_time`. A column and not a `capabilities_json`
# key, because that dict is rebuilt from the checkboxes on every save.
single_session: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Public models are usable by anyone; otherwise access comes from `groups`.
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+86
View File
@@ -0,0 +1,86 @@
"""Data groups: which provider may read which of a person's data.
A connection belongs to a data group, and so does everything a model can be
handed about a person -- memories, notes, skills, knowledge bases, reports, the
personality and impression a model keeps, and the chats themselves. A model
reads only the rows of the group its own connection is in. Two providers in one
group see the same data; two in different groups never see each other's.
**Data belongs to a group, not to a connection.** Moving a connection into
another group does not carry anything with it -- that provider simply starts
reading the other group. That is the only reading under which "which provider
has seen this?" has an answer that does not depend on history.
`id` is a short string rather than a generated UUID so the one group every
instance has can be called `"default"` in code and in the database alike, and
every row written before groups existed can be backfilled to it without a
lookup. See services/data_groups.py for how a group is resolved.
"""
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, new_id
# The group every instance has, every connection is in until somebody says
# otherwise, and every row written before 1.10.0 is backfilled to.
DEFAULT_GROUP = "default"
class DataGroup(Timestamps, Base):
"""One partition of the people's data, and the models that may read it.
`owner_id` NULL is an instance group, set up by an administrator and usable
by everybody. Set, it is somebody's personal group -- made by a person
holding `data.manage` to keep one provider away from the rest of their own
data, and invisible to everybody else.
The four model columns name the services that read a group's data without
being a chat's model: the embedder that indexes it and the model that
reviews generated images. Empty means the instance's own choice, which is
what every group starts with. They are text ids with a connection beside
them, never a `Model` primary key, for the reason `Chat.model_id` gives: a
"Test & refresh" recreates the row.
"""
__tablename__ = "data_groups"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
embedding_model_id: Mapped[str] = mapped_column(String(300), default="")
embedding_connection_id: Mapped[str] = mapped_column(String(32), default="")
review_model_id: Mapped[str] = mapped_column(String(300), default="")
review_connection_id: Mapped[str] = mapped_column(String(32), default="")
@property
def is_default(self) -> bool:
return self.id == DEFAULT_GROUP
@property
def personal(self) -> bool:
return self.owner_id is not None
def __repr__(self) -> str:
return f"<DataGroup {self.id} {self.name!r}>"
class InDataGroup:
"""Mixin: the data group a row belongs to.
Nullable, and NULL reads as the default group everywhere -- which is what a
row written before 1.10.0 holds until `data_groups.sweep_unassigned` reaches
it at startup. A plain string rather than a foreign key: `sync_schema` adds
a column with its type only, so a `REFERENCES` clause would exist on a fresh
database and not on an upgraded one, and the two would then disagree about
what deleting a group does.
"""
data_group_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+37
View File
@@ -0,0 +1,37 @@
"""Which models a main model may send helpers to, besides itself.
Designated **per main model**, on the owner's word: gpt-oss may use qwen35,
bonsai may use deepseek-flash, and neither says anything about the other. The
instance designates (`owner_id` NULL); a person holding `helpers.designate` may
add their own, and theirs are added to the instance's -- a row of theirs for the
same pair wins, which is how they change whether it is offered.
`offer` says whether the main model may choose the helper itself. Off, it can be
added to a chat only by hand, and is then the chat's.
Text model ids and no foreign key, for the reason every such reference here has:
"Test & refresh" recreates `Model` rows.
"""
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
class HelperDesignation(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "helper_designations"
__table_args__ = (UniqueConstraint("owner_id", "main_model", "helper_model"),)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
main_model: Mapped[str] = mapped_column(String(300), nullable=False)
helper_model: Mapped[str] = mapped_column(String(300), nullable=False)
helper_connection_id: Mapped[str] = mapped_column(String(32), default="")
offer: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
def __repr__(self) -> str:
return f"<HelperDesignation {self.main_model} -> {self.helper_model}>"
+5 -4
View File
@@ -38,6 +38,7 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.models.data_group import InDataGroup
# Who wrote a record. Not decoration: a skill the model wrote itself is the one
# worth looking at twice when its behaviour changes unexpectedly.
@@ -81,7 +82,7 @@ chat_knowledge_bases = Table(
)
class KnowledgeBase(UUIDPrimaryKey, Timestamps, Base):
class KnowledgeBase(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""A named collection of documents.
Sharing lives here rather than on the individual document: "this folder is
@@ -167,7 +168,7 @@ class Document(UUIDPrimaryKey, Timestamps, Base):
return f"<Document {self.title!r}>"
class Note(UUIDPrimaryKey, Timestamps, Base):
class Note(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""Something the model wrote down, or a person did.
Longer and more specific than a memory. Not injected: a handful of notes
@@ -188,7 +189,7 @@ class Note(UUIDPrimaryKey, Timestamps, Base):
return f"<Note {self.title!r}>"
class Memory(UUIDPrimaryKey, Timestamps, Base):
class Memory(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""One short fact, in front of the model on every turn.
Deliberately not shareable and deliberately small. The length cap is
@@ -208,7 +209,7 @@ class Memory(UUIDPrimaryKey, Timestamps, Base):
return f"<Memory {self.content[:40]!r}>"
class Skill(UUIDPrimaryKey, Timestamps, Base):
class Skill(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""A named set of instructions the model can choose to follow.
`description` is the load-bearing field: it is what gets injected, and it is
+2 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.models.data_group import InDataGroup
# Where a report came from. Not a foreign key to anything -- see `source_id`.
SOURCE_SCHEDULE = "schedule"
@@ -14,7 +15,7 @@ SOURCE_MANUAL = "manual"
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
class Report(UUIDPrimaryKey, Timestamps, Base):
class Report(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""A finished piece of work, filed.
Deliberately not a `Chat` with one `Message` in it. A report is read top to
+2 -1
View File
@@ -8,6 +8,7 @@ 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.models.data_group import InDataGroup
from lembas.db.types import JSONDict
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
@@ -26,7 +27,7 @@ ORIGIN_MODEL = "model"
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
class Schedule(UUIDPrimaryKey, Timestamps, Base):
class Schedule(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
"""One standing instruction and when it comes due.
The row carries no recurrence logic at all: `rule_json` is read by
+43
View File
@@ -0,0 +1,43 @@
"""Talk rules: which model may talk to which.
A rule names a *main* model -- the one a chat belongs to -- and a *target*, and
says allow or deny. It governs who a main model is offered as a crowd member, as
a friend to ask, and on its roster of peers. Keyed on the models' text ids, never
a `Model` primary key, for the reason every such reference here is: "Test &
refresh" recreates the row, and a rule that silently stopped applying after a
refresh is the worst kind of rule.
`owner_id` NULL is an instance rule, written by an administrator. Set, it is one
person's own. `*` on either side means any model. See services/talk.py for how
the two layers and the instance's mode are combined.
"""
from __future__ import annotations
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
ANY_MODEL = "*"
EFFECT_ALLOW = "allow"
EFFECT_DENY = "deny"
EFFECTS = (EFFECT_ALLOW, EFFECT_DENY)
class TalkRule(UUIDPrimaryKey, Timestamps, Base):
"""One rule: may `from_model` talk to `to_model`, for everybody or one person."""
__tablename__ = "talk_rules"
__table_args__ = (UniqueConstraint("owner_id", "from_model", "to_model"),)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
from_model: Mapped[str] = mapped_column(String(300), nullable=False)
to_model: Mapped[str] = mapped_column(String(300), nullable=False)
effect: Mapped[str] = mapped_column(String(8), nullable=False, default=EFFECT_DENY)
def __repr__(self) -> str:
whose = self.owner_id or "instance"
return f"<TalkRule {whose} {self.from_model} -> {self.to_model} {self.effect}>"
+12
View File
@@ -17,10 +17,13 @@ from lembas.api import (
admin_agents,
admin_audio,
admin_branding,
admin_crowd,
admin_data_groups,
admin_extraction,
admin_images,
admin_models,
admin_prompts,
admin_rules,
admin_schedules,
admin_search,
admin_suggestions,
@@ -37,6 +40,7 @@ from lembas.api import (
folders,
library,
messages,
models,
pages,
preferences,
push,
@@ -82,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
try:
from lembas.db.session import session_scope
from lembas.services.chat import sweep_temporary
from lembas.services.data_groups import sweep_unassigned
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
@@ -92,6 +97,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Documents that predate knowledge bases have nowhere to live until
# this runs; see services/library/documents.py.
sweep_unfiled(db)
# Rows written before data groups existed, into the group they were
# read in -- see services/data_groups.py.
sweep_unassigned(db)
# Temporary chats older than a day. Startup only, like the sweeps
# above it -- see services/chat.py:sweep_temporary.
sweep_temporary(db)
@@ -203,6 +211,7 @@ def create_app() -> FastAPI:
app.include_router(folders.router)
app.include_router(library.router)
app.include_router(messages.router)
app.include_router(models.router)
app.include_router(reports.router)
app.include_router(schedules.router)
app.include_router(agents.router)
@@ -221,6 +230,9 @@ def create_app() -> FastAPI:
app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
app.include_router(admin_crowd.router)
app.include_router(admin_data_groups.router)
app.include_router(admin_rules.router)
app.include_router(push.router)
app.include_router(branding.router)
+39
View File
@@ -328,6 +328,45 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True,
"Library",
),
# Data groups keep one provider's models away from the data another's have
# been handed. The administrator's arrangement applies to everybody; this
# lets a person make groups of their own, put a connection into one for
# themselves, and move their own records between groups. Off by default,
# because it moves what a provider can read -- and an instance that never
# looks should keep the arrangement its administrator made.
# Talk rules: which model may bring which into a conversation. The
# instance's rules apply to everybody; a person may always narrow them for
# themselves, and with this they may also widen them -- their own rules and
# mode then win, and they may add any model to a crowd by hand. Off by
# default, because an instance rule is usually there for a reason.
PermissionDef(
"rules.override",
"Override the model rules for themselves",
"Let this person's own rules about which model may talk to which win over "
"the instance's, and let them add any model to a crowd by hand.",
False,
"Chat",
),
# Helpers on another model. The instance designates which models each main
# model may send helpers to; this lets a person add their own designations
# for themselves. Off by default: a designation decides where a person's
# tasks -- and whatever context a model writes into them -- are sent.
PermissionDef(
"helpers.designate",
"Choose their own helper models",
"Let this person designate, for each model, other models it may send "
"helpers to -- added to the instance's designations, for them alone.",
False,
"Chat",
),
PermissionDef(
"data.manage",
"Manage their own data groups",
"Make personal data groups, choose which of them each connection reads "
"for this person, and move their own records between groups.",
False,
"Library",
),
)
# Gates whose read and write halves are separate permissions. Keyed on the gate,
+6 -2
View File
@@ -71,7 +71,11 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"chat_empty": (
"Empty chat",
"Above the composer on a chat with nothing in it yet.",
"Speak, friend, and enter.",
# No commas, on purpose. It is the riddle on the Doors of Durin, and
# its answer is to *say* "friend" -- the password is the word itself.
# With commas it is an invitation to a friend, which is the misreading
# that kept the Fellowship outside the door.
"Speak friend and enter.",
),
"offline_title": (
"Offline heading",
@@ -87,7 +91,7 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"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.",
"Speak friend and enter. This door is not yours to open.",
),
"error_404": (
"404 — not found",
+58 -14
View File
@@ -20,6 +20,7 @@ from lembas.db.models import (
Connection,
Message,
Model,
User,
)
from lembas.services import files as files_service
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
@@ -115,17 +116,34 @@ def resolve_endpoint(
if connection is None or not connection.enabled:
# The original connection is gone or disabled. Any enabled connection
# still offering this model id will do.
model = db.scalar(
select(Model)
.join(Connection)
.where(
Model.model_id == speaker.model_id,
Model.enabled.is_(True),
Connection.enabled.is_(True),
# still offering this model id will do -- **in the chat's own data
# group**, for the chat's own model. Any connection at all would repoint
# the conversation onto whichever provider happened to serve the same
# id, and hand it the whole history on the way.
from lembas.services import data_groups
candidates = list(
db.scalars(
select(Model)
.join(Connection)
.where(
Model.model_id == speaker.model_id,
Model.enabled.is_(True),
Connection.enabled.is_(True),
)
.order_by(Connection.position)
)
.order_by(Connection.position)
)
if speaks_for_chat and candidates:
owner = db.get(User, chat.user_id) if chat.user_id else None
groups = data_groups.connection_groups(db, owner)
wanted = data_groups.for_chat(db, chat)
candidates = [
m
for m in candidates
if groups.get(m.connection_id, data_groups.DEFAULT_GROUP) == wanted
]
model = candidates[0] if candidates else None
if model is None:
raise LLMError(
f"No enabled connection currently offers the model "
@@ -588,7 +606,10 @@ def build_request(
if crowd_turn is None and upto is not None:
from lembas.services import crowd as crowd_service
crowd_turn = crowd_service.state_of(upto)
# `scheduling_state`: the opening reply carries a stamp for the chip's
# sake, and regenerating it must still build an ordinary first answer --
# not one told that "the answers above are quoted, yours comes next".
crowd_turn = crowd_service.scheduling_state(upto)
# Images are only sent to a model an administrator has marked as having
# vision. Sending them to one that has not is not a graceful degradation:
# most endpoints reject the whole request.
@@ -838,16 +859,28 @@ def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
return chosen.model_id, chosen.connection_id
def available_models(db: DBSession, user=None) -> list[Model]:
def available_models(db: DBSession, user=None, group: str | None = None) -> list[Model]:
"""Models this user may start a chat with, in the administrator's order.
Pinning does NOT hoist a model up this list: pinned models get their own
shortcuts in the sidebar, and a picker whose order silently differs from
the one configured in the admin screen is just confusing.
`group` narrows to the models whose connection is in one data group for this
person -- what a chat that already exists may switch to, and who may be
asked or added to it. `None` is the new-chat screen, where any model can
start a chat and the chat then takes that model's group.
"""
from lembas.security import permissions
reachable = permissions.models_visible_to(db, user)
if group is not None:
from lembas.services import data_groups
groups = data_groups.connection_groups(db, user)
reachable = [
m for m in reachable if groups.get(m.connection_id, data_groups.DEFAULT_GROUP) == group
]
return sorted(reachable, key=lambda m: (m.position, m.model_id))
@@ -862,7 +895,9 @@ MAX_ROSTER_CHARS = 2400
MAX_ROSTER_ENTRY = 300
def roster_models(db: DBSession, user=None, *, exclude: str = "") -> list[Model]:
def roster_models(
db: DBSession, user=None, *, exclude: str = "", group: str | None = None
) -> list[Model]:
"""The other models this person could reach, in the administrator's order.
`exclude` is a `model_id` and is normally the chat's own: a model does not
@@ -871,10 +906,19 @@ def roster_models(db: DBSession, user=None, *, exclude: str = "") -> list[Model]
would be both a leak and a dead end, since asking it anything is refused by
the same check.
"""
if group is not None:
# Who the main model may talk to, by the talk rules -- a different data
# group counting as a deny that only an explicit rule opens. `exclude`
# is the main model at every call site that passes a group.
from lembas.services import talk
return talk.offered(db, user, exclude, group)
return [model for model in available_models(db, user) if model.model_id != exclude]
def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
def roster_block(
db: DBSession, user=None, *, exclude: str = "", group: str | None = None
) -> str:
"""The roster as the models read it: one line each, name, id, what it is for.
The id is in brackets because it is what has to be typed back into
@@ -885,7 +929,7 @@ def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
"""
lines: list[str] = []
budget = MAX_ROSTER_CHARS
for model in roster_models(db, user, exclude=exclude)[:MAX_ROSTER_MODELS]:
for model in roster_models(db, user, exclude=exclude, group=group)[:MAX_ROSTER_MODELS]:
parts = ((model.description or "").strip(), (model.notes or "").strip())
about = " ".join(part for part in parts if part)
about = " ".join(about.split())[:MAX_ROSTER_ENTRY]
+49 -3
View File
@@ -134,6 +134,41 @@ def state_of(message: Message | None) -> Turn | None:
return None
def is_opening(state: Turn | None) -> bool:
"""Whether this state is the main model's opening reply.
`phase=out, index=0` is **display state and never scheduling state**. The
opening reply is not started by the crowd -- the composer starts it, exactly
as it starts every other reply, and a round only begins when it *finishes*.
Stamping it afterwards is what lets the transcript say `1 of 3` on the bubble
that opened the round; before that it was the one contribution with no chip,
so a two-model round read as an ordinary reply followed by a crowd.
Everything that asks "is a round already in progress?" has to skip it, or the
stamp changes behaviour it was never meant to touch -- see `scheduling_state`.
"""
return state is not None and state.phase == PHASE_OUT and state.index == 0
def scheduling_state(message: Message | None) -> Turn | None:
"""The round state the scheduler should act on: `state_of`, minus the opening.
Two things would break if the opening stamp were fed to `next_turn` as real
state, and both are silent:
* **`started_at` would be inherited on a regenerate.** Regenerating the
opening reply an hour later would hand `next_turn` an hour-old clock and the
round would stop with "out of time" before anybody spoke.
* **The once-per-turn gates key off "no state at all"** -- compaction, the
title, the unread push. A stamped opening reads as a later speaker, and each
of them would be skipped for the turn that is supposed to have them.
So the stamp is written where the transcript reads it and nowhere else.
"""
state = state_of(message)
return None if is_opening(state) else state
def now_stamp() -> str:
return datetime.now(UTC).isoformat()
@@ -237,11 +272,19 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
Deduplicated against the main model: adding the chat's own model to the crowd
would have it answer twice in a row, which is not what anybody meant by it.
And narrowed by the talk rules, evaluated from the main model -- which is
also where a different data group counts as a deny that only a rule opens.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups, talk
# `addable`, not `offered`: a member somebody added by hand is exactly one the
# rules would not have offered, and skipping it when the round runs would
# quietly undo their choice.
reachable = {
model.model_id: model for model in chat_service.roster_models(db, user, exclude="")
model.model_id: model
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
seen = {chat.model_id}
@@ -255,10 +298,11 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
"""Members that will be skipped, so a screen can say so rather than lie."""
from lembas.services import chat as chat_service
from lembas.services import data_groups, talk
reachable = {
model.model_id for model in chat_service.roster_models(db, user, exclude="")
model.model_id
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
return [
member.model_id
@@ -374,9 +418,11 @@ __all__ = [
"Turn",
"elapsed",
"is_newest",
"is_opening",
"member_speakers",
"next_turn",
"now_stamp",
"scheduling_state",
"state_of",
"tool_defs",
"unreachable_members",
+430
View File
@@ -0,0 +1,430 @@
"""Which data group a connection, a chat or a speaker is in.
A data group is the unit of isolation between providers: a model reads the
memories, notes, skills, knowledge, reports, personality and impression of
exactly one group -- the one its connection resolves to -- and a chat belongs
to the group it was started in. See db/models/data_group.py for what the group
itself is.
**The resolution order lives here and nowhere else.** For a connection:
1. the person's own mapping, in `settings_json["data_groups"]`, honoured only
while they hold `data.manage` and only to a group they may use -- so taking
the permission away puts them back on the instance's arrangement without
anybody having to find and clear what they set;
2. the administrator's, `Connection.data_group_id`;
3. the default group.
A mapping to a group that has since been deleted falls through to the next rung
rather than to nothing, for the same reason.
**A chat's group is stamped, not derived.** `for_chat` reads the row, and only
derives -- and stamps -- when the row predates the column. A chat whose model
has since been moved into another group therefore stays where it was, and
`refusal` is what stops that model being handed the chat's history. Deriving it
afresh every turn would instead carry the transcript into whichever group the
model happened to be in today.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
DEFAULT_GROUP,
Chat,
Connection,
DataGroup,
KnowledgeBase,
Memory,
Model,
Note,
Report,
Schedule,
Skill,
User,
)
if TYPE_CHECKING:
from lembas.services.chat import Speaker
log = logging.getLogger(__name__)
DEFAULT_NAME = "Default"
# Where a person's own connection-to-group choices live in `settings_json`.
SETTING_KEY = "data_groups"
# The permission that lets somebody make personal groups, move a connection
# into one for themselves, and move their own records between groups.
PERMISSION = "data.manage"
# Every table carrying `data_group_id` whose NULL means "written before groups
# existed" and therefore belongs in the default group. Chats are not on this
# list: a chat's group is derived from its model, see `for_chat`.
LIBRARY_TABLES: tuple[Any, ...] = (Memory, Note, Skill, KnowledgeBase, Report, Schedule)
# Rows of these, counted per group, on the admin and settings pages.
COUNTED: tuple[tuple[Any, str], ...] = (
(Chat, "chats"),
(Memory, "memories"),
(Note, "notes"),
(Skill, "skills"),
(KnowledgeBase, "knowledge bases"),
(Report, "reports"),
)
def group_of(row: Any) -> str:
"""The group a stored row belongs to. NULL is the default group."""
return getattr(row, "data_group_id", None) or DEFAULT_GROUP
def condition(model: Any, group: str):
"""A WHERE clause selecting the rows of `model` in `group`.
NULL counts as the default, so a row the startup sweep has not reached yet
is never lost from the group it belongs to.
"""
column = model.data_group_id
if group == DEFAULT_GROUP:
return or_(column.is_(None), column == DEFAULT_GROUP)
return column == group
# --- The groups themselves -----------------------------------------------------
def ensure_default(db: DBSession) -> DataGroup:
"""The default group, created the first time anything asks for it."""
group = db.get(DataGroup, DEFAULT_GROUP)
if group is None:
group = DataGroup(id=DEFAULT_GROUP, name=DEFAULT_NAME, position=0)
db.add(group)
db.commit()
return group
def get(db: DBSession, group_id: str | None) -> DataGroup | None:
if not group_id:
return None
if group_id == DEFAULT_GROUP:
return ensure_default(db)
return db.get(DataGroup, group_id)
def all_groups(db: DBSession) -> list[DataGroup]:
"""Every group on the instance, personal ones included. Administrators only."""
ensure_default(db)
return list(
db.scalars(
select(DataGroup).order_by(
DataGroup.owner_id.is_not(None), DataGroup.position, DataGroup.name
)
)
)
def instance_groups(db: DBSession) -> list[DataGroup]:
ensure_default(db)
return list(
db.scalars(
select(DataGroup)
.where(DataGroup.owner_id.is_(None))
.order_by(DataGroup.position, DataGroup.name)
)
)
def usable(db: DBSession, user: User | None) -> list[DataGroup]:
"""The groups this person's data may be in: the instance's, and their own."""
groups = instance_groups(db)
if user is not None:
groups += list(
db.scalars(
select(DataGroup).where(DataGroup.owner_id == user.id).order_by(DataGroup.name)
)
)
return groups
def may_use(db: DBSession, user: User | None, group_id: str) -> bool:
group = get(db, group_id)
if group is None:
return False
return group.owner_id is None or (user is not None and group.owner_id == user.id)
def several(db: DBSession, user: User | None) -> bool:
"""Whether there is any choice to show. One group means no chip, no select."""
return len(usable(db, user)) > 1
def name_of(db: DBSession, group_id: str | None) -> str:
group = get(db, group_id or DEFAULT_GROUP)
return group.name if group is not None else (group_id or DEFAULT_NAME)
def may_manage(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
# --- Connections ---------------------------------------------------------------
def personal_map(user: User | None) -> dict[str, str]:
"""The person's own connection -> group choices, as stored."""
if user is None:
return {}
stored = (user.settings_json or {}).get(SETTING_KEY) or {}
if not isinstance(stored, dict):
return {}
return {str(k): str(v) for k, v in stored.items() if k and v}
def connection_groups(db: DBSession, user: User | None) -> dict[str, str]:
"""Every connection's group for this person, resolved once.
One query for the lot, because the model lists call this for every model
they show and a query per model would be one per row of every picker.
"""
ensure_default(db)
known = {group.id for group in db.scalars(select(DataGroup))}
resolved: dict[str, str] = {}
for connection_id, group_id in db.execute(select(Connection.id, Connection.data_group_id)):
resolved[connection_id] = group_id if group_id in known else DEFAULT_GROUP
if user is not None and may_manage(db, user):
for connection_id, group_id in personal_map(user).items():
if connection_id in resolved and group_id in known and may_use(db, user, group_id):
resolved[connection_id] = group_id
return resolved
def for_connection(db: DBSession, user: User | None, connection_id: str | None) -> str:
if not connection_id:
return DEFAULT_GROUP
return connection_groups(db, user).get(connection_id, DEFAULT_GROUP)
def for_model(db: DBSession, user: User | None, model: Model) -> str:
return for_connection(db, user, model.connection_id)
def _connection_for(db: DBSession, model_id: str, connection_id: str | None) -> str | None:
"""The connection a (model id, connection) pair actually lands on.
The connection when one is named and still enabled; otherwise the first
enabled connection offering that id, which is exactly the one
`chat.resolve_endpoint` would fall back to.
"""
if connection_id:
connection = db.get(Connection, connection_id)
if connection is not None and connection.enabled:
return connection.id
return db.scalar(
select(Model.connection_id)
.join(Connection)
.where(
Model.model_id == model_id,
Model.enabled.is_(True),
Connection.enabled.is_(True),
)
.order_by(Connection.position)
)
def for_pair(
db: DBSession, user: User | None, model_id: str, connection_id: str | None = None
) -> str:
"""The group of a model named by its text id and, if known, its connection."""
return for_connection(db, user, _connection_for(db, model_id, connection_id))
# --- Chats and speakers --------------------------------------------------------
def for_chat(db: DBSession, chat: Chat | None) -> str:
"""The group a chat belongs to.
Read from the row. A row with none -- written before groups, or by a path
that creates a chat without going through `_new_chat` -- is given the group
of its model now, and keeps it.
"""
if chat is None:
return DEFAULT_GROUP
if chat.data_group_id:
return chat.data_group_id
owner = db.get(User, chat.user_id) if chat.user_id else None
group = DEFAULT_GROUP
if chat.model_id:
group = for_pair(db, owner, chat.model_id, chat.connection_id)
chat.data_group_id = group
return group
def for_speaker(
db: DBSession, user: User | None, chat: Chat | None, speaker: Speaker | None
) -> str:
"""The group whose data this speaker is handed.
The chat's own group for the chat's own model. For anybody else -- a crowd
member, a schedule's model -- the group of *their* connection: a model reads
its own group's stores and never the chat's, which is what keeps a crowd
member from another provider out of this group's memories even when a rule
has let it into the conversation.
"""
if chat is not None and (speaker is None or speaker.model_id == chat.model_id):
return for_chat(db, chat)
if speaker is None or not speaker.model_id:
return DEFAULT_GROUP
return for_pair(db, user, speaker.model_id, speaker.connection_id)
def for_composer(db: DBSession, user: User | None, chat_id: str, model_id: str = "") -> str:
"""The group a composer is writing into: its chat's, or its chosen model's.
What the `@` menu and the library picker filter on. A copy made from the
library becomes part of the conversation and is sent to the chat's model,
so offering another group's note there would be the boundary crossed by
hand. On the new-chat screen there is no chat yet, and the model the
composer has chosen decides -- it is the one the chat will be pinned to.
"""
chat = db.get(Chat, chat_id) if chat_id else None
if chat is not None and user is not None and chat.user_id == user.id:
return for_chat(db, chat)
if model_id:
return for_pair(db, user, model_id)
from lembas.services import chat as chat_service
chosen = chat_service.default_model(db, user)
return for_pair(db, user, *chosen) if chosen else DEFAULT_GROUP
def refusal(db: DBSession, user: User | None, chat: Chat, speaker: Speaker) -> str:
"""Why this speaker may not be sent this chat, or "" when it may.
Only the chat's own model is checked here: a crowd member reads its own
group's stores whatever the chat's group is, and whether it may join the
conversation at all is decided where the crowd is assembled.
Refused when the model's connection is now in a different group from the
chat -- an administrator moved it, or the person remapped it. Sending the
reply anyway would hand that provider the chat's whole history.
"""
if speaker.model_id != chat.model_id:
return ""
chat_group = for_chat(db, chat)
model_group = for_pair(db, user, speaker.model_id, speaker.connection_id)
if model_group == chat_group:
return ""
return (
f"This chat belongs to the data group {name_of(db, chat_group)!r}, and its "
f"model is now in {name_of(db, model_group)!r}, so it cannot be sent this "
f"chat's history. Pick a model in {name_of(db, chat_group)!r}, or start a "
f"new chat."
)
# --- Housekeeping ----------------------------------------------------------------
def sweep_unassigned(db: DBSession) -> int:
"""Put every row written before 1.10.0 into the group it belongs to.
Library rows go to the default group: before groups existed every
connection was in it, so that is where every one of them was read. Chats
get their model's group, which on an upgrade is the default too, and on a
later run is the right answer for a chat some path created without
stamping one. Runs at startup beside `documents.sweep_unfiled`.
"""
ensure_default(db)
moved = 0
for model in LIBRARY_TABLES:
result = db.execute(
update(model).where(model.data_group_id.is_(None)).values(data_group_id=DEFAULT_GROUP)
)
moved += result.rowcount or 0
for chat in db.scalars(select(Chat).where(Chat.data_group_id.is_(None))):
for_chat(db, chat)
moved += 1
db.commit()
if moved:
log.info("data groups: %d rows assigned", moved)
return moved
def counts(db: DBSession, group_id: str, *, owner: User | None = None) -> dict[str, int]:
"""How many of each kind of record are in a group, for one person or all."""
found: dict[str, int] = {}
for model, label in COUNTED:
query = select(func.count()).select_from(model).where(condition(model, group_id))
if owner is not None:
column = model.user_id if model is Chat else model.owner_id
query = query.where(column == owner.id)
found[label] = db.scalar(query) or 0
return found
def in_use(db: DBSession, group_id: str) -> dict[str, int]:
"""What still points at a group, which is what stops it being deleted."""
found = counts(db, group_id)
found["connections"] = (
db.scalar(
select(func.count())
.select_from(Connection)
.where(Connection.data_group_id == group_id)
)
or 0
)
return {label: n for label, n in found.items() if n}
def delete(db: DBSession, group: DataGroup) -> None:
"""Remove a group that nothing is in. Raises ValueError otherwise.
Refused rather than cascaded. Deleting a group's records along with it is
far too large a thing to do from one button, and moving them into another
group silently would hand them to that group's providers.
"""
if group.is_default:
raise ValueError("The default group cannot be deleted.")
busy = in_use(db, group.id)
if busy:
listed = ", ".join(f"{n} {label}" for label, n in busy.items())
raise ValueError(f"The group still holds {listed}. Move them out first.")
# Nobody may go on naming a group that is gone; their mapping falls through.
for user in db.scalars(select(User)):
mapped = personal_map(user)
if group.id in mapped.values():
kept = {k: v for k, v in mapped.items() if v != group.id}
user.settings_json = {**(user.settings_json or {}), SETTING_KEY: kept}
db.delete(group)
db.commit()
__all__ = [
"DEFAULT_GROUP",
"all_groups",
"condition",
"connection_groups",
"counts",
"delete",
"ensure_default",
"for_chat",
"for_composer",
"for_connection",
"for_model",
"for_pair",
"for_speaker",
"get",
"group_of",
"in_use",
"instance_groups",
"may_manage",
"may_use",
"name_of",
"personal_map",
"refusal",
"several",
"sweep_unassigned",
"usable",
]
+41 -7
View File
@@ -34,7 +34,7 @@ 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 crowd as crowd_service
from lembas.services import interaction, settings_store, tokens, tool_labels
from lembas.services import data_groups, interaction, settings_store, tokens, tool_labels
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import push as push_service
@@ -640,8 +640,15 @@ async def _run(generation: Generation) -> None:
# on has to survive that. It is also the only thing that can make the
# bubble's avatar and the model actually asked agree.
speaker = chat_service.speaker_for(db, chat, message)
endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker)
owner = db.get(User, chat.user_id)
# Before the endpoint is even resolved: a chat whose model has since
# been moved into another data group must not be sent to it, or that
# provider is handed the whole history the group was keeping from it.
moved = data_groups.refusal(db, owner, chat, speaker)
if moved:
generation.error = moved
return
endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker)
# Before the request is built, not while it streams. Every other
# budget here can only be noticed part way through and so ends with
@@ -661,7 +668,10 @@ async def _run(generation: Generation) -> None:
# once, here, and used for three decisions: which tools it may have,
# which instruction closes its request, and whether it may ask for
# another round.
crowd_state = crowd_service.state_of(message)
# `scheduling_state` for the reason `build_request` gives: the
# opening reply's stamp is for the transcript, and regenerating it
# must not hand it a member's tools or a member's instruction.
crowd_state = crowd_service.scheduling_state(message)
crowd_settings = settings_store.crowd(db)
may_ask_again = bool(
crowd_state is not None
@@ -2232,7 +2242,11 @@ def _advance_crowd(generation: Generation) -> bool:
speakers = crowd_service.member_speakers(db, chat, owner_user)
speakers = speakers[: int(settings["max_models"]) + 1]
state = crowd_service.state_of(message)
# `scheduling_state` and not `state_of`: the opening reply carries a
# stamp for the transcript's sake (so it can say `1 of 3`), and that
# stamp must not read as "a round is already running" -- it would
# inherit the old clock on a regenerate. See `crowd.is_opening`.
state = crowd_service.scheduling_state(message)
# The turn a round belongs to: the user message this all answers.
turn_id = state.turn if state is not None else _turn_anchor(db, message)
following = crowd_service.next_turn(
@@ -2254,6 +2268,22 @@ def _advance_crowd(generation: Generation) -> bool:
db.commit()
return False
if state is None:
# The round begins here, so stamp the reply that opened it. It is
# the only contribution that is not started by the crowd, and
# before this it was the only one with no chip -- which made a
# two-model round read as an ordinary reply followed by a crowd,
# and left the reader counting "2 of 2" with no 1 in sight. Same
# turn and same `started_at`, so the bubbles group.
message.crowd_json = crowd_service.Turn(
turn=following.turn,
round=following.round,
phase=crowd_service.PHASE_OUT,
index=0,
of=following.of,
started_at=following.started_at,
).as_json()
speaker = speakers[following.index]
placeholder = chat_service.create_message(
db,
@@ -2281,10 +2311,14 @@ def _opens_the_turn(message: Message) -> bool:
"""Whether this reply is the first one answering a question.
True for every ordinary reply, and for a crowd only for the main model's
opening turn -- which is the one with no crowd state on it at all, because a
round begins when that reply *finishes*.
opening turn. That reply has no crowd state while it is being written -- a
round begins when it *finishes* -- and once the round has begun it carries the
opening stamp, which `is_opening` reads as "still the one that opens the
turn". Both are the same answer to this question, and missing the second means
a reply that has already been compacted-for and titled gets it again on the
next look.
"""
return crowd_service.state_of(message) is None
return crowd_service.scheduling_state(message) is None
def _opens_the_turn_id(generation: Generation) -> bool:
+66 -6
View File
@@ -186,6 +186,20 @@ def context_variables(
# set one behaves exactly as it always did.
stamp = clock.now_for(user)
# Whose data this request may carry. The data group of the *answering*
# model -- the chat's own for the chat's model, the member's own for a crowd
# member -- so a model is handed exactly one group's memories, skills and
# personality and never the chat's merely for being in it. No chat is the
# admin preview, which has no speaker and shows every group.
group: str | None = None
if chat is not None:
from lembas.services import chat as chat_service
from lembas.services import data_groups
group = data_groups.for_speaker(
db, user, chat, speaker or chat_service.speaker_for(db, chat)
)
values: dict[str, str] = {
"today": stamp.strftime("%A %-d %B %Y"),
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
@@ -227,9 +241,11 @@ def context_variables(
"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 "",
"memories": memories_service.block(db, user, group) if "memory" in families else "",
"skills": (
skills_service.index_block(db, user, exclude=tools_service.scoped_skills_off(chat))
skills_service.index_block(
db, user, exclude=tools_service.scoped_skills_off(chat), group=group
)
if "skills" in families
else ""
),
@@ -276,6 +292,9 @@ def context_variables(
# opinion. One fragment covering all three would say nothing useful to
# any of them.
"friend": "",
# Where a helper may run, when that is more than the answering model.
# Filled below, from the same candidates the tool's enum was built from.
"helper_models": "",
# Who else is here. Filled below, where the chat's own model is known --
# a model does not need telling that it exists.
"model_roster": "",
@@ -304,8 +323,15 @@ def context_variables(
# Naming the bases a chat is scoped to matters: without it the model
# cannot tell "there is nothing about this" from "I am only allowed to
# see the contracts folder", and phrases a miss as the former.
if "knowledge" in families and chat.knowledge_bases:
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
# Only the attached bases in this speaker's group: a base from another
# group is not searchable here, and naming it would leak its name.
in_group = [
base
for base in chat.knowledge_bases
if (base.data_group_id or data_groups.DEFAULT_GROUP) == group
]
if "knowledge" in families and in_group:
values["knowledge_bases"] = ", ".join(base.name for base in in_group)
values["document_names"] = _document_names(db, chat)
# The one thing a tool description cannot carry, because a description
@@ -338,21 +364,55 @@ def context_variables(
# reason the roster and the tool are one checkbox rather than two.
if "friend" in families:
values["model_roster"] = chat_service.roster_block(
db, user, exclude=speaking.model_id
db, user, exclude=speaking.model_id, group=group
)
if "subagent" in families and speaking.model_id == chat.model_id:
values["helper_models"] = _helper_models(db, chat, user)
if "persona" in families:
# This person's own personality for this model, falling back to the
# administrator's default until the model has written one with them;
# and this model's impression of them, which has no default and never
# could.
key = speaking.model_id
key = personas_service.key_for(speaking.model_id, group)
values["persona"] = personas_service.block(db, key, user)
values["person_view"] = personas_service.view_block(db, key, user)
return values
def _helper_models(db: DBSession, chat, user) -> str:
"""One line per model a helper may run on, or "" when it is only this one.
The same candidates `tools._subagent_defs` built the `model` enum from, so
the list the model reads and the values the call accepts cannot disagree.
Roster-shaped and capped the same way.
"""
from lembas.services import chat as chat_service
from lembas.services import helpers as helpers_service
found = helpers_service.candidates(db, chat, user)
if len(found) < 2:
return ""
lines: list[str] = []
budget = chat_service.MAX_ROSTER_CHARS
for candidate in found[: chat_service.MAX_ROSTER_MODELS]:
model = candidate.model
parts = ((model.description or "").strip(), (model.notes or "").strip())
about = " ".join(" ".join(p for p in parts if p).split())[: chat_service.MAX_ROSTER_ENTRY]
line = f"- {model.label} ({model.model_id})"
if candidate.source == helpers_service.SOURCE_SELF:
line += " — you"
elif about:
line += f" — {about}"
if len(line) > budget:
break
budget -= len(line)
lines.append(line)
return "\n".join(lines)
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
"""What a scheduled task's chat is for, and how often it comes round.
+363
View File
@@ -0,0 +1,363 @@
"""Which model a helper runs on: the main model itself, or one designated for it.
`subagent_run` used to have one answer -- the parent's own model. It has three
sources now, in this order, and they are decided here by logic, never by the
model:
1. **The main model itself**, unless it cannot run two requests at once.
2. **Helpers somebody added to this chat by hand** (`ChatHelper`).
3. **Helpers designated for the main model with `offer` on** -- by the instance,
or by a person holding `helpers.designate` -- which the model may choose.
Each candidate passes two checks before it is offered:
* **Capacity.** A model marked *serves one request at a time* cannot be its own
helper: the helper's request would queue behind the very reply waiting for it.
A connection marked *holds one model at a time* (llama-swap in front of one
GPU) cannot serve a helper on another of its models: loading it evicts the
model whose reply is waiting. The main model may still help itself there.
Both flags default off, so an instance that never sets them behaves exactly as
before -- the main model is its own helper.
* **The talk rules**, read from the main model: a chat's hand-added helpers need
to be `addable`, a designation the model chooses itself needs to be `offered`.
A different data group is the deny a rule has to open, exactly as for a crowd.
`ask_friend` and the crowd do not take the capacity check, on the owner's word:
both are sequential, and waiting for a model to load is accepted there.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Chat, ChatHelper, HelperDesignation, Model, User
PERMISSION = "helpers.designate"
SOURCE_SELF = "self"
SOURCE_CHAT = "chat"
SOURCE_OFFERED = "offered"
@dataclass(frozen=True)
class Candidate:
"""A model a helper may run on, and why it is on the list."""
model: Model
source: str
@dataclass(frozen=True)
class Choice:
"""One designation as the helpers picker shows it.
`reason` is in English, for a model; `capacity` and `verdict` are what a
screen needs to say the same thing in the reader's language.
"""
model: Model
offer: bool
addable: bool
reason: str
capacity: bool = False
verdict: object = None
def may_designate(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
def main_row(db: DBSession, chat: Chat) -> Model | None:
"""The chat's own model as a row, on the connection it is reached through."""
from lembas.services import chat as chat_service
return chat_service.model_row(db, chat_service.speaker_for(db, chat))
def capacity_refusal(main: Model | None, helper: Model) -> str:
"""Why this helper cannot run beside the main model's waiting reply, or ""."""
if main is None:
return ""
if helper.model_id == main.model_id and helper.connection_id == main.connection_id:
if helper.single_session:
return (
f"{helper.label} serves one request at a time, so it cannot be its own "
f"helper: the helper would wait behind the reply that is waiting for it."
)
return ""
if helper.connection_id == main.connection_id:
connection = helper.connection
if connection is not None and connection.one_model_at_a_time:
return (
f"{connection.name} holds one model at a time, so a helper on "
f"{helper.label} would unload {main.label} while its reply waits."
)
return ""
def designations(db: DBSession, user: User | None, main_model: str) -> list[HelperDesignation]:
"""The helpers designated for one main model: the instance's, then the person's.
A person's own row for the same helper replaces the instance's -- that is
how they change whether it is offered -- and theirs count only while they
hold `helpers.designate`.
"""
rows = list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id.is_(None),
HelperDesignation.main_model == main_model,
)
.order_by(HelperDesignation.helper_model)
)
)
if user is not None and may_designate(db, user):
own = list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id == user.id,
HelperDesignation.main_model == main_model,
)
.order_by(HelperDesignation.helper_model)
)
)
replaced = {row.helper_model for row in own}
rows = [row for row in rows if row.helper_model not in replaced] + own
return rows
def _reachable(db: DBSession, user: User | None, model_id: str, connection_id: str | None):
"""The row this person can reach for a model id, preferring a named connection."""
from lembas.services import chat as chat_service
rows = [m for m in chat_service.available_models(db, user) if m.model_id == model_id]
rows.sort(key=lambda m: m.connection_id != (connection_id or ""))
return rows[0] if rows else None
def candidates(db: DBSession, chat: Chat, user: User | None) -> list[Candidate]:
"""Every model a helper of this chat may run on, in the order it is chosen."""
from lembas.services import data_groups, talk
main = main_row(db, chat)
group = data_groups.for_chat(db, chat)
judge = talk.Judge(db, user)
found: list[Candidate] = []
seen: set[str] = set()
if main is not None and not capacity_refusal(main, main):
found.append(Candidate(main, SOURCE_SELF))
seen.add(main.model_id)
for row in chat.helpers:
model = _reachable(db, user, row.model_id, row.connection_id)
if model is None or model.model_id in seen:
continue
if not judge.verdict(chat.model_id, group, model).addable:
continue
if capacity_refusal(main, model):
continue
found.append(Candidate(model, SOURCE_CHAT))
seen.add(model.model_id)
for row in designations(db, user, chat.model_id):
if not row.offer or row.helper_model in seen:
continue
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
if model is None:
continue
if not judge.verdict(chat.model_id, group, model).offered:
continue
if capacity_refusal(main, model):
continue
found.append(Candidate(model, SOURCE_OFFERED))
seen.add(model.model_id)
return found
def choose(
db: DBSession, chat: Chat, user: User | None, wanted: str
) -> tuple[Model | None, str]:
"""The model a helper runs on, or a refusal saying what could have been named.
`wanted` comes from a tool call, so it is matched against the candidates and
nothing else -- by model id, then by label. Empty means the default: the main
model itself, or failing that the first helper added to this chat by hand.
"""
found = candidates(db, chat, user)
names = ", ".join(c.model.model_id for c in found)
wanted = (wanted or "").strip()
if not wanted:
for source in (SOURCE_SELF, SOURCE_CHAT):
for candidate in found:
if candidate.source == source:
return candidate.model, ""
if found:
return None, f"Name the model to send the helper to. You may use: {names}."
return None, _why_none(db, chat)
lowered = wanted.lower()
for candidate in found:
if lowered in (candidate.model.model_id.lower(), candidate.model.label.lower()):
return candidate.model, ""
main = main_row(db, chat)
target = _reachable(db, user, wanted, None)
if target is not None and capacity_refusal(main, target):
reason = capacity_refusal(main, target)
else:
reason = f"{wanted!r} is not a helper you may use."
return None, f"{reason} You may use: {names}." if names else f"{reason} {_why_none(db, chat)}"
def _why_none(db: DBSession, chat: Chat) -> str:
main = main_row(db, chat)
if main is not None and capacity_refusal(main, main):
return capacity_refusal(main, main) + " No other helper is set up for it."
return "No helper model is available here. Do this part yourself."
def picker(db: DBSession, chat_or_main: Chat | str, user: User | None) -> list[Choice]:
"""The designations for a main model, as the composer's helpers picker lists them.
Every designation, offered or not, with whether this person may add it to
the chat by hand and, where not, why. On the new-chat screen there is no
chat yet, so a main model id is passed instead.
"""
from lembas.services import data_groups, talk
if isinstance(chat_or_main, Chat):
main_model = chat_or_main.model_id
main = main_row(db, chat_or_main)
group = data_groups.for_chat(db, chat_or_main)
else:
main_model = chat_or_main
main = _reachable(db, user, main_model, None)
group = data_groups.for_pair(db, user, main_model)
judge = talk.Judge(db, user)
shown: list[Choice] = []
for row in designations(db, user, main_model):
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
if model is None:
continue
verdict = judge.verdict(main_model, group, model)
capacity = capacity_refusal(main, model)
reason = capacity or ("" if verdict.addable else verdict.reason)
shown.append(
Choice(
model,
row.offer,
bool(verdict.addable and not capacity),
reason,
capacity=bool(capacity),
verdict=verdict,
)
)
return shown
def set_designation(
db: DBSession,
owner: User | None,
main_model: str,
helper_model: str,
*,
offer: bool,
connection_id: str = "",
) -> None:
"""Designate a helper for a main model, or change whether it is offered."""
main_model = (main_model or "").strip()[:300]
helper_model = (helper_model or "").strip()[:300]
if not main_model or not helper_model:
return
existing = db.scalar(
select(HelperDesignation).where(
(
HelperDesignation.owner_id.is_(None)
if owner is None
else HelperDesignation.owner_id == owner.id
),
HelperDesignation.main_model == main_model,
HelperDesignation.helper_model == helper_model,
)
)
if existing is not None:
existing.offer = offer
existing.helper_connection_id = connection_id or existing.helper_connection_id
else:
db.add(
HelperDesignation(
owner_id=owner.id if owner is not None else None,
main_model=main_model,
helper_model=helper_model,
helper_connection_id=connection_id or "",
offer=offer,
)
)
db.commit()
def delete_designation(db: DBSession, owner: User | None, designation_id: str) -> bool:
row = db.get(HelperDesignation, designation_id)
if row is None or row.owner_id != (owner.id if owner is not None else None):
return False
db.delete(row)
db.commit()
return True
def own_designations(db: DBSession, owner: User | None) -> list[HelperDesignation]:
return list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id.is_(None)
if owner is None
else HelperDesignation.owner_id == owner.id
)
.order_by(HelperDesignation.main_model, HelperDesignation.helper_model)
)
)
def apply_chat_helpers(db: DBSession, chat: Chat, user: User | None, values: list[str]) -> None:
"""Replace a chat's hand-added helpers with the models named.
Only designations for the chat's model this person may add -- the talk rules
and the capacity check, the same as the picker shows -- and never the chat's
own model, which is its own helper already.
"""
allowed = {c.model.model_id: c.model for c in picker(db, chat, user) if c.addable}
wanted: list[str] = []
for value in values:
value = str(value).strip()
if value and value in allowed and value != chat.model_id and value not in wanted:
wanted.append(value)
chat.helpers = [
ChatHelper(model_id=model_id, connection_id=allowed[model_id].connection_id, position=i)
for i, model_id in enumerate(wanted)
]
__all__ = [
"PERMISSION",
"Candidate",
"Choice",
"apply_chat_helpers",
"candidates",
"capacity_refusal",
"choose",
"delete_designation",
"designations",
"may_designate",
"own_designations",
"picker",
"set_designation",
]
+17 -1
View File
@@ -330,7 +330,23 @@ def _reviewer(context: ToolContext) -> tuple[Endpoint, str] | None:
try:
with session_scope() as db:
model = None
if wanted:
# The chat's data group may name its own reviewer, because the
# reviewer is sent the picture and the prompt that described it --
# this group's data, going to whichever provider reviews. A group
# that names none uses the instance's choice below.
from lembas.services import data_groups
group = data_groups.get(db, context.data_group)
if group is not None and group.review_model_id:
model = db.scalar(
select(Model)
.where(Model.model_id == group.review_model_id)
.order_by(
Model.connection_id != (group.review_connection_id or ""),
Model.position,
)
)
if model is None and wanted:
# By the model's own id, and by primary key for anything stored
# before that was the rule -- a value written by an older release
# is a primary key and must keep working.
+82 -21
View File
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import (
CHUNK_DOCUMENT,
DEFAULT_GROUP,
SOURCE_LINK,
SOURCE_UPLOAD,
Document,
@@ -69,19 +70,27 @@ def stored_path(stored_name: str) -> Path | None:
# --- Bases -------------------------------------------------------------------
def visible_bases(db: DBSession, user: User | None):
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
def visible_bases(db: DBSession, user: User | None, group: str | None = None):
"""Bases this user may see; `group` narrows to one data group, for a model."""
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user, group))
def get_base(db: DBSession, base_id: str, user: User | None) -> KnowledgeBase | None:
def get_base(
db: DBSession, base_id: str, user: User | None, group: str | None = None
) -> KnowledgeBase | None:
base = db.get(KnowledgeBase, base_id)
if base is None or not sharing.can_read(db, base, user):
if base is None or not sharing.can_read(db, base, user, group):
return None
return base
def create_base(
db: DBSession, *, owner: User, name: str, description: str = ""
db: DBSession,
*,
owner: User,
name: str,
description: str = "",
group: str = DEFAULT_GROUP,
) -> KnowledgeBase:
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
existing = db.scalar(
@@ -90,28 +99,58 @@ def create_base(
)
)
if existing is not None:
# Unique per person across every data group: the constraint is
# `(owner_id, name)` and cannot be changed without rebuilding the table.
if (existing.data_group_id or DEFAULT_GROUP) != (group or DEFAULT_GROUP):
raise ValueError(
f"You already have a knowledge base called {name!r} in another "
f"data group. Names are unique across your groups."
)
raise ValueError(f"You already have a knowledge base called {name!r}.")
base = KnowledgeBase(owner_id=owner.id, name=name, description=description.strip()[:2000])
base = KnowledgeBase(
owner_id=owner.id,
data_group_id=group or DEFAULT_GROUP,
name=name,
description=description.strip()[:2000],
)
db.add(base)
db.commit()
return base
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
"""The base a document goes into when none was chosen.
def default_base(db: DBSession, owner: User, group: str = DEFAULT_GROUP) -> KnowledgeBase:
"""The base a document goes into when none was chosen, in one data group.
Made on demand rather than at registration, so an account that never uses
the library never grows an empty one.
the library never grows an empty one. Outside the default group it is named
after the group, because a base name is unique per person across every group
and two called "My documents" cannot both exist.
"""
from lembas.services import data_groups
group = group or DEFAULT_GROUP
base = db.scalar(
select(KnowledgeBase)
.where(KnowledgeBase.owner_id == owner.id)
.where(
KnowledgeBase.owner_id == owner.id,
data_groups.condition(KnowledgeBase, group),
)
.order_by(KnowledgeBase.created_at)
)
if base is not None:
return base
base = KnowledgeBase(owner_id=owner.id, name=DEFAULT_BASE_NAME)
name = DEFAULT_BASE_NAME
if group != DEFAULT_GROUP:
name = f"{DEFAULT_BASE_NAME} — {data_groups.name_of(db, group)}"[:190]
taken = set(
db.scalars(select(KnowledgeBase.name).where(KnowledgeBase.owner_id == owner.id))
)
wanted, counter = name, 2
while wanted in taken:
wanted = f"{name} ({counter})"
counter += 1
base = KnowledgeBase(owner_id=owner.id, name=wanted, data_group_id=group)
db.add(base)
db.commit()
return base
@@ -172,10 +211,15 @@ def store_upload(
filename: str,
title: str = "",
base: KnowledgeBase | None = None,
group: str = DEFAULT_GROUP,
) -> Document:
"""Add an uploaded file to the library. Raises files.FileError if unusable."""
"""Add an uploaded file to the library. Raises files.FileError if unusable.
A document is in the data group of its base. `group` only chooses which
default base it lands in when no base was given.
"""
prepared = files_service.prepare(payload, filename)
base = base or default_base(db, owner)
base = base or default_base(db, owner, group)
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
(library_dir() / stored_name).write_bytes(prepared.payload)
@@ -205,14 +249,19 @@ def store_upload(
def store_page(
db: DBSession, *, owner: User, page: Fetched, base: KnowledgeBase | None = None
db: DBSession,
*,
owner: User,
page: Fetched,
base: KnowledgeBase | None = None,
group: str = DEFAULT_GROUP,
) -> Document:
"""Add a fetched web page to the library.
Saved as text rather than as the original HTML: the point of keeping it is
what it said, and the markup would have to be reduced again on every read.
"""
base = base or default_base(db, owner)
base = base or default_base(db, owner, group)
document = Document(
owner_id=owner.id,
base_id=base.id,
@@ -233,15 +282,22 @@ def store_page(
# --- Reading -----------------------------------------------------------------
def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = None):
def visible(
db: DBSession,
user: User | None,
*,
base_ids: list[str] | None = None,
group: str | None = None,
):
"""Documents this user may see, optionally narrowed to some bases.
Visibility comes from the base, not the document: a document is readable by
whoever can read the base it lives in. That is the whole reason bases are
shareable and documents are not.
shareable and documents are not -- and it is also why a document has no
data group of its own: it is in its base's.
"""
condition = Document.base_id.in_(
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user))
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user, group))
)
query = select(Document).where(condition)
if base_ids:
@@ -251,12 +307,14 @@ def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = No
return query
def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
def get(
db: DBSession, document_id: str, user: User | None, group: str | None = None
) -> Document | None:
document = db.get(Document, document_id)
if document is None:
return None
base = db.get(KnowledgeBase, document.base_id) if document.base_id else None
if base is None or not sharing.can_read(db, base, user):
if base is None or not sharing.can_read(db, base, user, group):
return None
return document
@@ -310,6 +368,7 @@ def search(
limit: int = 10,
base_ids: list[str] | None = None,
vector: list[float] | None = None,
group: str | None = None,
) -> list[Document]:
"""Documents matching `needle` that this user may see, best match first.
@@ -331,7 +390,9 @@ def search(
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(
db.scalars(
visible(db, user, base_ids=base_ids).where(Document.id.in_(list(order)))
visible(db, user, base_ids=base_ids, group=group).where(
Document.id.in_(list(order))
)
)
)
rows.sort(key=lambda document: order.get(document.id, len(order)))
+53 -7
View File
@@ -55,6 +55,7 @@ from lembas.db.models import (
Chunk,
Connection,
Document,
KnowledgeBase,
Model,
Note,
Report,
@@ -92,19 +93,40 @@ class Embedder:
batch: int = 16
def embedder(db: DBSession) -> Embedder | None:
"""The configured embedding model, or None.
def embedder(db: DBSession, group: str | None = None) -> Embedder | None:
"""The embedding model for one data group, or the instance's, 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.
**A group may name its own.** The embedder is sent the full text of every
record it indexes, so a group that keeps its data away from a provider has
to be able to keep it away from that provider's embedder too. A group that
names none uses the instance's, which is what every group starts with --
and the Data groups page says so, per group, beside the providers.
Mixing is impossible by construction rather than by care: a `Chunk` carries
the model that made its vector and `retrieval.semantic_ids` skips any other,
so a query embedded by one group's model never meets another's vectors.
"""
values = settings_store.extraction(db)
batch = int(values.get("embed_batch") or 16)
if group:
from lembas.services import data_groups
row = data_groups.get(db, group)
if row is not None and row.embedding_model_id:
return _resolve(db, row.embedding_model_id, row.embedding_connection_id, batch)
wanted = str(values.get("embedding_model_id") or "").strip()
if not wanted:
return None
model = db.scalar(
return _resolve(db, wanted, "", batch)
def _resolve(db: DBSession, wanted: str, connection_id: str, batch: int) -> Embedder | None:
query = (
select(Model)
.join(Connection)
.where(
@@ -112,8 +134,9 @@ def embedder(db: DBSession) -> Embedder | None:
Model.enabled.is_(True),
Connection.enabled.is_(True),
)
.order_by(Connection.position)
.order_by(Connection.id != (connection_id or ""), Connection.position)
)
model = db.scalar(query)
if model is None:
log.info("embedding model %r is configured but not available", wanted)
return None
@@ -123,7 +146,30 @@ def embedder(db: DBSession) -> Embedder | None:
return Embedder(
endpoint=Endpoint.from_connection(connection),
model_id=model.model_id,
batch=int(values.get("embed_batch") or 16),
batch=batch,
)
def group_of_row(db: DBSession, row) -> str:
"""The data group a record is in. A document is in its base's."""
from lembas.services import data_groups
if isinstance(row, Document):
base = db.get(KnowledgeBase, row.base_id) if row.base_id else None
return data_groups.group_of(base) if base is not None else data_groups.DEFAULT_GROUP
return data_groups.group_of(row)
def any_configured(db: DBSession) -> bool:
"""Whether any group -- or the instance -- has an embedder to index with."""
from lembas.services import data_groups
if embedder(db) is not None:
return True
return any(
embedder(db, group.id) is not None
for group in data_groups.all_groups(db)
if group.embedding_model_id
)
@@ -199,7 +245,7 @@ async def index_resource(kind: str, resource_id: str, *, force: bool = False) ->
if row is None:
forget_resource(db, kind, resource_id)
return 0
worker = embedder(db)
worker = embedder(db, group_of_row(db, row))
if worker is None:
return 0
body = text_of(row)
@@ -438,7 +484,7 @@ async def rebuild_all(*, force: bool = True) -> None:
_PROGRESS = Progress(running=True)
try:
with session_scope() as db:
if embedder(db) is None:
if not any_configured(db):
_PROGRESS.error = "No embedding model is configured."
return
work: list[tuple[str, str]] = []
+34 -12
View File
@@ -22,7 +22,7 @@ import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Memory, User
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, DEFAULT_GROUP, Memory, User
log = logging.getLogger(__name__)
@@ -39,14 +39,19 @@ MAX_TOTAL_CHARS = 4000
MAX_RECORDS = 200
def all_for(db: DBSession, user: User | None) -> list[Memory]:
def all_for(db: DBSession, user: User | None, group: str | None = None) -> list[Memory]:
"""This person's memories; `group` narrows to one data group, for a model.
`None` is the person's own list in their settings, which shows every group.
"""
if user is None:
return []
return list(
db.scalars(
select(Memory).where(Memory.owner_id == user.id).order_by(Memory.created_at)
)
)
query = select(Memory).where(Memory.owner_id == user.id)
if group is not None:
from lembas.services import data_groups
query = query.where(data_groups.condition(Memory, group))
return list(db.scalars(query.order_by(Memory.created_at)))
def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
@@ -56,7 +61,14 @@ def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
return memory
def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL) -> Memory:
def add(
db: DBSession,
*,
owner: User,
content: str,
author: str = AUTHOR_MODEL,
group: str = DEFAULT_GROUP,
) -> Memory:
"""Record a fact. Raises ValueError when there is no room or nothing to say.
An exact repeat returns the record that already exists rather than making a
@@ -71,15 +83,24 @@ def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL)
if not content:
raise ValueError("A memory cannot be empty.")
content = content[:MAX_MEMORY_CHARS]
group = group or DEFAULT_GROUP
# Both checks are per data group. A repeat of a fact another group already
# holds is a new memory *here* -- returning the other group's row would be
# telling this group's model that it had saved something it cannot see.
from lembas.services import data_groups
in_group = data_groups.condition(Memory, group)
existing = db.scalars(
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content)
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content, in_group)
).first()
if existing is not None:
return existing
count = db.scalar(
select(func.count()).select_from(Memory).where(Memory.owner_id == owner.id)
select(func.count())
.select_from(Memory)
.where(Memory.owner_id == owner.id, in_group)
)
if (count or 0) >= MAX_RECORDS:
# Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the
@@ -95,6 +116,7 @@ def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL)
memory = Memory(
owner_id=owner.id,
data_group_id=group,
content=content,
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL,
)
@@ -117,14 +139,14 @@ def delete(db: DBSession, memory: Memory) -> None:
db.commit()
def block(db: DBSession, user: User | None) -> str:
def block(db: DBSession, user: User | None, group: str | None = None) -> str:
"""The memories as they appear in the prompt, within the budget.
Oldest first, and truncation drops the *newest* -- a fact that has survived
a long time is more likely to be a standing preference than something said
once this morning.
"""
records = all_for(db, user)
records = all_for(db, user, group)
if not records:
return ""
+22 -9
View File
@@ -13,7 +13,7 @@ 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, CHUNK_NOTE, DEFAULT_GROUP, Note, User
from lembas.services import sharing
from lembas.services.library import retrieval
@@ -26,20 +26,25 @@ MAX_BODY_CHARS = 40_000
SNIPPET_CHARS = 800
def visible(db: DBSession, user: User | None):
return select(Note).where(sharing.visible_to(Note, user))
def visible(db: DBSession, user: User | None, group: str | None = None):
"""Notes this user may see; `group` narrows to one data group, for a model."""
return select(Note).where(sharing.visible_to(Note, user, group))
def get(db: DBSession, note_id: str, user: User | None) -> Note | None:
def get(
db: DBSession, note_id: str, user: User | None, group: str | None = None
) -> Note | None:
note = db.get(Note, note_id)
if note is None or not sharing.can_read(db, note, user):
if note is None or not sharing.can_read(db, note, user, group):
return None
return note
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
def recent(
db: DBSession, user: User | None, *, limit: int = 20, group: str | None = None
) -> list[Note]:
return list(
db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit))
db.scalars(visible(db, user, group).order_by(Note.updated_at.desc()).limit(limit))
)
@@ -50,6 +55,7 @@ def search(
*,
limit: int = 10,
vector: list[float] | None = None,
group: str | None = None,
) -> list[Note]:
"""Notes matching `needle` that this user may see, best match first.
@@ -62,16 +68,23 @@ def search(
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(db.scalars(visible(db, user).where(Note.id.in_(list(order)))))
rows = list(db.scalars(visible(db, user, group).where(Note.id.in_(list(order)))))
rows.sort(key=lambda note: order.get(note.id, len(order)))
return rows[:limit]
def create(
db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER
db: DBSession,
*,
owner: User,
title: str,
body: str,
author: str = AUTHOR_USER,
group: str = DEFAULT_GROUP,
) -> Note:
note = Note(
owner_id=owner.id,
data_group_id=group or DEFAULT_GROUP,
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
body=body.strip()[:MAX_BODY_CHARS],
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
+33 -6
View File
@@ -67,7 +67,7 @@ def embeddable(db: DBSession) -> bool:
return indexing.enabled(db)
def worker_for(db: DBSession):
def worker_for(db: DBSession, group: str | None = None):
"""The configured embedder, resolved while a session is open.
Split from the awaiting half deliberately. A caller that must not hold a
@@ -78,7 +78,21 @@ def worker_for(db: DBSession):
"""
from lembas.services.library import indexing
return indexing.embedder(db)
return indexing.embedder(db, group)
class QueryVector(list):
"""A query embedding that knows which model made it.
A plain list everywhere it is used as one. The extra attribute is what lets
`semantic_ids` skip chunks another model embedded: two models of the same
width produce vectors that score against each other perfectly happily and
mean nothing, and since 1.10.0 a data group may have its own embedder, so
two such models on one instance is an ordinary arrangement rather than a
rebuild left half done.
"""
model_id: str = ""
async def embed_with(worker, needle: str) -> list[float] | None:
@@ -100,7 +114,11 @@ async def embed_with(worker, needle: str) -> list[float] | None:
except LLMError as exc:
log.info("could not embed a query: %s", exc)
return None
return vectors[0] if vectors else None
if not vectors:
return None
vector = QueryVector(vectors[0])
vector.model_id = worker.model_id
return vector
async def embed_query(db: DBSession, needle: str) -> list[float] | None:
@@ -126,13 +144,22 @@ def semantic_ids(
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.
**And chunks another model made are skipped when the query says which model
it came from.** Width alone cannot tell two 1024-wide models apart, and with
an embedder per data group two of them on one instance is ordinary. A plain
list -- a caller that built its own vector -- keeps the width check alone.
"""
if not vector:
return []
width = len(vector)
rows = db.execute(
select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(Chunk.resource_type == kind)
).all()
query = select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(
Chunk.resource_type == kind
)
made_by = getattr(vector, "model_id", "")
if made_by:
query = query.where(Chunk.model_id == made_by)
rows = db.execute(query).all()
best: dict[str, float] = {}
for resource_id, blob, dims in rows:
+57 -15
View File
@@ -28,7 +28,15 @@ 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,
CHUNK_SKILL,
DEFAULT_GROUP,
Skill,
SkillRevision,
User,
)
from lembas.services import sharing
from lembas.services.library import retrieval
@@ -55,18 +63,23 @@ def slugify(name: str) -> str:
return cleaned[:60]
def visible(db: DBSession, user: User | None):
return select(Skill).where(sharing.visible_to(Skill, user))
def visible(db: DBSession, user: User | None, group: str | None = None):
"""Skills this user may see; `group` narrows to one data group, for a model."""
return select(Skill).where(sharing.visible_to(Skill, user, group))
def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
def get(
db: DBSession, skill_id: str, user: User | None, group: str | None = None
) -> Skill | None:
skill = db.get(Skill, skill_id)
if skill is None or not sharing.can_read(db, skill, user):
if skill is None or not sharing.can_read(db, skill, user, group):
return None
return skill
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
def by_name(
db: DBSession, name: str, user: User | None, group: str | None = 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
@@ -77,7 +90,7 @@ def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
"""
if user is None:
return None
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
return db.scalar(visible(db, user, group).where(Skill.name == slugify(name)))
def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
@@ -101,7 +114,11 @@ def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
def enabled_for(
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
db: DBSession,
user: User | None,
*,
exclude: Iterable[str] = (),
group: str | None = None,
) -> list[Skill]:
"""Skills that should appear in the index, oldest first for a stable order.
@@ -112,7 +129,7 @@ def enabled_for(
return []
hidden = {slugify(name) for name in exclude}
rows = db.scalars(
visible(db, user)
visible(db, user, group)
.where(Skill.enabled.is_(True))
.order_by(Skill.name)
.limit(MAX_INDEX_SKILLS + len(hidden))
@@ -120,14 +137,20 @@ def enabled_for(
return [skill for skill in rows if skill.name not in hidden][:MAX_INDEX_SKILLS]
def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> int:
def count_enabled(
db: DBSession,
user: User | None,
*,
exclude: Iterable[str] = (),
group: str | None = None,
) -> int:
"""How many skills are available here at all.
Zero is what withdraws `skill_get` and `skill_edit`: reading and improving
are meaningless with nothing to read, and a model told to "read one with
skill_get" above a list that is not there spends a round finding out.
"""
return len(enabled_for(db, user, exclude=exclude))
return len(enabled_for(db, user, exclude=exclude, group=group))
def search(
@@ -137,6 +160,7 @@ def search(
*,
limit: int = 10,
vector: list[float] | None = None,
group: str | None = None,
) -> list[Skill]:
"""Skills matching `needle` that this user may see, best match first.
@@ -149,7 +173,7 @@ def search(
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(db.scalars(visible(db, user).where(Skill.id.in_(list(order)))))
rows = list(db.scalars(visible(db, user, group).where(Skill.id.in_(list(order)))))
rows.sort(key=lambda skill: order.get(skill.id, len(order)))
return rows[:limit]
@@ -175,6 +199,7 @@ def create(
description: str,
body: str,
author: str = AUTHOR_USER,
group: str = DEFAULT_GROUP,
) -> Skill:
slug = slugify(name)
if not SKILL_NAME_PATTERN.match(slug):
@@ -182,7 +207,17 @@ 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:
taken = owned_by_name(db, slug, owner)
if taken is not None:
# A name is unique per person across every data group -- the table's
# constraint is `(owner_id, name)` and cannot be changed. "Edit it
# instead" would send a model in another group to a skill it cannot
# see, so that case gets its own sentence.
if (taken.data_group_id or DEFAULT_GROUP) != (group or DEFAULT_GROUP):
raise SkillError(
f"The name {slug!r} is already used by a skill in another data "
f"group. Choose a different name."
)
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
if not description.strip():
raise SkillError(
@@ -192,6 +227,7 @@ def create(
skill = Skill(
owner_id=owner.id,
data_group_id=group or DEFAULT_GROUP,
name=slug,
description=description.strip()[:MAX_DESCRIPTION_CHARS],
body=body.strip()[:MAX_BODY_CHARS],
@@ -257,9 +293,15 @@ def delete(db: DBSession, skill: Skill) -> None:
db.commit()
def index_block(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> str:
def index_block(
db: DBSession,
user: User | None,
*,
exclude: Iterable[str] = (),
group: str | None = None,
) -> str:
"""The one-line-per-skill listing that goes into the prompt."""
skills = enabled_for(db, user, exclude=exclude)
skills = enabled_for(db, user, exclude=exclude, group=group)
if not skills:
return ""
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
+114
View File
@@ -0,0 +1,114 @@
"""Which models are loaded right now, where the endpoint is able to say.
llama-swap holds one model at a time and reports which, inside the ordinary
`GET /v1/models` answer: every entry carries `"status": {"value": "loaded"}`
or `"unloaded"`. Choosing a model that is not loaded costs a load (seconds for
a small one, most of a minute for the 26B), so the model menu shows a dot on
the one that is ready.
**Only what an endpoint states, and nothing inferred.** The OpenAI spec has
no such field. A hosted API such as DeepSeek leaves it out because nothing is
ever unloaded there, so its models get no state and no dot, rather than a
guess dressed up as a reading. The same shape covers the next runner that
reports it: `status` as an object with `value`, or as a bare string.
**Cheap by construction**, because the menu asks every time it opens:
- one `/v1/models` per *connection*, not per model, all at once;
- a short timeout, because a slow endpoint must never hold up a menu;
- five seconds of cache per connection, so opening the menu repeatedly costs
one request;
- and ten minutes for a connection that said nothing about state, so a hosted
API is not asked for its model list on every click only to answer nothing
again.
Process-level, like the branding cache. With several workers each keeps its
own, which costs at most one extra request each and cannot be wrong for longer
than the TTL.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from lembas.services.llm.openai_client import Endpoint, list_models
log = logging.getLogger(__name__)
TIMEOUT = 3.0
TTL = 5.0
TTL_SILENT = 600.0
LOADED = "loaded"
LOADING = "loading"
UNLOADED = "unloaded"
_LOADED_WORDS = frozenset({"loaded", "ready", "running"})
_LOADING_WORDS = frozenset({"loading", "starting"})
# connection id -> (monotonic time read, TTL, {model_id: state})
_CACHE: dict[str, tuple[float, float, dict[str, str]]] = {}
def state_of(entry: dict[str, Any]) -> str:
"""One `/v1/models` entry's state, or "" when it states none."""
status = entry.get("status")
value = status.get("value") if isinstance(status, dict) else status
if not isinstance(value, str) or not value.strip():
return ""
word = value.strip().lower()
if word in _LOADED_WORDS:
return LOADED
if word in _LOADING_WORDS:
return LOADING
return UNLOADED
async def _read(connection) -> dict[str, str]:
now = time.monotonic()
cached = _CACHE.get(connection.id)
if cached and now - cached[0] < cached[1]:
return cached[2]
try:
entries = await asyncio.wait_for(
list_models(Endpoint.from_connection(connection)), TIMEOUT
)
except Exception: # noqa: BLE001 - an unreachable endpoint has no state, not an error page
log.debug("model state unavailable for %s", connection.name, exc_info=True)
# Not cached: the next open asks again, which is right for an endpoint
# that is merely starting up.
return {}
states = {entry["id"]: state for entry in entries if (state := state_of(entry))}
_CACHE[connection.id] = (now, TTL if states else TTL_SILENT, states)
return states
async def states_for(models) -> dict[str, str]:
"""`{model_id: state}` for the models whose endpoint reports one.
Models without a stated state are absent, not `""`, so the page can treat
"no key" as "draw nothing".
"""
connections = {}
for model in models:
connection = getattr(model, "connection", None)
if connection is not None and connection.enabled:
connections[connection.id] = connection
if not connections:
return {}
results = await asyncio.gather(*(_read(c) for c in connections.values()))
by_connection = dict(zip(connections, results, strict=True))
out: dict[str, str] = {}
for model in models:
state = by_connection.get(model.connection_id, {}).get(model.model_id)
if state:
out[model.model_id] = state
return out
def forget() -> None:
"""Drop the cache. For tests."""
_CACHE.clear()
+37 -1
View File
@@ -31,6 +31,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
AUTHOR_MODEL,
AUTHOR_USER,
DEFAULT_GROUP,
Impression,
Persona,
PersonaRevision,
@@ -54,8 +55,39 @@ MAX_VIEW_CHARS = 800
# a model editing itself every turn cannot grow the table without limit.
MAX_REVISIONS = 20
# Between a model id and a data group in a person's key. Two characters, because
# one `@` is a character a model id could plausibly contain and this must never
# split one.
KEY_SEPARATOR = "@@"
def key_for(model_id: str, group: str | None) -> str:
"""The key a person's personality and impression are stored under.
**Namespaced by data group, and the reason is a constraint.** Both tables
are `UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint, and
this project's schema changes are additive only -- so a `data_group_id`
column could not let one person hold a personality for the same model id in
two groups, which is exactly what one model id served by two providers in
different groups needs.
The default group keeps the bare model id, which is what every row written
before groups existed already holds, so an upgrade moves nothing. The
administrator's default (`owner_id NULL`) is always bare: it is their text,
not a person's data, and every group falls back to it.
"""
if not model_id or not group or group == DEFAULT_GROUP:
return model_id
return f"{model_id}{KEY_SEPARATOR}{group}"
def split_key(model_key: str) -> tuple[str, str]:
"""(model id, data group) out of a stored key."""
model_id, separator, group = (model_key or "").rpartition(KEY_SEPARATOR)
if not separator:
return model_key or "", DEFAULT_GROUP
return model_id, group or DEFAULT_GROUP
def get(db: DBSession, model_key: str, owner: User | None) -> Persona | None:
"""One personality row, exactly as asked for and with no fallback.
@@ -86,7 +118,8 @@ def effective(db: DBSession, model_key: str, owner: User | None) -> Persona | No
own = get(db, model_key, owner)
if own is not None:
return own
return get(db, model_key, None) if owner is not None else None
# The default is keyed on the bare model id whatever group asked.
return get(db, split_key(model_key)[0], None) if owner is not None else None
def personas_of(db: DBSession, owner: User | None) -> list[Persona]:
@@ -302,6 +335,7 @@ def view_block(db: DBSession, model_key: str, owner: User | None) -> str:
__all__ = [
"KEY_SEPARATOR",
"MAX_PERSONA_CHARS",
"MAX_REVISIONS",
"MAX_VIEW_CHARS",
@@ -312,8 +346,10 @@ __all__ = [
"get",
"impression",
"impressions_for",
"key_for",
"personas_for",
"personas_of",
"split_key",
"view_block",
"revert",
"write",
+68 -8
View File
@@ -163,6 +163,14 @@ VARIABLES: tuple[Variable, ...] = (
"page, bounded, and empty unless this model may ask one of them a "
"question — a list of peers it cannot reach is context spent on nothing.",
),
Variable(
"helper_models",
"The models a helper may run on",
"One line per model subagent_run may send a helper to, the answering model "
"first when it may be its own helper: its name, the id to pass as model, "
"and what it is for. Empty when the only choice is the model itself, so the "
"section vanishes and the tool reads as it always did.",
),
Variable(
"persona",
"Its personality with this person",
@@ -1366,6 +1374,28 @@ BUILTIN: tuple[Fragment, ...] = (
"because a helper made it."
),
),
Fragment(
key="tool.subagent_models",
label="Which model a helper runs on",
group=GROUP_TOOLS,
order=252,
families=("subagent",),
variables=("helper_models",),
requires=("helper_models",),
hint="Appears only when a helper may run on a model other than the one "
"answering -- the chat's own helpers added by hand, or ones designated for "
"this model and offered to it. The list is built after the capacity check "
"and the model rules, so every line is one the call will accept.",
default=(
"### Where a helper can run\n"
"\n"
"subagent_run takes a model. Leave it out for the first one below; name "
"another, by the id in brackets, when its strengths suit the piece of work "
"better. Only these are accepted:\n"
"\n"
"{{helper_models}}"
),
),
Fragment(
key="tool.subagent_agent",
label="Helpers on a machine",
@@ -2023,17 +2053,28 @@ BUILTIN: tuple[Fragment, ...] = (
group=GROUP_TASKS,
order=451,
hint="Added as the last turn when a member speaks on the forward pass. "
"The failure to word against is a member that repeats what has already "
"been said in different words, which is what makes a crowd feel like an "
"echo rather than a second opinion.",
"Two failures to word against. One is a member that repeats what has "
"already been said in different words, which makes a crowd an echo "
"rather than a second opinion. The other only shows up on a request that "
"asks for something to be *made* -- write this, pick one, draft that -- "
"where a member reads the original instruction as addressed to it too "
"and produces a rival answer beside its critique. That is not a second "
"opinion either; it is two first opinions, and it is what sends a round "
"off the question.",
default=(
"You are one of several models answering this. The answers above are "
"quoted with the name of whoever wrote them; yours comes next.\n"
"\n"
"Respond to what is above you. Do not answer the person's original "
"request again yourself — that has been done, and your turn is about "
"what was done with it.\n"
"\n"
"Add what is missing, correct what is wrong, and say what you would "
"have done differently. Do not restate what has already been said to "
"show that you agree with it — if you have nothing to add, say so in "
"one line and stop. Be brief: somebody is reading all of these."
"have done differently and why. Where you would have made a different "
"choice, say what it would buy — naming an alternative is not the same "
"as giving a reason to prefer it. Do not restate what has already been "
"said to show that you agree with it — if you have nothing to add, say "
"so in one line and stop. Be brief: somebody is reading all of these."
),
),
Fragment(
@@ -2066,11 +2107,23 @@ BUILTIN: tuple[Fragment, ...] = (
"round. Its own fragment rather than a sentence inside the one below, "
"because inviting a choice a model cannot express is worse than not "
"offering it: on a model without the tools capability there is no "
"crowd_again to call, and that is the case the next fragment covers.",
"crowd_again to call, and that is the case the next fragment covers.\n"
"\n"
"The failure to word against is capitulation: the model that opened the "
"round abandoning its own answer because somebody spoke after it. A "
"closing turn told only to synthesise will follow the last speaker, "
"which is how a crowd ends up less accurate than the model that started "
"it.",
default=(
"You opened this and you are closing it. The others have answered and "
"have had the chance to disagree.\n"
"\n"
"Your own answer is not automatically the worse one for having been "
"written first. Change your position where somebody gave you a reason, "
"and say what the reason was; agreement with no argument behind it is "
"not a reason, and neither is a member having moved on to something "
"else.\n"
"\n"
"Write the answer the person actually asked for. Take what the others "
"got right, say where you disagree with them and why, and name "
"anything still unresolved rather than papering over it. Attribute "
@@ -2091,11 +2144,18 @@ BUILTIN: tuple[Fragment, ...] = (
"is reached, or this model has no tools and so cannot ask. It says the "
"answer has to be final rather than inviting a choice that would be "
"ignored, which is the difference between a feature and a feature that "
"looks like one.",
"looks like one. It carries the same guard against capitulation as the "
"fragment above, and for the same reason.",
default=(
"You opened this and you are closing it, and this is the last turn: "
"there will be no further round.\n"
"\n"
"Your own answer is not automatically the worse one for having been "
"written first. Change your position where somebody gave you a reason, "
"and say what the reason was; agreement with no argument behind it is "
"not a reason, and neither is a member having moved on to something "
"else.\n"
"\n"
"Write the answer the person actually asked for. Take what the others "
"got right, say where you disagree with them and why, and attribute "
"what you took from whom. Where the disagreement is unresolved, say so "
+19 -8
View File
@@ -19,7 +19,7 @@ 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.db.models import CHUNK_REPORT, DEFAULT_GROUP, SOURCE_MANUAL, SOURCES, Report, User
from lembas.services import sharing
from lembas.services.library import retrieval
@@ -33,7 +33,7 @@ MAX_BODY_CHARS = 60_000
SNIPPET_CHARS = 400
def visible(user: User | None):
def visible(user: User | None, group: str | None = None):
"""Every report this person owns or has been shared.
Takes no session because it builds a query rather than running one, and
@@ -43,13 +43,17 @@ def visible(user: User | None):
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.
`group` narrows to one data group, and is what a model's tools pass.
"""
return select(Report).where(sharing.visible_to(Report, user))
return select(Report).where(sharing.visible_to(Report, user, group))
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
def get(
db: DBSession, report_id: str, user: User | None, group: str | None = None
) -> Report | None:
report = db.get(Report, report_id)
if report is None or not sharing.can_read(db, report, user):
if report is None or not sharing.can_read(db, report, user, group):
return None
return report
@@ -67,8 +71,12 @@ def owned(db: DBSession, report_id: str, user: User | None) -> Report | 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 recent(
db: DBSession, user: User | None, *, limit: int = 20, group: str | None = None
) -> list[Report]:
return list(
db.scalars(visible(user, group).order_by(Report.created_at.desc()).limit(limit))
)
def search(
@@ -78,6 +86,7 @@ def search(
*,
limit: int = 20,
vector: list[float] | None = None,
group: str | None = None,
) -> list[Report]:
"""Reports matching `needle`, best match first.
@@ -94,7 +103,7 @@ def search(
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 = list(db.scalars(visible(user, group).where(Report.id.in_(list(order)))))
rows.sort(key=lambda report: order.get(report.id, len(order)))
return rows[:limit]
@@ -165,6 +174,7 @@ def create(
model_id: str = "",
error: str = "",
unread: bool = True,
group: str = DEFAULT_GROUP,
) -> Report:
"""File a report.
@@ -178,6 +188,7 @@ def create(
"""
report = Report(
owner_id=owner.id,
data_group_id=group or DEFAULT_GROUP,
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],
+14 -2
View File
@@ -214,17 +214,29 @@ async def compile_request(
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
def endpoint_for(db, user: User) -> tuple[Endpoint, str] | None:
def endpoint_for(db, user: User, model_id: str = "") -> 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 the schedule's own data group.** The request being compiled is the
person's words about their own work, and it goes to whichever model does the
compiling -- so that model is chosen among the ones that will run the
schedule, never merely the first one pinned. `model_id` is the model the
form has chosen; without one, the person's default model decides the group.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups
models = chat_service.available_models(db, user)
if model_id:
group = data_groups.for_pair(db, user, model_id)
else:
chosen = chat_service.default_model(db, user)
group = data_groups.for_pair(db, user, *chosen) if chosen else data_groups.DEFAULT_GROUP
models = chat_service.available_models(db, user, group)
if not models:
return None
chosen = next((m for m in models if m.pinned), models[0])
+14
View File
@@ -30,6 +30,7 @@ import logging
from datetime import UTC, datetime
from lembas.db.models import (
DEFAULT_GROUP,
ROLE_ASSISTANT,
TARGET_CHAT,
TARGET_MESSAGES,
@@ -187,6 +188,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
source_id=chat_id,
schedule_id=schedule.id,
error="The run did not produce a reply.",
group=schedule.data_group_id or DEFAULT_GROUP,
)
return
reports_service.create(
@@ -198,6 +200,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
source_id=chat_id,
schedule_id=schedule.id,
model_id=message.model_id or "",
group=schedule.data_group_id or DEFAULT_GROUP,
)
return
@@ -213,7 +216,18 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
# 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
from lembas.services import schedules as schedules_service
# Checked when the schedule was saved, and again here: the person may
# have moved a connection or remapped a group since, and a turn in
# Messages is read by Messages' model on every later reply.
refused = schedules_service.messages_refusal(
db, owner, schedule.data_group_id or ""
)
if refused:
schedule.last_error = refused
db.commit()
return
conversation = messages_service.for_user(db, owner)
chat_service.create_message(
db,
+60
View File
@@ -14,10 +14,12 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
KIND_MESSAGES,
KIND_TASK,
ORIGIN_USER,
ORIGINS,
TARGET_CHAT,
TARGET_MESSAGES,
TARGETS,
Chat,
Schedule,
@@ -55,6 +57,51 @@ def for_chat(db: DBSession, chat: Chat) -> Schedule | None:
return db.scalars(select(Schedule).where(Schedule.chat_id == chat.id)).first()
def group_for(db: DBSession, owner: User, model_id: str) -> str:
"""The data group a schedule runs in: its model's, or the person's default's.
Stamped on the schedule and on its task chat when it is made. A run reads
that group's memories and notes, and what it produces -- a report, a turn
in a chat -- lands in it.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups
if model_id:
return data_groups.for_pair(db, owner, model_id)
chosen = chat_service.default_model(db, owner)
return data_groups.for_pair(db, owner, *chosen) if chosen else data_groups.DEFAULT_GROUP
def messages_refusal(db: DBSession, owner: User, group: str) -> str:
"""Why a schedule in `group` may not post into Messages, or "".
Messages is one long conversation, pinned to one data group like any chat.
A run from another group posting its answer there would put that group's
output in front of this group's model on the next turn -- data crossing
between providers through the one channel nobody would think to check.
"""
from lembas.services import data_groups
conversation = db.scalars(
select(Chat)
.where(Chat.user_id == owner.id, Chat.kind == KIND_MESSAGES)
.order_by(Chat.created_at)
).first()
if conversation is not None:
home = data_groups.for_chat(db, conversation)
else:
home = group_for(db, owner, "")
if (group or data_groups.DEFAULT_GROUP) == home:
return ""
return (
f"This schedule's model is in the data group "
f"{data_groups.name_of(db, group)!r} and Messages is in "
f"{data_groups.name_of(db, home)!r}, so it cannot post there. File it as a "
f"report, or keep it in its own chat."
)
def count_for(db: DBSession, user: User) -> int:
return int(
db.scalar(
@@ -103,6 +150,13 @@ def create(
# 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.")
group = group_for(db, owner, model_id)
target = target if target in TARGETS else TARGET_CHAT
if target == TARGET_MESSAGES:
refused = messages_refusal(db, owner, group)
if refused:
raise ScheduleError(refused)
limit = int(settings_store.schedules(db).get("max_per_user") or 20)
if count_for(db, owner) >= limit:
raise ScheduleError(
@@ -115,6 +169,7 @@ def create(
kind=KIND_TASK,
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
model_id=model_id or "",
data_group_id=group,
# 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
@@ -134,6 +189,7 @@ def create(
target=target if target in TARGETS else TARGET_CHAT,
chat_id=chat.id,
model_id=model_id or "",
data_group_id=group,
origin=origin if origin in ORIGINS else ORIGIN_USER,
enabled=True,
next_fire_at=rule_service.next_after(
@@ -162,6 +218,10 @@ def update(
if instruction is not None:
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
if target is not None and target in TARGETS:
if target == TARGET_MESSAGES and schedule.target != TARGET_MESSAGES:
refused = messages_refusal(db, owner, schedule.data_group_id or "")
if refused:
raise ScheduleError(refused)
schedule.target = target
if rule is not None:
clean = rule_service.validate(rule)
+19
View File
@@ -33,6 +33,7 @@ IMAGES = "images"
SCHEDULES = "schedules"
SUBAGENTS = "subagents"
CROWD = "crowd"
RULES = "rules"
BRANDING = "branding"
EXTRACTION = "extraction"
@@ -349,6 +350,16 @@ def _schedules_defaults() -> dict[str, Any]:
}
def _rules_defaults() -> dict[str, Any]:
"""Who may talk to whom, instance-wide. See services/talk.py.
`open` is any model to any model, with deny rules; `closed` is none to none,
with allow rules. Open by default, so an instance that never looks behaves
exactly as it did before rules existed.
"""
return {"mode": "open"}
def _crowd_defaults() -> dict[str, Any]:
"""Several models answering one turn, in order, then again in reverse.
@@ -431,6 +442,7 @@ _DEFAULTS: dict[str, Any] = {
SCHEDULES: _schedules_defaults,
SUBAGENTS: _subagents_defaults,
CROWD: _crowd_defaults,
RULES: _rules_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
@@ -726,6 +738,13 @@ def crowd(db: DBSession) -> dict[str, Any]:
return values
def rules(db: DBSession) -> dict[str, Any]:
"""The talk-rules group, with the mode clamped to the two that exist."""
values = get_group(db, RULES)
values["mode"] = "closed" if values.get("mode") == "closed" else "open"
return values
def images_ready(db: DBSession) -> bool:
"""Whether image generation can actually happen.
+28 -3
View File
@@ -74,12 +74,19 @@ def principal_ids(user: User | None) -> tuple[list[str], list[str]]:
return [user.id], [group.id for group in user.groups]
def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
def visible_to(
model: Any, user: User | None, group: str | None = None
) -> ColumnElement[bool]:
"""A WHERE clause selecting the rows of `model` this user may see.
Returned as a condition rather than a query so callers can add their own
filtering, ordering and pagination without this module knowing about any of
it.
`group` narrows to one data group, and is what every path that hands rows to
a *model* passes -- a model reads only its own group's data. `None` is the
person's own view of their library, which shows every group they have: the
isolation is between providers, not between a person and their records.
"""
if user is None:
# Signed out sees nothing. Not an empty library -- no library.
@@ -93,7 +100,12 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
(Share.principal_type == PRINCIPAL_GROUP) & Share.principal_id.in_(groups or [""]),
),
)
return or_(model.owner_id == user.id, model.id.in_(shared))
seen = or_(model.owner_id == user.id, model.id.in_(shared))
if group is None:
return seen
from lembas.services import data_groups
return and_(seen, data_groups.condition(model, group))
def only_shared(model: Any, user: User | None) -> ColumnElement[bool]:
@@ -120,9 +132,22 @@ def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
return model.owner_id == user.id
def can_read(db: DBSession, resource: Any, user: User | None) -> bool:
def can_read(
db: DBSession, resource: Any, user: User | None, group: str | None = None
) -> bool:
"""Whether this user may read one row -- and, given `group`, whether it is in it.
The group half is what makes fetching a record *by id* obey the same
boundary as searching for it. Without it a model that learned an id from
another group's transcript could read the record straight past the filter.
"""
if user is None or resource is None:
return False
if group is not None:
from lembas.services import data_groups
if data_groups.group_of(resource) != group:
return False
if resource.owner_id == user.id:
return True
users, groups = principal_ids(user)
+113 -46
View File
@@ -77,7 +77,7 @@ from typing import TYPE_CHECKING, Any
from lembas.db.models import KIND_AGENT, KIND_CHAT, Chat, Model, User
from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import settings_store
from lembas.services import data_groups, settings_store
from lembas.services.agent import policy as agent_policy
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -196,6 +196,7 @@ def _create_child(
title: str,
write: bool,
friend: Model | None = None,
helper: Model | None = None,
) -> Chat:
"""The hidden chat one helper or one friend runs in.
@@ -224,6 +225,11 @@ def _create_child(
from lembas.services import chat as chat_service
peer = friend is not None
owner = db.get(User, parent.user_id) if parent.user_id else None
# A helper on another model runs on that model -- as a pair, for the reason
# the friend is -- and reads that model's data group, exactly as a friend
# does. It is still a helper: the parent's kind, machine and scope.
other = friend or helper
child = Chat(
user_id=parent.user_id,
# An ordinary chat for a friend even when the asking one is an agent
@@ -231,8 +237,16 @@ def _create_child(
# and a peer being asked a question is not working on one.
kind=KIND_CHAT if peer else parent.kind,
title=title[:200] or ("Question" if peer else "Helper"),
model_id=friend.model_id if peer else parent.model_id,
connection_id=friend.connection_id if peer else parent.connection_id,
model_id=other.model_id if other is not None else parent.model_id,
connection_id=other.connection_id if other is not None else parent.connection_id,
# The helper is in its parent's group, doing its parent's work. A friend
# is in its own model's, so it reads its own group's data and never the
# asker's.
data_group_id=(
data_groups.for_pair(db, owner, other.model_id, other.connection_id)
if other is not None
else data_groups.for_chat(db, parent)
),
# Never in a listing, and swept a day later even if it is kept.
temporary=True,
parent_chat_id=parent.id,
@@ -245,8 +259,11 @@ def _create_child(
child.agent_mode = MODE_WRITING if write else MODE_READING
if peer:
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
effort = str((friend.params_json or {}).get("reasoning_effort") or "")
if effort not in chat_service.efforts_for(friend):
if other is not None:
# The other model's own default, never the parent's: the vocabularies
# differ, and `high` handed to a Bonsai raises inside its chat template.
effort = str((other.params_json or {}).get("reasoning_effort") or "")
if effort not in chat_service.efforts_for(other):
effort = ""
else:
effort = chat_service.resolved_effort(parent)
@@ -446,6 +463,7 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
title = str(args.get("title") or "").strip() or task[:60]
briefing = str(args.get("context") or "")
want_write = bool(args.get("write"))
wanted_model = str(args.get("model") or "")
if not task:
return _error(
@@ -484,6 +502,15 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
if owner is None: # pragma: no cover - a chat outliving its owner
return _error("That account no longer exists.", task=task)
# Which model the helper runs on -- decided by logic, never taken on the
# model's word: the name is matched against the candidates the tool was
# built from, which already passed the capacity check and the talk rules.
from lembas.services import helpers as helpers_service
helper, refusal = helpers_service.choose(db, parent, owner, wanted_model)
if helper is None:
return _error(refusal, task=task)
# After the refusals above and before anything is created. The order is
# the design: a call that could never have worked should be told *why*
# rather than told it has run out of helpers, and the counter should
@@ -499,7 +526,13 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
if refusal:
return _error(refusal, task=task)
child = _create_child(db, parent, title=title, write=write)
own = (
helper.model_id == parent.model_id
and (not parent.connection_id or helper.connection_id == parent.connection_id)
)
child = _create_child(
db, parent, title=title, write=write, helper=None if own else helper
)
child_id = child.id
_LIVE.add(child_id)
@@ -559,7 +592,9 @@ def _friend_error(message: str, *, question: str = "") -> ToolOutcome:
)
def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model | None, str]:
def _resolve_friend(
db, owner: User, wanted: str, *, asking: str, group: str | None = None
) -> tuple[Model | None, str]:
"""The model a call named, or a refusal that says what it could have named.
The name arrives in a tool call, which is to say it was written by a model
@@ -570,11 +605,15 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
Matched on `model_id` first and on the label second, because the roster
prints both and a model will sometimes type back the pretty one.
`group` is the asking chat's data group. A friend is handed the question
and whatever context the asker wrote into it, so one in another group would
be carrying this group's data to another provider.
"""
from lembas.services import chat as chat_service
question_for = wanted.strip()
candidates = chat_service.roster_models(db, owner, exclude=asking)
candidates = chat_service.roster_models(db, owner, exclude=asking, group=group)
if not candidates:
return None, (
"There is no other model here to ask. Answer from what you know."
@@ -583,7 +622,7 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
return None, (
"Name the model to ask, exactly as it is written in brackets in the "
"list you were given:\n"
+ chat_service.roster_block(db, owner, exclude=asking)
+ chat_service.roster_block(db, owner, exclude=asking, group=group)
)
lowered = question_for.lower()
@@ -603,7 +642,7 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
return None, (
f"There is no model called {question_for!r} that you can reach. "
"These are the ones you can:\n"
+ chat_service.roster_block(db, owner, exclude=asking)
+ chat_service.roster_block(db, owner, exclude=asking, group=group)
)
@@ -670,7 +709,13 @@ async def _run_ask_friend(context: ToolContext, args: dict[str, Any]) -> ToolOut
if owner is None: # pragma: no cover - a chat outliving its owner
return _friend_error("That account no longer exists.", question=question)
friend, refusal = _resolve_friend(db, owner, wanted, asking=parent.model_id)
friend, refusal = _resolve_friend(
db,
owner,
wanted,
asking=parent.model_id,
group=data_groups.for_chat(db, parent),
)
if friend is None:
return _friend_error(refusal, question=question)
@@ -791,10 +836,27 @@ def friend_tool_defs() -> list[ToolDef]:
]
def tool_defs() -> list[ToolDef]:
"""The one tool, built here so `services/tools.py` need not know the wording."""
def tool_defs(models: list[str] | None = None) -> list[ToolDef]:
"""The one tool, built here so `services/tools.py` need not know the wording.
`models` is who a helper may run on, from `helpers.candidates`, the main
model first when it is its own helper. One of them, or none given, is the
tool as it always was; more adds a `model` argument whose enum is exactly
that list, so a small model cannot type a name that was never offered.
"""
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
properties = _subagent_properties()
if models and len(models) > 1:
properties["model"] = {
"type": "string",
"enum": list(models),
"description": (
"Which model the helper runs on. Leave it out for the default "
f"({models[0]}). Choose another when its strengths suit the task "
"better -- the list of helpers you were given says what each is."
),
}
return [
ToolDef(
name="subagent_run",
@@ -815,39 +877,7 @@ def tool_defs() -> list[ToolDef]:
),
parameters={
"type": "object",
"properties": {
"task": {
"type": "string",
"description": (
"What the helper is to do, written out in full and as "
"an instruction. Say what a good answer contains and "
"how long it should be. It is read on its own, with "
"none of this conversation around it."
),
},
"title": {
"type": "string",
"description": "A few words naming this piece of work.",
},
"context": {
"type": "string",
"description": (
"Facts the helper needs that it cannot look up — what "
"the reader asked for, decisions already made, names "
"and paths. Not a summary of the conversation."
),
},
"write": {
"type": "boolean",
"description": (
"True if the helper must change something: write a "
"file, keep a note, file a report. Leave it out for "
"anything that only reads, which is nearly always. A "
"writing helper is refused where you would have been "
"stopped for approval yourself."
),
},
},
"properties": properties,
"required": ["task"],
},
run=_run_subagent,
@@ -873,3 +903,40 @@ __all__ = [
"live_count",
"tool_defs",
]
def _subagent_properties() -> dict[str, Any]:
"""The arguments every `subagent_run` has; `tool_defs` may add `model`."""
return {
"task": {
"type": "string",
"description": (
"What the helper is to do, written out in full and as "
"an instruction. Say what a good answer contains and "
"how long it should be. It is read on its own, with "
"none of this conversation around it."
),
},
"title": {
"type": "string",
"description": "A few words naming this piece of work.",
},
"context": {
"type": "string",
"description": (
"Facts the helper needs that it cannot look up — what "
"the reader asked for, decisions already made, names "
"and paths. Not a summary of the conversation."
),
},
"write": {
"type": "boolean",
"description": (
"True if the helper must change something: write a "
"file, keep a note, file a report. Leave it out for "
"anything that only reads, which is nearly always. A "
"writing helper is refused where you would have been "
"stopped for approval yourself."
),
},
}
+354
View File
@@ -0,0 +1,354 @@
"""Who may talk to whom: the rules behind the crowd, `ask_friend` and the roster.
Always evaluated **from the chat's main model**. If the main model may not talk
to a target, the target is not *offered* -- not listed on its roster, not named
as a friend it may ask, not in the crowd picker's main list. Members of a crowd
are not checked against each other: the rule is about who a conversation's own
model brings in, and a member loses `friend` and `subagent` anyway.
Two answers, because the owner asked for two things:
* **offered** -- what happens on its own: the roster, a friend a model names, the
picker's main list.
* **addable** -- what a person may do by hand in the crowd picker. A rule a
person wrote for themselves is soft for them; an instance rule is hard, unless
they hold `rules.override`.
`decide` is pure -- plain values in, a `Verdict` out -- so every combination is
tested without a database, and the admin page's matrix is drawn by the same
function that enforces the rules, which is what makes the matrix trustworthy.
**A different data group is an implicit deny.** A crowd member or a friend is
sent the conversation, so a model in another group joins only when a rule says
so explicitly: the administrator's, or a person's own when they hold the
override. It then reads its own group's stores, never the chat's.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
ANY_MODEL,
EFFECT_ALLOW,
EFFECT_DENY,
EFFECTS,
Model,
TalkRule,
User,
)
MODE_OPEN = "open"
MODE_CLOSED = "closed"
MODES = (MODE_OPEN, MODE_CLOSED)
# Where a person's own mode is kept in `settings_json`. Empty follows the instance.
SETTING_KEY = "talk_mode"
# Lets a person's own rules and mode win over the instance's, for them alone.
PERMISSION = "rules.override"
@dataclass(frozen=True)
class Rule:
from_model: str
to_model: str
effect: str
# Why something is not offered. A code rather than a sentence, so a screen can
# say it in the reader's language (`api/admin_rules.py:describe`) while a model
# refused a friend is told it in English (`Verdict.reason`).
WHY_INSTANCE_RULE = "instance_rule"
WHY_YOUR_RULE = "your_rule"
WHY_GROUP = "group"
WHY_INSTANCE_CLOSED = "instance_closed"
WHY_YOUR_CLOSED = "your_closed"
@dataclass(frozen=True)
class Verdict:
offered: bool
addable: bool
why: str = ""
rule: Rule | None = None
@property
def reason(self) -> str:
"""The reason in English, for a model -- or "" when it is offered."""
if self.offered or not self.why:
return ""
if self.why in (WHY_INSTANCE_RULE, WHY_YOUR_RULE) and self.rule is not None:
whose = "the instance's" if self.why == WHY_INSTANCE_RULE else "your"
return _named(self.rule, whose)
return {
WHY_GROUP: "it is in another data group",
WHY_INSTANCE_CLOSED: "the instance allows no model to talk to another",
WHY_YOUR_CLOSED: "your setting allows no model to talk to another",
}.get(self.why, "")
def match(rules: list[Rule], main: str, target: str) -> Rule | None:
"""The most specific rule for a pair: exact, then `main -> *`, `* -> target`, `* -> *`."""
for wanted in ((main, target), (main, ANY_MODEL), (ANY_MODEL, target), (ANY_MODEL, ANY_MODEL)):
for rule in rules:
if (rule.from_model, rule.to_model) == wanted:
return rule
return None
def _named(rule: Rule, whose: str) -> str:
frm = "any model" if rule.from_model == ANY_MODEL else rule.from_model
to = "any model" if rule.to_model == ANY_MODEL else rule.to_model
verb = "allows" if rule.effect == EFFECT_ALLOW else "forbids"
return f"{whose} rule {frm} → {to} {verb} it"
def decide(
*,
instance_mode: str,
instance_rule: Rule | None,
user_mode: str = "",
user_rule: Rule | None = None,
override: bool = False,
same_group: bool = True,
) -> Verdict:
"""Whether a main model may talk to a target, offered and by hand.
The instance's verdict is its most specific rule, or failing that its mode
-- with a different data group counting as a deny that only an explicit
allow opens.
Without the override a person can only narrow: their own rule or their
`closed` mode can take something off what is offered, and since those are
theirs, they may still add it by hand. With the override, their explicit
rule wins outright, then their mode, then the instance's verdict; and they
may add anything by hand, because doing so is their explicit decision.
"""
if instance_rule is not None:
instance_ok = instance_rule.effect == EFFECT_ALLOW
instance_why: tuple[str, Rule | None] = (WHY_INSTANCE_RULE, instance_rule)
elif not same_group:
instance_ok, instance_why = False, (WHY_GROUP, None)
else:
instance_ok = instance_mode != MODE_CLOSED
instance_why = (WHY_INSTANCE_CLOSED, None)
if not override:
if user_rule is not None:
user_ok = user_rule.effect == EFFECT_ALLOW
user_why: tuple[str, Rule | None] = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
user_ok, user_why = False, (WHY_YOUR_CLOSED, None)
else:
user_ok, user_why = True, ("", None)
offered = instance_ok and user_ok
why, rule = ("", None) if offered else (instance_why if not instance_ok else user_why)
return Verdict(offered=offered, addable=instance_ok, why=why, rule=rule)
if user_rule is not None:
ok = user_rule.effect == EFFECT_ALLOW
why, rule = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
ok, (why, rule) = False, (WHY_YOUR_CLOSED, None)
elif user_mode == MODE_OPEN:
allowed = instance_rule is not None and instance_rule.effect == EFFECT_ALLOW
ok = same_group or allowed
why, rule = (WHY_GROUP, None)
else:
ok = instance_ok
why, rule = instance_why
if ok:
why, rule = "", None
return Verdict(offered=ok, addable=True, why=why, rule=rule)
# --- Loading what `decide` needs ---------------------------------------------------
def _rules(db: DBSession, owner_id: str | None) -> list[Rule]:
rows = db.scalars(
select(TalkRule).where(
TalkRule.owner_id.is_(None) if owner_id is None else TalkRule.owner_id == owner_id
)
)
return [Rule(r.from_model, r.to_model, r.effect) for r in rows]
def user_mode(user: User | None) -> str:
if user is None:
return ""
value = str((user.settings_json or {}).get(SETTING_KEY) or "")
return value if value in MODES else ""
def may_override(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
class Judge:
"""Everything `decide` needs for one person, loaded once per request.
The model lists ask about every model they show; loading the rules and the
connection groups per question would be a query per row of every picker.
`user=None` is the instance's own view, for the admin page's matrix.
"""
def __init__(self, db: DBSession, user: User | None) -> None:
from lembas.services import data_groups, settings_store
self.instance_mode = settings_store.rules(db)["mode"]
self.instance_rules = _rules(db, None)
self.user_rules = _rules(db, user.id) if user is not None else []
self.user_mode = user_mode(user)
self.override = may_override(db, user)
self.groups = data_groups.connection_groups(db, user)
self.default = data_groups.DEFAULT_GROUP
def group_of(self, model: Model) -> str:
return self.groups.get(model.connection_id, self.default)
def verdict(self, main_model: str, main_group: str, target: Model) -> Verdict:
return decide(
instance_mode=self.instance_mode,
instance_rule=match(self.instance_rules, main_model, target.model_id),
user_mode=self.user_mode,
user_rule=match(self.user_rules, main_model, target.model_id),
override=self.override,
same_group=self.group_of(target) == main_group,
)
def candidates(
db: DBSession, user: User | None, main_model: str, main_group: str
) -> list[tuple[Model, Verdict]]:
"""Every model this person can reach other than the main one, with its verdict."""
from lembas.services import chat as chat_service
judge = Judge(db, user)
return [
(model, judge.verdict(main_model, main_group, model))
for model in chat_service.available_models(db, user)
if model.model_id != main_model
]
def offered(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a main model is offered on its own: the roster, a friend, the picker."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.offered]
def addable(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a person may add to a crowd by hand."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.addable]
# --- Changing rules --------------------------------------------------------------------
def rules_of(db: DBSession, owner: User | None) -> list[TalkRule]:
return list(
db.scalars(
select(TalkRule)
.where(
TalkRule.owner_id.is_(None)
if owner is None
else TalkRule.owner_id == owner.id
)
.order_by(TalkRule.from_model, TalkRule.to_model)
)
)
def set_rule(
db: DBSession,
owner: User | None,
from_model: str,
to_model: str,
effect: str,
*,
both: bool = False,
) -> None:
"""Write one rule, or a pair in both directions. Replaces one for the same pair."""
from_model = (from_model or "").strip()[:300] or ANY_MODEL
to_model = (to_model or "").strip()[:300] or ANY_MODEL
if effect not in EFFECTS:
effect = EFFECT_DENY
pairs = [(from_model, to_model)]
if both and from_model != to_model:
pairs.append((to_model, from_model))
for frm, to in pairs:
existing = db.scalar(
select(TalkRule).where(
(TalkRule.owner_id.is_(None) if owner is None else TalkRule.owner_id == owner.id),
TalkRule.from_model == frm,
TalkRule.to_model == to,
)
)
if existing is not None:
existing.effect = effect
else:
db.add(
TalkRule(
owner_id=owner.id if owner is not None else None,
from_model=frm,
to_model=to,
effect=effect,
)
)
db.commit()
def delete_rule(db: DBSession, owner: User | None, rule_id: str) -> bool:
"""Remove one rule, only from the layer it belongs to."""
rule = db.get(TalkRule, rule_id)
if rule is None or rule.owner_id != (owner.id if owner is not None else None):
return False
db.delete(rule)
db.commit()
return True
def matrix(db: DBSession, user: User | None, models: list[Model]) -> list[dict]:
"""Main x target, drawn by the same `decide` that enforces the rules.
Evaluated as if the main model's chat were in the main model's own group,
which is what a chat started on it is.
"""
judge = Judge(db, user)
rows = []
for main in models:
group = judge.group_of(main)
cells = []
for target in models:
if target.model_id == main.model_id:
cells.append(None)
continue
cells.append(judge.verdict(main.model_id, group, target))
rows.append({"main": main, "cells": cells})
return rows
__all__ = [
"MODES",
"MODE_CLOSED",
"MODE_OPEN",
"PERMISSION",
"Judge",
"Rule",
"Verdict",
"addable",
"candidates",
"decide",
"delete_rule",
"match",
"matrix",
"may_override",
"offered",
"rules_of",
"set_rule",
"user_mode",
]
+90 -28
View File
@@ -32,7 +32,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, KIND_TASK, SOURCE_CHAT, Chat, User
from lembas.db.models import AUTHOR_MODEL, DEFAULT_GROUP, KIND_TASK, SOURCE_CHAT, Chat, User
from lembas.db.session import session_scope
from lembas.services import personas as personas_service
from lembas.services import prompts as prompts_service
@@ -289,6 +289,12 @@ class ToolContext:
image_checkpoint: str = ""
model_id: str = ""
connection_id: str = ""
# Which data group this call reads and writes, resolved from the answering
# model's connection. Every library runner passes it to its store and every
# write stamps it, so a model reaches exactly one group's records -- by
# search *and* by id, since a model that learned an id from somewhere else
# must not be able to fetch the record past the filter.
data_group: str = DEFAULT_GROUP
@dataclass
@@ -465,12 +471,18 @@ async def _run_knowledge_search(context: ToolContext, args: dict[str, Any]) -> T
# session held across one is the trade `_maybe_compact` already refuses.
# None for every "no" -- no model configured, endpoint down -- and the
# search is then exactly the keyword one it has always been.
vector = await _query_vector(query)
vector = await _query_vector(query, context.data_group)
with session_scope() as db:
user = db.get(User, context.owner_id)
found = documents_service.search(
db, user, query, limit=6, base_ids=context.base_ids, vector=vector
db,
user,
query,
limit=6,
base_ids=context.base_ids,
vector=vector,
group=context.data_group,
)
event = {
"name": "knowledge_search",
@@ -502,7 +514,7 @@ async def _run_knowledge_get(context: ToolContext, args: dict[str, Any]) -> Tool
document_id = str(args.get("id") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
document = documents_service.get(db, document_id, user)
document = documents_service.get(db, document_id, user, context.data_group)
if document is None:
return ToolOutcome(
"There is no such document, or it is not available to you.",
@@ -526,7 +538,7 @@ async def _run_knowledge_get(context: ToolContext, args: dict[str, Any]) -> Tool
return ToolOutcome(f"{document.title}\n\n{body}", event)
async def _query_vector(query: str) -> list[float] | None:
async def _query_vector(query: str, group: str | None = None) -> list[float] | None:
"""The query as a vector, for the stores that can use one.
Its own session, opened and closed before the caller opens theirs: this is
@@ -538,20 +550,22 @@ async def _query_vector(query: str) -> list[float] | None:
from lembas.services.library import retrieval
with session_scope() as db:
worker = retrieval.worker_for(db)
worker = retrieval.worker_for(db, group)
return await retrieval.embed_with(worker, query)
# --- Notes -------------------------------------------------------------------
async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
query = str(args.get("query") or "").strip()
vector = await _query_vector(query)
vector = await _query_vector(query, context.data_group)
with session_scope() as db:
user = db.get(User, context.owner_id)
found = (
notes_service.search(db, user, query, limit=8, vector=vector)
notes_service.search(
db, user, query, limit=8, vector=vector, group=context.data_group
)
if query
else notes_service.recent(db, user, limit=8)
else notes_service.recent(db, user, limit=8, group=context.data_group)
)
event = {
"name": "notes_search",
@@ -571,7 +585,7 @@ async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolO
async def _run_notes_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
if note is None:
return ToolOutcome(
"There is no such note, or it is not available to you.",
@@ -599,7 +613,12 @@ async def _run_notes_create(context: ToolContext, args: dict[str, Any]) -> ToolO
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.create(
db, owner=user, title=title, body=body, author=AUTHOR_MODEL
db,
owner=user,
title=title,
body=body,
author=AUTHOR_MODEL,
group=context.data_group,
)
return ToolOutcome(
f"Saved note {note.id} — {note.title!r}.",
@@ -615,7 +634,7 @@ async def _run_notes_create(context: ToolContext, args: dict[str, Any]) -> ToolO
async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
if note is None or note.owner_id != context.owner_id:
return ToolOutcome(
"There is no such note, or it belongs to someone else. A note "
@@ -642,7 +661,7 @@ async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOut
async def _run_notes_delete(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
if note is None or note.owner_id != context.owner_id:
return ToolOutcome(
"There is no such note, or it belongs to someone else.",
@@ -735,7 +754,7 @@ async def _run_persona_write(context: ToolContext, args: dict[str, Any]) -> Tool
return _persona_error("persona_write", "There is nobody here to be this with.")
row = personas_service.write(
db,
model_key=context.model_id,
model_key=personas_service.key_for(context.model_id, context.data_group),
owner=user,
content=content,
author=AUTHOR_MODEL,
@@ -782,7 +801,9 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
if user is None:
return _persona_error("impression_write", "There is nobody here to describe.")
if not content:
row = personas_service.impression(db, context.model_id, user)
row = personas_service.impression(
db, personas_service.key_for(context.model_id, context.data_group), user
)
if row is not None:
personas_service.clear_impression(db, row)
return ToolOutcome(
@@ -791,7 +812,7 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
)
row = personas_service.write_impression(
db,
model_key=context.model_id,
model_key=personas_service.key_for(context.model_id, context.data_group),
owner=user,
content=content,
author=AUTHOR_MODEL,
@@ -819,7 +840,7 @@ async def _run_memory_add(context: ToolContext, args: dict[str, Any]) -> ToolOut
user = db.get(User, context.owner_id)
try:
memory = memories_service.add(
db, owner=user, content=content, author=AUTHOR_MODEL
db, owner=user, content=content, author=AUTHOR_MODEL, group=context.data_group
)
except ValueError as exc:
return ToolOutcome(
@@ -861,7 +882,7 @@ async def _run_memory_forget(context: ToolContext, args: dict[str, Any]) -> Tool
wanted = str(args.get("content") or "").strip().lower()
with session_scope() as db:
user = db.get(User, context.owner_id)
records = memories_service.all_for(db, user)
records = memories_service.all_for(db, user, context.data_group)
if not wanted:
return ToolOutcome(
"Say which memory to remove, quoting its text.",
@@ -918,6 +939,7 @@ async def _run_report_write(context: ToolContext, args: dict[str, Any]) -> ToolO
source=SOURCE_CHAT,
source_id=context.chat_id or "",
model_id=context.model_id or "",
group=context.data_group,
)
return ToolOutcome(
f"Filed report {report.id} — {report.title!r}. "
@@ -937,9 +959,11 @@ async def _run_report_search(context: ToolContext, args: dict[str, Any]) -> Tool
with session_scope() as db:
user = db.get(User, context.owner_id)
found = (
reports_service.search(db, user, query, limit=8, vector=vector)
reports_service.search(
db, user, query, limit=8, vector=vector, group=context.data_group
)
if query
else reports_service.recent(db, user, limit=8)
else reports_service.recent(db, user, limit=8, group=context.data_group)
)
event = {
"name": "report_search",
@@ -962,7 +986,7 @@ async def _run_report_search(context: ToolContext, args: dict[str, Any]) -> Tool
async def _run_report_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
report = reports_service.get(db, str(args.get("id") or ""), user)
report = reports_service.get(db, str(args.get("id") or ""), user, context.data_group)
if report is None:
return ToolOutcome(
"There is no such report.",
@@ -985,7 +1009,7 @@ async def _run_skill_get(context: ToolContext, args: dict[str, Any]) -> ToolOutc
name = str(args.get("name") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
skill = skills_service.by_name(db, name, user)
skill = skills_service.by_name(db, name, user, context.data_group)
# Enforced here and not only in the listing. Without this the per-chat
# narrowing is advisory: a model can name a skill it was never shown --
# from an earlier turn, from a note -- and the runner would fetch it.
@@ -1020,6 +1044,7 @@ async def _run_skill_create(context: ToolContext, args: dict[str, Any]) -> ToolO
description=str(args.get("description") or ""),
body=str(args.get("body") or ""),
author=AUTHOR_MODEL,
group=context.data_group,
)
except skills_service.SkillError as exc:
return ToolOutcome(
@@ -1039,7 +1064,9 @@ async def _run_skill_create(context: ToolContext, args: dict[str, Any]) -> ToolO
async def _run_skill_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
skill = skills_service.by_name(db, str(args.get("name") or ""), user)
skill = skills_service.by_name(
db, str(args.get("name") or ""), user, context.data_group
)
if skill is None or skill.owner_id != context.owner_id:
return ToolOutcome(
"There is no such skill, or it belongs to someone else.",
@@ -1723,13 +1750,31 @@ def _schedule_defs() -> list[ToolDef]:
return schedule_tool.tool_defs()
def _subagent_defs() -> list[ToolDef]:
"""The subagent tool. Imported inside the call for the reason above."""
def _subagent_registry_defs() -> list[ToolDef]:
from lembas.services import subagent as subagent_service
return subagent_service.tool_defs()
def _subagent_defs(db: DBSession, chat: Chat, user: User | None) -> list[ToolDef]:
"""The subagent tool, shaped by which models a helper may run on.
Withdrawn when there are none -- a main model that cannot help itself and
has no other helper set up would be offered a tool every call of which is
refused, and a model finds that out one wasted round at a time. With only
itself it is the tool it always was; with more, it gains a `model`
argument listing exactly those. Imported inside the call for the reason
above.
"""
from lembas.services import helpers as helpers_service
from lembas.services import subagent as subagent_service
found = helpers_service.candidates(db, chat, user)
if not found:
return []
return subagent_service.tool_defs([c.model.model_id for c in found])
def _friend_defs() -> list[ToolDef]:
"""The ask-a-friend tool. Same module, same reason for the late import."""
from lembas.services import subagent as subagent_service
@@ -1797,7 +1842,9 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
# reaches the model. That omission has cost two features their
# instructions already.
*_schedule_defs(),
*_subagent_defs(),
# The registry wants the tool's name and family, not this chat's
# candidates -- so the bare definition, which needs no chat.
*_subagent_registry_defs(),
*_friend_defs(),
*_crowd_defs(),
]
@@ -1859,7 +1906,7 @@ def resolve_tools(
*_agent_defs(db, chat, user),
*(_image_defs(db, image_values) if images_ready else []),
*(_schedule_defs() if schedules_on else []),
*(_subagent_defs() if subagents_on else []),
*(_subagent_defs(db, chat, user) if subagents_on else []),
*(_friend_defs() if subagents_on else []),
# Only on the closing turn, and only with a round left. Not gated on a
# capability or a permission: a tool that exists on exactly one turn of
@@ -1878,7 +1925,16 @@ def resolve_tools(
# something on here would still be reaching for a tool the gates had
# already removed.
off = scoped_off(chat)
empty_library = not skills_service.count_enabled(db, user, exclude=scoped_skills_off(chat))
# Counted in the answering model's own data group: skills in another group
# are not readable here, so they must not keep `skill_get` on offer.
from lembas.services import data_groups
empty_library = not skills_service.count_enabled(
db,
user,
exclude=scoped_skills_off(chat),
group=data_groups.for_speaker(db, user, chat, speaker),
)
# What a crowd speaker may do, which is narrower than what the chat may.
if crowd_turn is not None:
@@ -2095,6 +2151,7 @@ def context_for(
model.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups
from lembas.services.agent import session as agent_session
if chat is not None and speaker is None:
@@ -2110,6 +2167,11 @@ def context_for(
image_checkpoint=(chat.image_checkpoint or "") if chat is not None else "",
model_id=(speaker.model_id or "") if speaker is not None else "",
connection_id=(speaker.connection_id or "") if speaker is not None else "",
data_group=(
data_groups.for_speaker(db, user, chat, speaker)
if chat is not None
else DEFAULT_GROUP
),
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
skills_off=scoped_skills_off(chat),
tools=tools.by_name if tools is not None else None,
+160 -5
View File
@@ -679,6 +679,10 @@ MESSAGES.update(
"Add a connection": "Pridať spojenie",
"Models": "Modely",
"Model": "Model",
"Context window": "Kontextové okno",
"Sees images": "Vidí obrázky",
"Loaded": "Načítaný",
"Loading": "Načítava sa",
"Groups": "Skupiny",
"Members": "Členovia",
"Account": "Účet",
@@ -1075,13 +1079,23 @@ MESSAGES.update(
"potom nemôže držať kolo otvorené celé poobedie."
),
"Fold away a short \"I agree\" on the way back": (
"Zbaliť krátke „súhlasím“ na cestě späť"
"Zbaliť krátke „súhlasím“ na ceste späť"
),
"Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.": (
"Predvolene vypnuté. Po zapnutí panel nastavení každej konverzácie ponúka "
"ostatné modely; konverzácia bez zaškrtnutého modelu sa chová presne ako "
"vždy."
"Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.": (
"Predvolene vypnuté. Po zapnutí ponúka pole na písanie v každej "
"konverzácii ostatné modely; konverzácia bez zaškrtnutého modelu sa chová "
"presne ako vždy."
),
"%(models)s models answer each turn, over up to %(rounds)s rounds.": (
"Na každý ťah odpovedá %(models)s modelov, a to najviac v %(rounds)s kolách."
),
"%(n)s of %(total)s": "%(n)s z %(total)s",
"on the way back": "na ceste späť",
"closing": "uzatvára",
"round %(n)s": "kolo %(n)s",
"no rounds left": "už žiadne kolá",
"out of time": "vypršal čas",
"two endpoints failed": "dva endpointy zlyhali",
"Check for due work every": "Kontrolovať splatnú prácu každých",
"How often it looks": "Ako často sa pozerá",
"Nothing may repeat faster than": "Nič sa nesmie opakovať častejšie než",
@@ -1776,3 +1790,144 @@ MESSAGES.update(
"https://mcp.example.com/mcp": "https://mcp.example.com/mcp",
}
)
# --- Data groups (1.10.0) -------------------------------------------------------
MESSAGES.update(
{
'Data group': 'Dátová oblasť',
'Data groups': 'Dátové oblasti',
'Data': 'Dáta',
"Its models read only this group's memories, notes, skills, knowledge and reports, and a chat started on one of them stays in it. Moving a connection does not move any data: it starts reading the other group.": 'Jeho modely čítajú len pamäť, poznámky, schopnosti, znalosti a správy tejto oblasti a konverzácia začatá na niektorom z nich v nej zostáva. Presunutie spojenia nepresúva žiadne dáta: začne čítať druhú oblasť.',
'Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.': 'Viacero modelov odpovedá v jednom ťahu, v ľubovoľnej konverzácii. Nie je to funkcia agentových konverzácií: funguje v obyčajnej konverzácii a ovládanie je v okne na písanie vedľa prepínačov nástrojov.',
'All data groups': 'Všetky dátové oblasti',
'A personal group of %(who)s.': 'Osobná oblasť používateľa %(who)s.',
'Connections in this group': 'Spojenia v tejto oblasti',
"Their models read this group's data and nothing else. A connection taken out of a group goes back to the default one. Moving a connection moves no data: it starts reading the other group's.": 'Ich modely čítajú dáta tejto oblasti a nič iné. Spojenie vybraté z oblasti sa vráti do predvolenej. Presunutie spojenia nepresúva žiadne dáta: začne čítať dáta druhej oblasti.',
'There are no connections yet.': 'Zatiaľ tu nie sú žiadne spojenia.',
'A connection leaves the default group by being put into another one.': 'Spojenie opustí predvolenú oblasť tak, že ho zaradíte do inej.',
'Services that read this group': 'Služby, ktoré čítajú túto oblasť',
"The embedder is sent the full text of every document, note, skill and report it indexes, and the reviewer is sent every generated picture with its prompt. A group can name its own, so its data does not have to go to the instance's.": 'Model pre vnorenia dostane celý text každého dokumentu, poznámky, schopnosti a správy, ktoré indexuje, a posudzovateľ dostane každý vygenerovaný obrázok aj s jeho zadaním. Oblasť si môže určiť vlastné, aby jej dáta nemuseli ísť k tým, ktoré zvolila inštancia.',
"The instance's choice": 'Voľba inštancie',
"Changing it leaves this group's search keyword-only until the index is rebuilt on the Extraction page.": 'Po zmene vyhľadáva táto oblasť len podľa kľúčových slov, kým sa index neprestavia na stránke Extrakcia.',
'Image reviewer': 'Posudzovateľ obrázkov',
'Only models that can see images are listed.': 'Zobrazujú sa len modely, ktoré vidia obrázky.',
'Delete the data group “%(name)s”?': 'Zmazať dátovú oblasť „%(name)s“?',
"Where this group's data goes": 'Kam idú dáta tejto oblasti',
"Every provider this group's data is sent to, as the instance has it set. A person holding “Manage their own data groups” may have arranged their own differently. Speech to text and text to speech are not grouped: audio is sent and not kept.": 'Každý poskytovateľ, ktorému sa dáta tejto oblasti posielajú, tak ako to má nastavené inštancia. Kto má oprávnenie „Spravovať vlastné dátové oblasti“, môže mať svoje usporiadané inak. Prevod reči na text a textu na reč sa do oblastí nedelí: zvuk sa posiela a neuchováva.',
'its connection is in %(group)s': 'jeho spojenie je v oblasti %(group)s',
'Nothing: no connection is in this group, and no service reads it.': 'Nikam: v tejto oblasti nie je žiadne spojenie a žiadna služba ju nečíta.',
'Holds': 'Obsahuje',
"A data group keeps one provider's models away from the data another provider's models have been given. Every connection is in one group. Its models read only that group's memories, notes, skills, knowledge, reports and personalities, and a chat stays in the group it was started in.": 'Dátová oblasť drží modely jedného poskytovateľa ďalej od dát, ktoré dostali modely iného poskytovateľa. Každé spojenie patrí do jednej oblasti. Jeho modely čítajú len pamäť, poznámky, schopnosti, znalosti, správy a osobnosti tejto oblasti a konverzácia zostáva v oblasti, v ktorej začala.',
'Data group deleted.': 'Dátová oblasť zmazaná.',
'Instance groups': 'Oblasti inštancie',
'%(n)s connections': 'spojenia: %(n)s',
'%(n)s chats': 'konverzácie: %(n)s',
'own embedder': 'vlastné vnorenia',
'own reviewer': 'vlastný posudzovateľ',
'New data group name': 'Názov novej dátovej oblasti',
'Create data group': 'Vytvoriť dátovú oblasť',
'A new group holds nothing and reads nothing until a connection is put into it. Nothing moves on its own.': 'Nová oblasť nič neobsahuje a nič nečíta, kým do nej nezaradíte spojenie. Nič sa nepresúva samo.',
'Personal groups': 'Osobné oblasti',
'Tick a model to have it answer after this one, then be asked whether it disagrees.': 'Označte model a odpovie po tomto, potom sa ho opýta, či s niečím nesúhlasí.',
'Also answering': 'Odpovedajú aj',
'Skipped, because you cannot reach them any more:': 'Vynechané, lebo k nim už nemáte prístup:',
'In another data group than this chat (%(group)s). Start a new chat to use them:': 'V inej dátovej oblasti než táto konverzácia (%(group)s). Ak ich chcete použiť, začnite novú konverzáciu:',
'Only models whose connection is in this group can read it.': 'Čítať to môžu len modely, ktorých spojenie je v tejto oblasti.',
"Moving it hands it to the other group's models, and takes it away from this group's.": 'Presunutím to dostanú modely druhej oblasti a modely tejto oblasti o to prídu.',
'Only models whose connection is in this group can read it. Moving records between groups needs the permission to manage your own data groups.': 'Čítať to môžu len modely, ktorých spojenie je v tejto oblasti. Na presúvanie záznamov medzi oblasťami potrebujete oprávnenie spravovať vlastné dátové oblasti.',
'Every group': 'Všetky oblasti',
'Show': 'Zobraziť',
'Your data groups': 'Vaše dátové oblasti',
"A model reads only the memories, notes, skills, knowledge, reports and personality of the data group its connection is in, and a chat stays in the group it was started in. Two providers in different groups never see each other's.": 'Model číta len pamäť, poznámky, schopnosti, znalosti, správy a osobnosť tej dátovej oblasti, v ktorej je jeho spojenie, a konverzácia zostáva v oblasti, v ktorej začala. Dvaja poskytovatelia v rôznych oblastiach nikdy nevidia dáta toho druhého.',
'yours': 'vaša',
'Create': 'Vytvoriť',
'A group of your own, for keeping one provider away from the rest of your data. Nobody else can see it.': 'Vlastná oblasť, aby ste jedného poskytovateľa udržali ďalej od zvyšku svojich dát. Nikto iný ju nevidí.',
'Which group each connection reads': 'Ktorú oblasť číta ktoré spojenie',
'Your choice applies to you alone. Following the instance keeps up with whatever the administrator sets. Moving a connection moves no data: from then on it reads the other group, and your chats on it that were started in the old group can no longer use it.': 'Vaša voľba platí len pre vás. Keď sa riadite inštanciou, sledujete to, čo nastaví správca. Presunutie spojenia nepresúva žiadne dáta: odvtedy číta druhú oblasť a vaše konverzácie na ňom, ktoré začali v pôvodnej oblasti, ho už nemôžu použiť.',
'Follow the instance (%(group)s)': 'Podľa inštancie (%(group)s)',
'Reads: %(group)s': 'Číta: %(group)s',
'Set by the administrator. Changing it for yourself needs the permission to manage your own data groups.': 'Nastavuje správca. Ak to chcete zmeniť pre seba, potrebujete oprávnenie spravovať vlastné dátové oblasti.',
'chats': 'konverzácie',
'memories': 'záznamy v pamäti',
'notes': 'poznámky',
'skills': 'schopnosti',
'knowledge bases': 'znalostné bázy',
'reports': 'správy',
'connections': 'spojenia',
'Chat models': 'Modely v konverzáciách',
'Embedding': 'Vnorenia',
'Image review': 'Posudzovanie obrázkov',
}
)
# --- Model rules (1.11.0) ------------------------------------------------------
MESSAGES.update(
{
'Model rules': 'Pravidlá modelov',
"Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.": 'Ktorý model môže priviesť ktorý do konverzácie: ako člena skupiny, ako priateľa, ktorého sa pýta, a na zozname ďalších modelov, o ktorých sa dozvie. Pravidlo sa číta od vlastného modelu konverzácie. Model v inej dátovej oblasti sa neponúka, kým to pravidlo nepovolí.',
'The starting point': 'Východisko',
'Any model may talk to any other, except where a rule forbids it': 'Každý model môže hovoriť s každým, okrem prípadov, keď to pravidlo zakazuje',
'No model may talk to another, except where a rule allows it': 'Žiadny model nesmie hovoriť s iným, okrem prípadov, keď to pravidlo povoľuje',
'People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.': 'Ľudia si to pre seba môžu vždy zúžiť. Rozšíriť si to pre seba môžu len s oprávnením prekonať pravidlá modelov.',
'Rules': 'Pravidlá',
'Not offered to this model': 'Tomuto modelu sa neponúkajú',
'You may still add it yourself.': 'Môžete ho aj tak pridať sami.',
'any model': 'ľubovoľný model',
'may talk': 'smie hovoriť',
'may not talk': 'nesmie hovoriť',
'Delete this rule': 'Zmazať toto pravidlo',
'No rules yet.': 'Zatiaľ žiadne pravidlá.',
'This model': 'Tento model',
"The chat's own model.": 'Vlastný model konverzácie.',
'May': 'Smie',
'may not talk to': 'nesmie hovoriť s',
'may talk to': 'smie hovoriť s',
'That model': 'Tamten model',
'A crowd member, a friend it asks, a model on its roster.': 'Člen skupiny, priateľ, ktorého sa pýta, model na jeho zozname.',
'Both directions': 'Oboma smermi',
'Add rule': 'Pridať pravidlo',
'Who may talk to whom': 'Kto smie hovoriť s kým',
"Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.": 'Riadky sú vlastný model konverzácie, stĺpce model, ktorý by priviedol. Kreslí to tie isté pravidlá, ktoré sa uplatňujú, takže čo tu vidíte, to sa aj stane.',
'itself': 'on sám',
'Offered': 'Ponúka sa',
'Who your models may talk to': 'S kým smú hovoriť vaše modely',
"Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.": 'Vaše pravidlá a vaše nastavenie majú pre vás prednosť pred pravidlami inštancie. Do skupiny môžete ručne pridať aj ľubovoľný model.',
"Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.": 'Vaše pravidlá môžu pravidlá inštancie len zúžiť. Model, ktorý zadrží vaše vlastné pravidlo, môžete do skupiny aj tak pridať ručne; model, ktorý zadrží inštancia, nie.',
'Your starting point': 'Vaše východisko',
'Follow the instance': 'Podľa inštancie',
'Any model may talk to any other': 'Každý model môže hovoriť s každým',
'No model may talk to another': 'Žiadny model nesmie hovoriť s iným',
"The instance's rule %(a)s → %(b)s allows it.": 'Pravidlo inštancie %(a)s → %(b)s to povoľuje.',
"The instance's rule %(a)s → %(b)s forbids it.": 'Pravidlo inštancie %(a)s → %(b)s to zakazuje.',
'Your rule %(a)s → %(b)s allows it.': 'Vaše pravidlo %(a)s → %(b)s to povoľuje.',
'Your rule %(a)s → %(b)s forbids it.': 'Vaše pravidlo %(a)s → %(b)s to zakazuje.',
'It is in another data group.': 'Je v inej dátovej oblasti.',
'The instance lets no model talk to another.': 'Inštancia nedovoľuje žiadnemu modelu hovoriť s iným.',
'Your setting lets no model talk to another.': 'Vaše nastavenie nedovoľuje žiadnemu modelu hovoriť s iným.',
}
)
# --- Helpers on another model (1.12.0) -----------------------------------------
MESSAGES.update(
{
'Holds one model at a time': 'Drží naraz jeden model',
'Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.': 'Označte pri llama-swap pred jednou GPU alebo čomkoľvek, čo jeden model uvoľní, aby obslúžilo iný. Model tu potom môže byť vlastným pomocníkom, ale nikdy nepošle pomocníka inému modelu na tomto spojení: jeho načítanie by uvoľnilo model, ktorého odpoveď čaká.',
'Serves one request at a time': 'Obsluhuje naraz jednu požiadavku',
'Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.': 'Označte pri modeli s jedným slotom. Potom nemôže byť vlastným pomocníkom, lebo pomocník by čakal za odpoveďou, ktorá čaká naňho. Iné modely môžu byť jeho pomocníkmi aj tak.',
"Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.": 'Iné modely, ku ktorým môže tento posielať pomocníkov, pre všetkých. Ponúkané si môže vybrať sám; ostatné sa dajú do konverzácie pridať len ručne. Vždy je aj vlastným pomocníkom, pokiaľ neobsluhuje naraz jednu požiadavku. Pravidlá modelov a kapacita spojenia sa kontrolujú pri odoslaní pomocníka.',
'offered to the model': 'ponúkané modelu',
'by hand only': 'len ručne',
'Remove this helper': 'Odstrániť tohto pomocníka',
'Helper model': 'Model pomocníka',
'Offer it to the model': 'Ponúknuť ho modelu',
'Add helper': 'Pridať pomocníka',
'Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.': 'Modely, ku ktorým môže tento posielať pomocníkov. Označením ho pridáte do tejto konverzácie; ponúkaný si model môže vybrať aj tak.',
'Offered to the model.': 'Ponúkaný modelu.',
'Only when added here.': 'Len keď ho pridáte tu.',
'Your helper models': 'Vaše modely pomocníkov',
"Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.": 'Modely, ku ktorým môže každý z vašich modelov posielať pomocníkov, len pre vás, navyše k tým od inštancie. Váš riadok pre tú istú dvojicu nahradí riadok inštancie.',
"Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.": 'Nastavuje správca na stránke každého modelu. Na pridanie vlastných potrebujete oprávnenie vyberať si vlastné modely pomocníkov.',
'May send helpers to': 'Smie posielať pomocníkov k',
"It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.": 'Nemôže bežať popri odpovedi tohto modelu: jeden z nich obsluhuje naraz jednu požiadavku alebo ich spojenie drží naraz jeden model.',
}
)
+43 -4
View File
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
pinned to the scrollport with `background-attachment: local`, which is the old
trick and works everywhere -- the `local` layers scroll with the content and
cover the `scroll` ones exactly when there is nothing more to see.
⚠ "Cover" has to mean all of it. The covers used to be as wide as the
shadows and solid for only 40% of that width, so the other 60% of every
shadow always showed through, with nothing to scroll to. On Moria that is
near-black on near-black and nobody saw it. On Shire it was a grey sliver at
both ends of every tab bar. Each cover is now twice the shadow's width and
solid across the first half, which is the whole shadow. It fades only past
the shadow's end, so once content is scrolled the shadow shows as before.
*/
.tabs__bar {
background-image:
linear-gradient(to right, var(--bg) 40%, transparent),
linear-gradient(to left, var(--bg) 40%, transparent),
linear-gradient(to right, var(--bg) 50%, transparent),
linear-gradient(to left, var(--bg) 50%, transparent),
linear-gradient(to right, var(--scrim), transparent 1.5rem),
linear-gradient(to left, var(--scrim), transparent 1.5rem);
background-position: left center, right center, left center, right center;
background-repeat: no-repeat;
background-size: 1.5rem 100%;
background-size: 3rem 100%, 3rem 100%, 1.5rem 100%, 1.5rem 100%;
background-attachment: local, local, scroll, scroll;
/* A tab is a destination, so a flick should land on one rather than between
two. */
@@ -260,7 +268,9 @@ a.tabs__tab { text-decoration: none; }
*/
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
/* Both halves of the pair -- see `.grid--2` in app.css. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
gap: var(--sp-3);
}
.field-row > .field { margin-bottom: var(--sp-4); }
@@ -449,6 +459,35 @@ a.tabs__tab { text-decoration: none; }
.model-list__item:first-child { padding-top: 0; }
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
/* The models card in /settings. Every row shares the list's column tracks, so
the context sizes and the eyes line up; the description and the capability
tags take the rest of the row underneath, never the space beside the name. */
.model-list--models {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
column-gap: var(--sp-3);
}
.model-list--models .model-list__item {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
align-items: center;
row-gap: var(--sp-1);
}
/* Wraps rather than truncates: there is room below, and a name cut to
"Gemma 4 E…" on a phone is a different model's name. */
.model-list__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-1) var(--sp-2);
min-width: 0;
overflow-wrap: anywhere;
}
.model-list__more { grid-column: 2 / -1; min-width: 0; }
.model-list__tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.model-list__tags:empty { display: none; }
/* --- Permission grids ------------------------------------------------------ */
.checkbox-row {
display: flex;
+156 -3
View File
@@ -384,8 +384,16 @@ input.visually-hidden[type="checkbox"] {
/* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
/* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
reason `.suggestions` in chat.css sets out at length. The pair is not
optional: `min(100%, …)` stops the track demanding more than the box, and
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
flex item carries `min-width: auto`, which is a min-content floor, and a
floor beats `width`. A stylesheet cannot tell whether one of these grids has
been dropped into a flex parent today, so both go on every one of them.
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
/* --- Alerts --------------------------------------------------------------- */
.alert {
@@ -444,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
child will not shrink below its content without it, so a scroller missing it
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
scroll container's automatic minimum size to get away with omitting it, which
is true and is not something the next person should have to know. */
is true and is not something the next person should have to know.
`position: relative` makes each scroller the containing block for what is in
it, and without it a `.visually-hidden` label is not in it at all. That class
is `position: absolute`, so with no positioned ancestor it is placed against
the *page*, at its static position -- six thousand pixels down the Tools
panel on /admin/prompts -- and the document grew to 6771px behind a root
that is `overflow: hidden`. Nobody can scroll that by hand, but
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
and the root can. Switching a tab there lifted the whole shell 56px: the
topbar gone off the top and a strip of bare background under everything. */
.scroll-region,
.sidebar__scroll,
.inspector__body,
@@ -452,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
.thread-scroll,
.admin-scroll,
.main > .tabs > .tabs__body {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1369,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
which nothing else on the screen tells you -- gets the room back. */
.topbar__actions .picker__label { display: none; }
/* And the menu is the bar's, not the picker's. Anchored to the picker it
opens `right: 0` of a button that sits mid-bar with the panel buttons to
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
every name in half. Taking `position` off the picker makes the bar the
containing block: the menu spans the bar under it, whatever sits where --
up to its usual 24rem, held at the bar's right edge by the auto margin. */
.topbar { position: relative; }
.topbar__actions .picker { position: static; }
.topbar__actions .picker__menu {
left: max(var(--sp-2), var(--safe-left));
right: max(var(--sp-2), var(--safe-right));
width: auto;
max-width: 24rem;
margin-left: auto;
}
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
@@ -1722,6 +1758,79 @@ body.is-resizing .canvas__body { pointer-events: none; }
color: var(--leaf);
}
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
/* The model picker: one row per model on the list's own column tracks, so the
context sizes and the eyes form columns whatever a name's length. Scoped to
the modifier because `.picker__list` is also the @-mention menu's list. */
.picker__list--models {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
column-gap: var(--sp-3);
}
.picker__list--models .picker__option {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
align-items: center;
gap: inherit;
}
.picker__list--models .picker__option .picker__avatar { margin-top: 0; }
/* Whether a model is loaded, where its endpoint says so (llama-swap does; a
hosted API does not, and gets nothing). A dot on the avatar's corner, ringed
in the menu's own surface so it reads against any avatar colour. Nothing is
drawn until ui.js has an answer -- an empty `data-model-state` is "unknown",
which is not the same claim as "unloaded". */
.model-slot { position: relative; display: flex; flex: none; }
.model-state {
position: absolute;
right: calc(var(--model-state-size) / -3);
bottom: calc(var(--model-state-size) / -3);
width: var(--model-state-size);
height: var(--model-state-size);
border-radius: var(--radius-full);
box-shadow: 0 0 0 var(--outline-w) var(--surface);
display: none;
}
.model-slot[data-model-state="loaded"] .model-state { display: block; background: var(--model-state-loaded); }
.model-slot[data-model-state="loading"] .model-state {
display: block;
background: var(--model-state-loading);
animation: model-state-pulse var(--dur-slow) var(--ease-in-out) infinite;
}
@keyframes model-state-pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.model-slot[data-model-state="loading"] .model-state { animation: none; }
}
.picker__list--models .picker__option-name { min-width: 0; }
.model-ctx {
font-size: var(--text-xs);
color: var(--ink-faint);
white-space: nowrap;
}
.model-vision { display: flex; color: var(--ink-faint); min-width: 1rem; }
.picker__list--models .picker__tick { display: flex; margin-top: 0; min-width: 1rem; visibility: hidden; }
.picker__list--models .picker__option.is-selected .picker__tick { visibility: visible; }
/* Models in another data group, named under the list inside a chat. Text, not
options: switching this chat to one would hand it the conversation. */
.picker__elsewhere {
border-top: 1px solid var(--border);
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-sm);
color: var(--ink-muted);
}
.picker__elsewhere-head,
.picker__elsewhere-names {
margin: 0;
overflow-wrap: anywhere;
}
.picker__elsewhere-names {
margin-top: var(--sp-1);
color: var(--ink-faint);
}
.picker__empty {
padding: var(--sp-4);
margin: 0;
@@ -1845,3 +1954,47 @@ body.is-resizing .canvas__body { pointer-events: none; }
}
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out); }
/* --- Who may talk to whom --------------------------------------------------
The talk-rules matrix: one row per main model, one column per model it would
bring in. Its own scroller, because a dozen model ids across is wider than a
phone and the page must never scroll sideways. */
.talk-matrix-scroll {
overflow-x: auto;
max-width: 100%;
}
.talk-matrix {
border-collapse: collapse;
font-size: var(--text-xs);
}
.talk-matrix th,
.talk-matrix td {
padding: var(--sp-1) var(--sp-2);
border: var(--border-w) solid var(--border);
text-align: center;
white-space: nowrap;
}
.talk-matrix thead th { color: var(--ink-muted); font-weight: 500; }
.talk-matrix tbody th { text-align: left; color: var(--ink-muted); font-weight: 500; }
.talk-matrix__yes { color: var(--leaf); }
.talk-matrix__no { color: var(--danger); }
.talk-matrix__self { color: var(--ink-faint); }
/* The add-a-rule form: three fields on a shared track so the label, the
control and the hint of each line up whatever the text does -- the tree's
subgrid rule. Stacks below a phone's width. */
.talk-rule-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
grid-template-rows: auto auto auto;
column-gap: var(--sp-3);
min-width: 0;
margin-top: var(--sp-4);
}
.talk-rule-form > .field {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
min-width: 0;
}
.talk-rule-form > .btn-row { grid-column: 1 / -1; }
+30 -2
View File
@@ -266,7 +266,27 @@
*/
.suggestions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
/* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%`
alone did not: it is a grid item of `.thread__intro`, so it carries
`min-width: auto`, which for a grid item means *a min-content floor* -- and
min-width beats width. Its min-content size is two cards side by side, so it
rendered 428px wide inside a 390px phone with `width: 100%` set and ignored.
That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)`
-- the tree's standing rule, and right -- made it *worse* on its own, 428px
to 455px: a percentage is indefinite while the floor is being measured, so
the track fell back to a card's max-content and raised the very number that
was overflowing. The two go together. With the floor removed, `width: 100%`
finally resolves against the 366px column, `min(100%, …)` hands the track
366px to clamp against, and `auto-fit` places one column.
It scrolled `.thread-scroll` rather than the page, which is why a pass
looking for a document that scrolls sideways never saw it: `overflow-y: auto`
makes the other axis scrollable too. Reported on a phone, found by asking
which *element* could scroll and then reading its computed `width` against
its parent's. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--sp-3);
width: 100%;
max-width: 40rem;
@@ -983,9 +1003,17 @@
flex-direction: column;
justify-content: flex-end;
}
/* position: relative anchors the `@` and `/` menu to the box. */
/* position: relative anchors the `@` and `/` menu to the box.
`width: 100%` is the width; `max-width` only caps it. Without it the box was
as wide as its widest content: `.composer` is a flex column, and auto margins
on a flex item switch off the stretch it would otherwise get. So the hint
under it decided. "GPT-OSS has no vision, so images will not be sent" made
the box 768px, and the same screen with a model that sees images made it
538px. */
.composer__inner {
position: relative;
width: 100%;
max-width: var(--thread-max-width);
margin: 0 auto;
}
+8
View File
@@ -50,6 +50,14 @@
--radius-xl: 18px;
--radius-full: 999px;
/* The load-state dot on a model's avatar in the model menu. Its colours are
tokens of their own, defaulting to the theme's success and warning, so
an instance whose success colour is not green can still say "loaded" in
green -- that is what people read a dot beside a name as. */
--model-state-size: 0.625rem;
--model-state-loaded: var(--success);
--model-state-loading: var(--warning);
/*
--- Controls ----------------------------------------------------------
Every button, input and select resolves its height from these. That is the
+70 -21
View File
@@ -279,6 +279,13 @@
return document.getElementById("attachments");
}
/* The composer's model, which on the new-chat screen decides which data
group the library picker may offer. See data_groups.for_composer. */
function modelId() {
var field = document.querySelector('.composer input[name="model_id"], [data-picker-input]');
return field ? field.value : "";
}
function chatId() {
var input = document.getElementById("file-input");
var url = (input && input.dataset.uploadUrl) || "";
@@ -335,7 +342,9 @@
var close = dialog.querySelector("button");
function load(query) {
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || ""), {
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || "") +
"&chat_id=" + encodeURIComponent(chatId()) +
"&model_id=" + encodeURIComponent(modelId()), {
credentials: "same-origin",
})
.then(function (response) { return response.text(); })
@@ -355,6 +364,7 @@
var body = new FormData();
body.append("document_id", option.dataset.attachKnowledge);
body.append("chat_id", chatId());
body.append("model_id", modelId());
postForChip("/api/files/from-knowledge", body);
finish();
});
@@ -1099,27 +1109,59 @@
The worker no longer takes over open pages on its own -- see sw.js -- so
something has to say that one is waiting, and the reader decides. A toast
rather than a reload: an application with a reply streaming into it must
not be navigated out from under somebody. */
not be navigated out from under somebody.
🚨 "A worker is waiting" is not the same as "this page is out of date",
and the toast used to treat them as one. After a release it offered a
reload on every page, including one just fetched with Ctrl+Shift+R, and
reloading could not make it stop. A page is always fetched from the network
and every asset it names carries `?v=<release>`, so a page loaded after
the update IS the update, whichever worker happens to control it. And the
worker that controls it is nearly always the previous one: a reload
creates the new page before the old one goes away, so the old worker
never runs out of pages and the new one never stops waiting.
So the question is asked of the page. The worker's release is in its own
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
base.html. When the two match there is nothing newer to reload into, and
the worker is left to take over once the old tabs are closed. */
var PAGE_RELEASE = window.lembasRelease || "";
function releaseOf(worker) {
try {
return new URL(worker.scriptURL).searchParams.get("v") || "";
} catch (error) {
return "";
}
}
/* Unknown on either side counts as newer: better an extra offer than a
release nobody is told about. */
function isNewerThanThisPage(worker) {
var release = releaseOf(worker);
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
}
function offerReload(worker) {
window.lembas.notify(
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
}
function watchForUpdate(registration) {
function offer(worker) {
if (!worker || !navigator.serviceWorker.controller) return;
worker.addEventListener("statechange", function () {
if (worker.state !== "installed") return;
window.lembas.notify(
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
if (isNewerThanThisPage(worker)) offerReload(worker);
});
}
if (registration.waiting && navigator.serviceWorker.controller) {
window.lembas.notify(
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
registration.waiting.postMessage({ type: "SKIP_WAITING" });
} } }
);
if (registration.waiting && navigator.serviceWorker.controller &&
isNewerThanThisPage(registration.waiting)) {
offerReload(registration.waiting);
}
registration.addEventListener("updatefound", function () {
offer(registration.installing);
@@ -1130,17 +1172,24 @@
the right answer to it -- the page is now being served by a worker whose
cache it did not start from.
Two guards, and the second is the one that is easy to miss. A flag, because
`controllerchange` can fire more than once. And `hadController`, because on
a *first* visit there is no worker at all: the one that installs then calls
`clients.claim()`, which fires this event for the first time -- so without
it, the very first page anybody loads reloads itself in front of them for
no reason they could possibly work out. */
Three guards, and the second is the one that is easy to miss. A flag,
because `controllerchange` can fire more than once. And `hadController`,
because on a *first* visit there is no worker at all: the one that
installs then calls `clients.claim()`, which fires this event for the
first time -- so without it, the very first page anybody loads reloads
itself in front of them for no reason they could possibly work out.
The third is the same question as the toast's. Somebody pressing Reload in
one tab activates the worker for all of them, and a tab that was already
rendered by that release has nothing to gain from a reload -- and may have
a reply streaming into it. */
var reloading = false;
if ("serviceWorker" in navigator) {
var hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", function () {
if (reloading || !hadController) return;
var controller = navigator.serviceWorker.controller;
if (controller && !isNewerThanThisPage(controller)) return;
reloading = true;
window.location.reload();
});
+10 -1
View File
@@ -222,7 +222,16 @@
{
name: "temp",
summary: "Start a temporary chat, gone after a day",
run: function () { window.location = "/chat?temporary=1"; }
run: function () {
/* On the new-chat screen the model, folder and kind already chosen are
in the query string. Add the flag to them rather than starting over,
or the chat is made on the default model. */
var query = new URLSearchParams(
window.location.pathname === "/chat" ? window.location.search : ""
);
query.set("temporary", "1");
window.location = "/chat?" + query.toString();
}
},
{
name: "stop",
+11 -2
View File
@@ -62,7 +62,14 @@
/* A chat under way carries its connection on the composer; a new one is
still choosing it, so the select and the hidden field are the truth. */
profileId: picker ? picker.value : (box && box.dataset.profileId) || "",
projectDir: dir ? dir.value : (box && box.dataset.projectDir) || ""
projectDir: dir ? dir.value : (box && box.dataset.projectDir) || "",
/* The model the composer is writing to. Only the new-chat screen needs
it -- a chat that exists answers "which data group" by itself -- but
there it decides which library items may be offered at all. */
modelId: (function () {
var field = document.querySelector('.composer input[name="model_id"], [data-picker-input]');
return field ? field.value : "";
})()
};
}
@@ -171,7 +178,8 @@
"/api/files/mention-picker?q=" + encodeURIComponent(query) +
"&chat_id=" + encodeURIComponent(where.chatId) +
"&profile_id=" + encodeURIComponent(where.profileId) +
"&project_dir=" + encodeURIComponent(where.projectDir);
"&project_dir=" + encodeURIComponent(where.projectDir) +
"&model_id=" + encodeURIComponent(where.modelId);
fetch(url, { credentials: "same-origin" })
.then(function (response) { return response.text(); })
@@ -266,6 +274,7 @@
var body = new FormData();
body.append("chat_id", where.chatId);
body.append("model_id", where.modelId);
if (option.dataset.mentionFile) {
body.append("profile_id", where.profileId);
body.append("path", option.dataset.mentionFile);
+57 -1
View File
@@ -320,6 +320,11 @@
if (filter) {
filter.value = "";
applyFilter(menu, "");
}
// Not on a touchscreen: focusing a text field there raises the keyboard,
// which covers half the list the finger came to choose from. The filter
// is one tap away for whoever wants it.
if (filter && !window.matchMedia("(hover: none)").matches) {
filter.focus();
} else {
var selected = menu.querySelector(".picker__option.is-selected") ||
@@ -329,6 +334,45 @@
// Keep the chosen model in view when the list is long.
var current = menu.querySelector(".picker__option.is-selected");
if (current) current.scrollIntoView({ block: "nearest" });
refreshStates(menu);
}
/* Which models are loaded, asked for each time the model menu opens --
llama-swap holds one at a time and it changes by the minute, so a value
rendered with the page would be stale by the time anybody looked. Only
models whose endpoint reports a state come back; everything else keeps an
empty `data-model-state`, which draws nothing. While one is loading the
menu asks again every two seconds, and stops when it closes. */
function refreshStates(menu) {
var list = menu.querySelector(".picker__list--models");
if (!list || !window.fetch) return;
clearTimeout(menu._stateTimer);
fetch("/api/models/state", {
credentials: "same-origin",
headers: { Accept: "application/json" }
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
var states = (data && data.states) || {};
var loading = false;
list.querySelectorAll(".picker__option[data-model-id]").forEach(function (option) {
var slot = option.querySelector("[data-model-state]");
if (!slot) return;
var state = states[option.dataset.modelId] || "";
slot.dataset.modelState = state;
if (state === "loading") loading = true;
var label = slot.querySelector("[data-model-state-label]");
if (label) {
label.textContent = state === "loaded" ? list.dataset.labelLoaded
: state === "loading" ? list.dataset.labelLoading : "";
}
});
if (loading && !menu.hidden) {
menu._stateTimer = setTimeout(function () {
if (!menu.hidden) refreshStates(menu);
}, 2000);
}
}).catch(function () { /* No state is a menu without dots, not an error. */ });
}
function applyFilter(menu, needle) {
@@ -1128,7 +1172,19 @@ document.addEventListener("lembas:notify", function (event) {
shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */
var outer = scroller(bar);
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
if (!outer || outer === body) return;
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
ancestor that can scroll, the document included -- and the document
could, by the height of whatever leaked out of the scroller, so a tab
switch lifted the whole shell and left a strip of background under it.
The containing block in app.css stops the leak; this stops a leak
anyone adds later from being turned into a visible one.
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
is scrolled past the lede it reports the scroller's own top and the
sum below would come out as nothing to do. */
var tabs = bar.parentElement;
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
});
})();
@@ -72,6 +72,22 @@
</p>
</div>
{# Which data group this provider reads. Only worth a control once there is a
second group to choose; until then every connection is in the default one
and the field would be a select with one option. #}
{% if data_group_choices is defined and data_group_choices|length > 1 %}
<div class="field">
<label class="field__label" for="group-{{ connection.id }}">{{ t("Data group") }}</label>
<select class="select" id="group-{{ connection.id }}" name="data_group_id">
{% for group in data_group_choices %}
<option value="{{ group.id }}"
{{ 'selected' if (connection.data_group_id or 'default') == group.id }}>{{ group.name }}</option>
{% endfor %}
</select>
<p class="field__hint">{{ t("Its models read only this group's memories, notes, skills, knowledge and reports, and a chat started on one of them stays in it. Moving a connection does not move any data: it starts reading the other group.") }}</p>
</div>
{% endif %}
<div class="field">
<label class="field__label" for="unload-{{ connection.id }}">{{ t("Unload URL") }}</label>
<div class="btn-row">
@@ -105,6 +121,15 @@
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="one_model_at_a_time" value="true"
{{ 'checked' if connection.one_model_at_a_time }}>
<span>{{ t("Holds one model at a time") }}</span>
</label>
<p class="field__hint">{{ t("Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
@@ -50,6 +50,23 @@
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">{{ t("Models") }}</span>
</a>
{# Beside Connections and Models because it is about them: which
provider's models may read which part of the people's data. #}
<a class="nav-item {{ 'is-active' if section == 'data-groups' }}" href="/admin/data-groups">
{{ icon("shield", "icon--sm") }}
<span class="nav-item__label">{{ t("Data groups") }}</span>
</a>
{# Its own entry rather than a card on Agents, where it started. Sitting
there made it read as an agent-chat feature -- which is what the owner
took it for, reasonably, since that is what the page is called. #}
<a class="nav-item {{ 'is-active' if section == 'crowd' }}" href="/admin/crowd">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("A crowd") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'rules' }}" href="/admin/rules">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("Model rules") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">{{ t("Audio") }}</span>
@@ -395,76 +395,6 @@
button was pressed, which is what keeps each group's save handler writing one
key.
#}
{# A third settings group on this page, saved by its own form -- the reason the
Helpers card gives. A crowd is not an agent-chat feature either, but this is the
page somebody opens to find out what one turn may set going. #}
<form method="post" action="/admin/agents/crowd" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("A crowd") }}</h2>
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
<form method="post" action="/admin/agents/subagents" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("Helpers") }}</h2>
+87
View File
@@ -0,0 +1,87 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "crowd" %}
{% block title %}A crowd - {{ brand.name }}{% endblock %}
{% block heading %}A crowd{% endblock %}
{% block admin_content %}
{#
Its own page rather than a card on Agents, which is where it shipped in 1.6.0.
Sitting there made it read as an agent-chat feature -- the owner took it for one,
reasonably, because that is what the page is called -- and a crowd has nothing to
do with agent chats: it works in any conversation.
#}
<p class="admin-lede">{{ t("Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<form method="post" action="/admin/crowd" class="form-grid">
<section class="card">
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
{% endblock %}
@@ -0,0 +1,143 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "data-groups" %}
{% block title %}{{ group.name }} - {{ t("Data groups") }} - {{ brand.name }}{% endblock %}
{% block heading %}{{ group.name }}{% endblock %}
{% block admin_content %}
<p class="admin-lede">
<a href="/admin/data-groups">{{ icon("chevron-left", "icon--sm") }} {{ t("All data groups") }}</a>
{% if owner %}· {{ t("A personal group of %(who)s.", who=owner.email) }}{% endif %}
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
{% if error %}
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
{% endif %}
{#
The delete form is declared before the main one and reached by `form=`, so it
can never end up nested inside it -- a nested <form> truncates its parent, the
1.3.0 bug that made a whole admin page unsaveable.
#}
<form id="delete-group" method="post" action="/admin/data-groups/{{ group.id }}/delete"></form>
<form method="post" action="/admin/data-groups/{{ group.id }}" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("Name") }}</h2>
<div class="field">
<label class="field__label" for="name">{{ t("Name") }}</label>
<input class="input" id="name" name="name" value="{{ group.name }}" required>
<p class="field__hint"></p>
</div>
<div class="field">
<label class="field__label" for="description">{{ t("What it is for") }}</label>
<input class="input" id="description" name="description"
value="{{ group.description }}" maxlength="2000">
<p class="field__hint"></p>
</div>
</section>
{% if not owner %}
<section class="card">
<h2 class="card__title">{{ t("Connections in this group") }}</h2>
<p class="card__lede">{{ t("Their models read this group's data and nothing else. A connection taken out of a group goes back to the default one. Moving a connection moves no data: it starts reading the other group's.") }}</p>
<input type="hidden" name="connections_sent" value="1">
<div class="field">
{% for connection in connections %}
<label class="checkbox">
<input type="checkbox" name="connection_ids" value="{{ connection.id }}"
{{ 'checked' if connection.id in member_ids }}
{{ 'disabled' if group.is_default and connection.id in member_ids }}>
<span>{{ connection.name }}{% if not connection.enabled %} <span class="badge">{{ t("disabled") }}</span>{% endif %}</span>
</label>
{% else %}
<p class="field__hint">{{ t("There are no connections yet.") }}</p>
{% endfor %}
{% if group.is_default %}
<p class="field__hint">{{ t("A connection leaves the default group by being put into another one.") }}</p>
{% endif %}
</div>
</section>
{% endif %}
<section class="card">
<h2 class="card__title">{{ t("Services that read this group") }}</h2>
<p class="card__lede">{{ t("The embedder is sent the full text of every document, note, skill and report it indexes, and the reviewer is sent every generated picture with its prompt. A group can name its own, so its data does not have to go to the instance's.") }}</p>
<div class="field">
<label class="field__label" for="embedding">{{ t("Embedding model") }}</label>
<select class="select" id="embedding" name="embedding">
<option value="">{{ t("The instance's choice") }}</option>
{% for model in embedders %}
<option value="{{ model.model_id }}|{{ model.connection_id }}"
{{ 'selected' if group.embedding_model_id == model.model_id and (not group.embedding_connection_id or group.embedding_connection_id == model.connection_id) }}>
{{ model.label }} · {{ model.connection.name }}
</option>
{% endfor %}
</select>
<p class="field__hint">{{ t("Changing it leaves this group's search keyword-only until the index is rebuilt on the Extraction page.") }}</p>
</div>
<div class="field">
<label class="field__label" for="reviewer">{{ t("Image reviewer") }}</label>
<select class="select" id="reviewer" name="reviewer">
<option value="">{{ t("The instance's choice") }}</option>
{% for model in reviewers %}
<option value="{{ model.model_id }}|{{ model.connection_id }}"
{{ 'selected' if group.review_model_id == model.model_id and (not group.review_connection_id or group.review_connection_id == model.connection_id) }}>
{{ model.label }} · {{ model.connection.name }}
</option>
{% endfor %}
</select>
<p class="field__hint">{{ t("Only models that can see images are listed.") }}</p>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
{% if not group.is_default %}
<button class="btn btn--danger" type="submit" form="delete-group"
data-confirm-button="{{ t('Delete the data group “%(name)s”?', name=group.name) }}">
{{ icon("trash", "icon--sm") }} {{ t("Delete") }}
</button>
{% endif %}
</div>
</form>
<section class="card" style="margin-top: var(--sp-6)">
<h2 class="card__title">{{ t("Where this group's data goes") }}</h2>
<p class="card__lede">{{ t("Every provider this group's data is sent to, as the instance has it set. A person holding “Manage their own data groups” may have arranged their own differently. Speech to text and text to speech are not grouped: audio is sent and not kept.") }}</p>
{% if exits %}
{# Rows rather than a table, so a phone gets a stack instead of a sideways
scroll -- the same list-row pattern as every other admin list. #}
<div class="model-rows">
{% for exit in exits %}
<div class="model-row">
<div class="model-row__main">
<span class="model-row__name">{{ labels.get(exit.what, exit.what) }}</span>
<span class="model-row__id">
{%- if exit.model %}{{ exit.model }} · {% endif %}{{ exit.connection -}}
</span>
</div>
<div class="model-row__meta">
{% if exit.elsewhere %}
<span class="badge badge--danger">{{ t("its connection is in %(group)s", group=exit.elsewhere) }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="field__hint">{{ t("Nothing: no connection is in this group, and no service reads it.") }}</p>
{% endif %}
<p class="field__hint">
{{ t("Holds") }}:
{% for label, n in counts.items() %}{{ n }} {{ labels.get(label, label) }}{% if not loop.last %}, {% endif %}{% endfor %}.
</p>
</section>
{% endblock %}
@@ -0,0 +1,73 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "data-groups" %}
{% block title %}{{ t("Data groups") }} - {{ brand.name }}{% endblock %}
{% block heading %}{{ t("Data groups") }}{% endblock %}
{% block admin_content %}
<p class="admin-lede">{{ t("A data group keeps one provider's models away from the data another provider's models have been given. Every connection is in one group. Its models read only that group's memories, notes, skills, knowledge, reports and personalities, and a chat stays in the group it was started in.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Data group deleted.") if saved == "deleted" else t("Settings saved.") }}</span></div>
{% endif %}
<h2 class="admin-section-title">
{{ t("Instance groups") }} <span class="badge">{{ groups|length }}</span>
</h2>
<div class="model-rows">
{% for group in groups %}
<a class="model-row" href="/admin/data-groups/{{ group.id }}">
<div class="model-row__main">
<span class="model-row__name">{{ group.name }}</span>
{% if group.description %}
<span class="model-row__id">{{ group.description }}</span>
{% endif %}
</div>
<div class="model-row__meta">
{% if group.is_default %}<span class="badge badge--leaf">{{ t("default") }}</span>{% endif %}
<span class="badge">{{ t("%(n)s connections", n=connections[group.id]) }}</span>
<span class="badge">{{ t("%(n)s chats", n=counts[group.id]["chats"]) }}</span>
{% if group.embedding_model_id %}<span class="badge">{{ t("own embedder") }}</span>{% endif %}
{% if group.review_model_id %}<span class="badge">{{ t("own reviewer") }}</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
<section class="card" style="margin-top: var(--sp-6)">
<form method="post" action="/admin/data-groups" class="btn-row">
<input class="input" name="name" placeholder="{{ t('New data group name') }}" required
aria-label="{{ t('New data group name') }}">
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} {{ t("Create data group") }}
</button>
</form>
<p class="field__hint">{{ t("A new group holds nothing and reads nothing until a connection is put into it. Nothing moves on its own.") }}</p>
</section>
{#
Personal groups are somebody's own arrangement of their own data -- made by
a person holding "Manage their own data groups" -- and are listed so an
administrator can see they exist, not so they can be rearranged from here.
#}
{% if personal %}
<h2 class="admin-section-title">
{{ t("Personal groups") }} <span class="badge">{{ personal|length }}</span>
</h2>
<div class="model-rows">
{% for group in personal %}
<a class="model-row" href="/admin/data-groups/{{ group.id }}">
<div class="model-row__main">
<span class="model-row__name">{{ group.name }}</span>
<span class="model-row__id">{{ owners[group.owner_id].email if group.owner_id in owners else "" }}</span>
</div>
<div class="model-row__meta">
<span class="badge">{{ t("%(n)s chats", n=counts[group.id]["chats"]) }}</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
@@ -306,6 +306,15 @@
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="single_session" value="true"
{{ 'checked' if model.single_session }}>
<span>{{ t("Serves one request at a time") }}</span>
</label>
<p class="field__hint">{{ t("Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
@@ -343,6 +352,47 @@
{# Outside the form above, and it has to be: two forms cannot nest, and this one
posts somewhere else. See the note beside the Detect button. #}
<section class="card">
<h2 class="card__title">{{ t("Helpers") }}</h2>
<p class="card__lede">{{ t("Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.") }}</p>
{% if designations %}
<ul class="model-list">
{% for row in designations %}
<li class="model-list__item">
<strong class="mono">{{ row.helper_model }}</strong>
<div class="btn-row">
{% if row.offer %}
<span class="badge badge--leaf">{{ t("offered to the model") }}</span>
{% else %}
<span class="badge">{{ t("by hand only") }}</span>
{% endif %}
<form id="remove-helper-{{ row.id }}" method="post"
action="/admin/models/{{ model.id }}/helpers/{{ row.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="remove-helper-{{ row.id }}"
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% if helper_choices %}
<form method="post" action="/admin/models/{{ model.id }}/helpers" class="row"
style="gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4)">
<label class="visually-hidden" for="helper-model">{{ t("Helper model") }}</label>
<select class="select" id="helper-model" name="helper_model" style="flex: 1; min-width: 0">
{% for choice in helper_choices %}<option value="{{ choice }}">{{ choice }}</option>{% endfor %}
</select>
<label class="checkbox">
<input type="checkbox" name="offer" value="true" checked>
<span>{{ t("Offer it to the model") }}</span>
</label>
<button class="btn" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
</form>
{% endif %}
</section>
<section class="card">
<h2 class="card__title">{{ t("Default personality") }}</h2>
<p class="card__lede">
+38
View File
@@ -0,0 +1,38 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "rules" %}
{% block title %}{{ t("Model rules") }} - {{ brand.name }}{% endblock %}
{% block heading %}{{ t("Model rules") }}{% endblock %}
{% block admin_content %}
<p class="admin-lede">{{ t("Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<section class="card">
<h2 class="card__title">{{ t("The starting point") }}</h2>
<form method="post" action="/admin/rules/mode">
<div class="field">
<label class="checkbox">
<input type="radio" name="mode" value="open" {{ 'checked' if mode == 'open' }}>
<span>{{ t("Any model may talk to any other, except where a rule forbids it") }}</span>
</label>
<label class="checkbox">
<input type="radio" name="mode" value="closed" {{ 'checked' if mode == 'closed' }}>
<span>{{ t("No model may talk to another, except where a rule allows it") }}</span>
</label>
<p class="field__hint">{{ t("People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.") }}</p>
</div>
<div class="btn-row"><button class="btn btn--primary" type="submit">{{ t("Save") }}</button></div>
</form>
</section>
<section class="card">
<h2 class="card__title">{{ t("Rules") }}</h2>
{% set rules_action = "/admin/rules" %}
{% include "partials/_talk_rules.html" %}
</section>
{% endblock %}
+3
View File
@@ -152,6 +152,9 @@
what `app.js` turns into a sentence on the settings page.
#}
<script>
/* The release this page was rendered by, for `app.js` to hold a waiting
worker up against -- see "A release that arrived while you were reading". */
window.lembasRelease = {{ version | tojson }};
window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */
@@ -293,6 +293,182 @@
</div>
</div>
{% endif %}
{#
Who else answers.
Beside the tool switches rather than buried in Chat settings, and
*inside this form* rather than in the topbar, for one reason each.
The first: somebody deciding who answers is making the same kind of
choice as somebody picking the model, and the first version of this
put it only in the Chat settings panel — behind the ⋯ menu, inside a
chat that already existed. The owner enabled the feature, went
looking, and could not find it. A control nobody can find is a
feature nobody has.
The second: on the new-chat screen there is no chat row to attach
anybody to, so the choice has to *ride along with the first message*
— which means being a field of this form. That is the same mechanism
the scope switches above use, with the same hidden-input trick,
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{#
Helpers: models designated for this chat's model that it may send a
helper to. Ticking one adds it to this chat by hand; an offered one
the model may use anyway. The crowd picker's shape and its reasons:
a sibling empty form reached by `form=`, one hidden field always
sent, and the verb on the checkbox.
#}
{% if helper_choices %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="{{ t('Helpers') }}" title="{{ t('Helpers') }}">
{{ icon("bolt") }}
{% if helper_member_ids %}
<span class="composer__count">{{ helper_member_ids|length }}</span>
{% endif %}
</button>
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
hidden aria-label="{{ t('Helpers') }}">
<p class="picker__lede">
{{ t("Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.") }}
</p>
{% if chat %}
<input type="hidden" name="helper_model_ids" value="" form="helpers-form">
{% else %}
<input type="hidden" name="helper_model_ids" value="">
{% endif %}
{% for choice in helper_choices %}
<label class="picker__option picker__option--toggle">
{% if chat %}
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
{{ 'checked' if choice.model.model_id in helper_member_ids }}
{{ 'disabled' if not choice.addable }}
form="helpers-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
{{ 'disabled' if not choice.addable }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ choice.model.label }}</span>
<span class="picker__option-note">
{%- if helper_reasons.get(choice.model.model_id) %}{{ helper_reasons[choice.model.model_id] }}
{%- elif choice.offer %}{{ t("Offered to the model.") }}
{%- else %}{{ t("Only when added here.") }}{% endif -%}
</span>
</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if crowd_available or crowd_held_back %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="{{ t('Crowd') }}" title="{{ t('Crowd') }}">
{{ icon("users") }}
{% if crowd_member_ids %}
<span class="composer__count">{{ crowd_member_ids|length + 1 }}</span>
{% endif %}
</button>
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
hidden aria-label="{{ t('Crowd') }}">
<p class="picker__lede">
{{ t("Tick a model to have it answer after this one, then be asked whether it disagrees.") }}
</p>
<p class="picker__group">{{ t("Also answering") }}</p>
{% if chat %}
{# One hidden field for the whole list, always submitted, so
unticking the last box still says something -- an absent checkbox
carries no signal of its own. #}
<input type="hidden" name="crowd_model_ids" value="" form="crowd-form">
{% else %}
<input type="hidden" name="crowd_model_ids" value="">
{% endif %}
{% for model in crowd_available %}
<label class="picker__option picker__option--toggle">
{% if chat %}
{# An existing chat: written at once. The verb is on the checkbox
and not on `#crowd-form`, because htmx binds a trigger to the
annotated element and `change` bubbles through *ancestors* --
which a sibling form is not. `form=` scopes the values, and
only the values: without it the PATCH would carry the
composer's own `content` and `project_dir`, and `update_chat`
answers that with a 409. The same reasoning the agent mode
select below carries. #}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ model.label }}</span>
{% if model.description %}
<span class="picker__option-note">{{ model.description }}</span>
{% endif %}
</span>
</label>
{% endfor %}
{#
Models the talk rules do not offer to this chat's model, named
with the reason rather than left out. Tickable where the person
may still add them by hand -- their own rule, or the override --
and shown disabled where they may not, so the reason is visible
either way. Same field and same verb as the list above.
#}
{% if crowd_held_back %}
<p class="picker__group">{{ t("Not offered to this model") }}</p>
{% for row in crowd_held_back %}
<label class="picker__option picker__option--toggle">
{% if chat %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ row.model.label }}</span>
<span class="picker__option-note">
{{ row.reason }}
{% if row.addable %}{{ t("You may still add it yourself.") }}{% endif %}
</span>
</span>
</label>
{% endfor %}
{% endif %}
{% if crowd_member_ids %}
{# One sentence and not three, with the numbers as placeholders: a
translation puts the parts in its own order, and two of these
fragments are not sentences in any language. #}
<p class="picker__lede">
{{ t("%(models)s models answer each turn, over up to %(rounds)s rounds.",
models=crowd_member_ids|length + 1, rounds=crowd_rounds) }}
</p>
{% endif %}
{% if crowd_skipped %}
<p class="picker__lede">
{{ t("Skipped, because you cannot reach them any more:") }}
<s>{{ crowd_skipped|join(", ") }}</s>
</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
{#
@@ -502,6 +678,15 @@
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #}
{% if chat and helper_choices %}
<form id="helpers-form"></form>
{% endif %}
{% if chat and (crowd_available or crowd_held_back) %}
{# Empty, and a sibling of the composer's form rather than inside it. See the
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
of that sentence matter. #}
<form id="crowd-form"></form>
{% endif %}
{% if chat and chat.kind == "agent" %}
<form id="agent-mode-form"></form>
{% endif %}
+7 -7
View File
@@ -88,24 +88,24 @@
replies: a round produces more bubbles than it has models in it. #}
<span class="badge">
{% if crowd.get("phase") == "out" %}
{{ crowd.get("index", 0) + 1 }} of {{ crowd.get("of", 1) }}
{{ t("%(n)s of %(total)s", n=crowd.get("index", 0) + 1, total=crowd.get("of", 1)) }}
{% elif crowd.get("phase") == "back" %}
on the way back
{{ t("on the way back") }}
{% else %}
closing
{{ t("closing") }}
{% endif %}
{% if crowd.get("round", 1) > 1 %} · round {{ crowd.get("round") }}{% endif %}
{% if crowd.get("round", 1) > 1 %} · {{ t("round %(n)s", n=crowd.get("round")) }}{% endif %}
</span>
{% if crowd.get("stopped") %}
{# Why a round ended, where it ended. Without this a crowd that ran out of
rounds or time simply stops, which reads as the feature failing. #}
<span class="badge badge--warning" title="{{ t('The round ended here') }}">
{% if crowd.get("stopped") == "rounds" %}
no rounds left
{{ t("no rounds left") }}
{% elif crowd.get("stopped") == "time" %}
out of time
{{ t("out of time") }}
{% else %}
two endpoints failed
{{ t("two endpoints failed") }}
{% endif %}
</span>
{% endif %}
@@ -3,9 +3,16 @@
Model picker.
A real dropdown rather than a <select>, because a <select> cannot show an
image, a description or capability badges -- browsers render only text in an
<option>. The hidden input is what actually carries the value, so the control
still behaves like a form field.
image or an icon -- browsers render only text in an <option>. The hidden
input is what actually carries the value, so the control still behaves like
a form field.
Each option is name, context window and an eye for vision, and nothing else.
It listed every capability switch as a tag until 1.8.2, which is twenty
`tool_*` tags per model in a menu whose one job is choosing; the full list is
on /settings. The options share the list's column tracks (subgrid), so the
context sizes and the eyes line up whatever a name's length -- and every
option emits every slot, empty or not, or its row shifts.
Inside a chat it PATCHes the chat; on /chat it navigates, because there is no
chat row to patch yet.
@@ -31,35 +38,64 @@
</div>
{% endif %}
<div class="picker__list">
<div class="picker__list picker__list--models"
data-label-loaded="{{ t('Loaded') }}" data-label-loading="{{ t('Loading') }}">
{% for model in models %}
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
type="button" role="option"
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
data-picker-value="{{ model.model_id }}"
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
{{ model_avatar(model, cls="picker__avatar") }}
<span class="picker__option-body">
<span class="picker__option-name">
{{ model.label }}
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
</span>
{% if model.description %}
<span class="picker__option-desc">{{ model.description }}</span>
{% endif %}
<span class="picker__option-tags">
{% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="tag">{{ name }}</span>{% endif %}
{% endfor %}
</span>
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}"
data-model-id="{{ model.model_id }}">
{#
The avatar in a slot of its own size, carrying the load-state dot on
its corner. A dot there takes no track, so the columns the context
sizes and the eyes line up on are the ones they had. The state is
fetched when the menu opens (ui.js, /api/models/state) rather than
rendered here: it changes by the minute, and asking every endpoint on
every page render would put a network call in front of each page.
#}
<span class="model-slot" data-model-state="">
{{ model_avatar(model, cls="picker__avatar") }}
<span class="model-state" aria-hidden="true"></span>
<span class="visually-hidden" data-model-state-label></span>
</span>
{% if current_model and model.model_id == current_model.model_id %}
{{ icon("check", "icon--sm picker__tick") }}
{% endif %}
<span class="picker__option-name">
<span class="truncate">{{ model.label }}</span>
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
</span>
<span class="model-ctx mono"
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
{%- if model.context_length %}CTX {{ model.context_length|context_size }}{% endif -%}
</span>
<span class="model-vision">
{%- if (model.capabilities_json or {}).get("vision") -%}
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
{%- endif -%}
</span>
{# Always rendered and shown by `.is-selected`, so it follows ui.js's
in-place choice rather than staying on the model the page loaded with. #}
<span class="picker__tick">{{ icon("check", "icon--sm") }}</span>
</button>
{% endfor %}
</div>
<p class="picker__empty" data-picker-empty hidden>{{ t("No model matches that.") }}</p>
{#
Models in another data group. Named rather than left out, so somebody
looking for one learns where it went; not options, because switching this
chat to one would hand it the whole conversation. Only inside a chat --
on /chat every model can start one.
#}
{% if chat and models_elsewhere %}
<div class="picker__elsewhere">
<p class="picker__elsewhere-head">
{{ t("In another data group than this chat (%(group)s). Start a new chat to use them:", group=chat_group_name) }}
</p>
<p class="picker__elsewhere-names">
{%- for model in models_elsewhere %}{{ model.label }}{% if not loop.last %}, {% endif %}{% endfor -%}
</p>
</div>
{% endif %}
</div>
{% if chat %}
@@ -71,7 +107,9 @@
</form>
{% else %}
{# No chat yet: selecting navigates so the whole composer re-renders with the
right vision warning and the right hidden model_id. #}
<span hidden data-picker-navigate="/chat?model="></span>
right vision warning and the right hidden model_id. The URL carries the
other preselections -- temporary, folder, kind -- and ends in `model=`,
which ui.js completes. #}
<span hidden data-picker-navigate="{{ model_navigate_url }}"></span>
{% endif %}
</div>
+3 -2
View File
@@ -65,11 +65,12 @@
<div class="topbar__actions">
{#
A link, not a script: the flag lives in the URL, so it survives a
reload and can be bookmarked.
reload and can be bookmarked. The URL is built in `chat_index` so it
keeps the chosen model, folder and kind.
#}
{% if not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
href="{{ temporary_toggle_url }}"
aria-label="{{ t('Temporary chat') }}"
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
{{ icon("clock") }}
@@ -0,0 +1,77 @@
{#
Data groups on the library pages. Import `with context`: both macros read
`several_groups`, `data_groups`, `group_names` and `may_move_groups`, which
`api/library.py:_groups` puts on every library page.
Nothing here appears on an instance with one group, so a library that never
uses groups looks exactly as it always did.
#}
{# Which group a row is in, as a badge. Only when there is a choice to see. #}
{% macro group_chip(row) -%}
{%- if several_groups -%}
<span class="badge" title="{{ t('Data group') }}">{{ group_names.get(row.data_group_id or 'default', '') }}</span>
{%- endif -%}
{%- endmacro %}
{#
The group a new record goes into, or the one an existing record is in.
A new record may go into any group this person can use: it is theirs, and
choosing where it lives is choosing which providers may read it. Moving one
that exists is `data.manage`, because that changes what a provider has
already been able to see. Every slot is emitted even when one is empty, so a
field row lines up by construction.
#}
{% macro group_field(row=None, chosen="") -%}
{%- if several_groups -%}
{%- set current = (row.data_group_id if row else chosen) or "default" -%}
<div class="field">
<label class="field__label" for="data_group_id">{{ t("Data group") }}</label>
{%- if not row or may_move_groups %}
<select class="select" id="data_group_id" name="data_group_id">
{%- for group in data_groups %}
<option value="{{ group.id }}" {{ 'selected' if group.id == current }}>{{ group.name }}</option>
{%- endfor %}
</select>
{%- else %}
<input class="input" id="data_group_id" value="{{ group_names.get(current, '') }}" disabled>
{%- endif %}
<p class="field__hint">
{%- if not row -%}
{{ t("Only models whose connection is in this group can read it.") }}
{%- elif may_move_groups -%}
{{ t("Moving it hands it to the other group's models, and takes it away from this group's.") }}
{%- else -%}
{{ t("Only models whose connection is in this group can read it. Moving records between groups needs the permission to manage your own data groups.") }}
{%- endif -%}
</p>
</div>
{%- endif -%}
{%- endmacro %}
{#
The list filter: a select that submits itself, as a GET so the filtered list
is a URL. The other filters on the page ride along as hidden fields.
#}
{% macro group_filter(action, current="", keep={}) -%}
{%- if several_groups -%}
{#- A compact control rather than a field: the label is for a screen reader,
because the options ("Every group", the group names) already say what the
select is for, and a full-width field here outweighed the list below it. #}
<form method="get" action="{{ action }}" class="btn-row" style="margin-bottom: var(--sp-4)">
{%- for name, value in keep.items() %}{% if value %}
<input type="hidden" name="{{ name }}" value="{{ value }}">
{%- endif %}{% endfor %}
<label class="visually-hidden" for="group-filter">{{ t("Data group") }}</label>
<select class="select" id="group-filter" name="group" style="flex: none; width: auto"
onchange="this.form.submit()">
<option value="">{{ t("Every group") }}</option>
{%- for group in data_groups %}
<option value="{{ group.id }}" {{ 'selected' if group.id == current }}>{{ group.name }}</option>
{%- endfor %}
</select>
<noscript><button class="btn btn--sm" type="submit">{{ t("Show") }}</button></noscript>
</form>
{%- endif -%}
{%- endmacro %}
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "knowledge" %}
{% block title %}{{ base.name }} - {{ brand.name }}{% endblock %}
@@ -102,6 +103,7 @@
value="{{ base.description }}" {{ 'disabled' if not is_owner }}>
</div>
</div>
{% if is_owner %}{{ group_field(base) }}{% endif %}
</section>
{% include "library/_share.html" %}
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "knowledge" %}
{% block title %}Knowledge - {{ brand.name }}{% endblock %}
@@ -14,6 +15,7 @@
<a class="filter-tab {{ 'is-active' if shared }}"
href="/library/knowledge?shared=1">Shared with me</a>
</div>
{{ group_filter("/library/knowledge", group, {"shared": "1" if shared else ""}) }}
{% if error %}
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
@@ -31,6 +33,7 @@
</div>
</div>
<div class="btn-row">
{{ group_chip(base) }}
{% if base.owner_id != user.id %}<span class="badge">{{ t("shared with you") }}</span>{% endif %}
</div>
</li>
@@ -59,6 +62,7 @@
placeholder="{{ t('What belongs in here.') }}">
</div>
</div>
{{ group_field(None, group) }}
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
</form>
</section>
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "notes" %}
{% block title %}{{ note.title if note else "New note" }} - {{ brand.name }}{% endblock %}
@@ -27,6 +28,7 @@
{{ 'disabled' if note and not is_owner }}
placeholder="{{ t('Markdown.') }}">{{ note.body if note else '' }}</textarea>
</div>
{% if not note or is_owner %}{{ group_field(note, group|default("")) }}{% endif %}
{% if note and note.author == "model" %}
<p class="field__hint">
{{ icon("sparkle", "icon--sm") }}
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "notes" %}
{% block title %}Notes - {{ brand.name }}{% endblock %}
@@ -20,6 +21,7 @@
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/notes">All notes</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
</div>
{{ group_filter("/library/notes", group, {"shared": "1" if shared else "", "q": q}) }}
<form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="{{ t('Search notes…') }}">
@@ -49,6 +51,7 @@
<div class="text-xs faint">{{ note.body[:160] }}{{ "…" if note.body|length > 160 }}</div>
</div>
<div class="btn-row">
{{ group_chip(note) }}
{% if note.author == "model" %}<span class="badge badge--leaf">{{ t("written by a model") }}</span>{% endif %}
{% if note.owner_id != user.id %}<span class="badge">{{ t("shared") }}</span>{% endif %}
</div>
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "skills" %}
{% block title %}{{ skill.name if skill else "New skill" }} - {{ brand.name }}{% endblock %}
@@ -40,6 +41,7 @@
{{ 'disabled' if skill and not is_owner }}
placeholder="{{ t('Markdown. Steps, conventions, things to avoid.') }}">{{ skill.body if skill else '' }}</textarea>
</div>
{% if not skill or is_owner %}{{ group_field(skill, group|default("")) }}{% endif %}
{% if skill %}
<div class="field">
@@ -1,5 +1,6 @@
{% extends "library/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
{% set section = "skills" %}
{% block title %}Skills - {{ brand.name }}{% endblock %}
@@ -19,6 +20,7 @@
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/skills">All skills</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
</div>
{{ group_filter("/library/skills", group, {"shared": "1" if shared else "", "q": q}) }}
<form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="{{ t('Search skills…') }}">
@@ -48,6 +50,7 @@
<div class="text-xs faint">{{ skill.description }}</div>
</div>
<div class="btn-row">
{{ group_chip(skill) }}
{% if not skill.enabled %}<span class="badge">{{ t("off") }}</span>{% endif %}
{% if skill.author == "model" %}<span class="badge badge--leaf">{{ t("written by a model") }}</span>{% endif %}
{% if skill.owner_id != user.id %}<span class="badge">{{ t("shared") }}</span>{% endif %}
@@ -0,0 +1,104 @@
{#
The rules list, the add form and the matrix, shared by the admin page and a
person's own settings. The caller sets `rules_action` (where the add form and
the deletes post), and passes `rules`, `model_ids`, `matrix`, `matrix_models`,
`any_model`, `allow` and `deny`.
The delete buttons reach one empty form each by `form=`, declared outside the
add form, so no form is ever nested inside another -- a nested <form>
truncates its parent, the 1.3.0 bug.
#}
{% from "_macros.html" import icon %}
{% if rules %}
<ul class="model-list">
{% for rule in rules %}
<li class="model-list__item">
<div style="min-width: 0">
<strong class="mono">{{ t("any model") if rule.from_model == any_model else rule.from_model }}</strong>
→
<strong class="mono">{{ t("any model") if rule.to_model == any_model else rule.to_model }}</strong>
</div>
<div class="btn-row">
{% if rule.effect == allow %}
<span class="badge badge--leaf">{{ t("may talk") }}</span>
{% else %}
<span class="badge badge--danger">{{ t("may not talk") }}</span>
{% endif %}
<form id="delete-rule-{{ rule.id }}" method="post" action="{{ rules_action }}/{{ rule.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="delete-rule-{{ rule.id }}"
aria-label="{{ t('Delete this rule') }}" title="{{ t('Delete this rule') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="field__hint">{{ t("No rules yet.") }}</p>
{% endif %}
<form method="post" action="{{ rules_action }}" class="talk-rule-form">
<div class="field">
<label class="field__label" for="rule-from">{{ t("This model") }}</label>
<select class="select" id="rule-from" name="from_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("The chat's own model.") }}</p>
</div>
<div class="field">
<label class="field__label" for="rule-effect">{{ t("May") }}</label>
<select class="select" id="rule-effect" name="effect">
<option value="{{ deny }}">{{ t("may not talk to") }}</option>
<option value="{{ allow }}">{{ t("may talk to") }}</option>
</select>
<p class="field__hint"></p>
</div>
<div class="field">
<label class="field__label" for="rule-to">{{ t("That model") }}</label>
<select class="select" id="rule-to" name="to_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("A crowd member, a friend it asks, a model on its roster.") }}</p>
</div>
<div class="btn-row">
<label class="checkbox">
<input type="checkbox" name="both" value="true">
<span>{{ t("Both directions") }}</span>
</label>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add rule") }}</button>
</div>
</form>
{% if matrix_models|length > 1 %}
<h3 class="card__title" style="margin-top: var(--sp-6)">{{ t("Who may talk to whom") }}</h3>
<p class="field__hint">{{ t("Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.") }}</p>
<div class="talk-matrix-scroll">
<table class="talk-matrix">
<thead>
<tr>
<th scope="col"></th>
{% for model in matrix_models %}<th scope="col" class="mono">{{ model.model_id }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in matrix %}
<tr>
<th scope="row" class="mono">{{ row.main.model_id }}</th>
{% for cell in row.cells %}
{% if cell is none %}
<td class="talk-matrix__self" aria-label="{{ t('itself') }}">·</td>
{% elif cell.offered %}
<td class="talk-matrix__yes" title="{{ t('Offered') }}">✓</td>
{% else %}
<td class="talk-matrix__no" title="{{ describe_verdict(cell) }}">✗</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
@@ -135,6 +135,10 @@
<circle cx="9" cy="10" r="1.6"/>
<path d="m4.5 17 4.2-4.2a1.5 1.5 0 0 1 2.1 0l3 3 1.9-1.9a1.5 1.5 0 0 1 2.1 0l2 2"/>
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24">
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/>
<circle cx="12" cy="12" r="3"/>
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V6M6 12l6-6 6 6"/></symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v13M6 12l6 6 6-6"/></symbol>
<symbol id="i-star" viewBox="0 0 24 24">
+224 -16
View File
@@ -57,6 +57,11 @@
<label class="tabs__tab" for="tab-memory">{{ icon("sparkle", "icon--sm") }} Memory</label>
{% endif %}
{% if several_groups or may_manage_groups %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-data">
<label class="tabs__tab" for="tab-data">{{ icon("shield", "icon--sm") }} {{ t("Data") }}</label>
{% endif %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
</div>
@@ -145,29 +150,135 @@
<div class="card">
<h2 class="card__title">{{ t("Available to you") }}</h2>
<p class="card__lede">{{ t("In the order an administrator arranged them.") }}</p>
<ul class="model-list">
{# Name, context window and vision on one line, on the list's
column tracks so they line up down the card; the capability
switches wrap underneath at the full width. They sat beside
the name until 1.8.2 and, twenty tags long, squeezed it to a
word per line and ran over it. #}
<ul class="model-list model-list--models">
{% for model in models %}
<li class="model-list__item">
<div class="row" style="gap: var(--sp-2); min-width: 0">
{{ model_avatar(model, cls="nav-item__avatar") }}
<div style="min-width: 0">
<strong>{{ model.label }}</strong>
{% if model.description %}
<div class="text-xs faint">{{ model.description }}</div>
{% endif %}
</div>
</div>
<div class="btn-row">
{% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="badge badge--leaf">{{ name }}</span>{% endif %}
{% endfor %}
{{ model_avatar(model, cls="nav-item__avatar") }}
<strong class="model-list__name">
<span>{{ model.label }}</span>
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
</strong>
<span class="model-ctx mono"
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
{%- if model.context_length %}CTX {{ model.context_length|context_size }}{% endif -%}
</span>
<span class="model-vision">
{%- if (model.capabilities_json or {}).get("vision") -%}
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
{%- endif -%}
</span>
{% if model.description %}
<div class="model-list__more text-xs faint">{{ model.description }}</div>
{% endif %}
<div class="model-list__more model-list__tags">
{%- for name, on in (model.capabilities_json or {}).items() -%}
{%- if on %}<span class="tag">{{ name }}</span>{% endif -%}
{%- endfor -%}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{#
Who your models may talk to. Anybody may narrow the instance's rules
for themselves; widening them takes `rules.override`, and the text
below says which of the two this person has, so a rule that does
nothing is not a mystery.
#}
<div class="card">
<h2 class="card__title">{{ t("Who your models may talk to") }}</h2>
<p class="card__lede">
{% if talk_override %}
{{ t("Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.") }}
{% else %}
{{ t("Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.") }}
{% endif %}
</p>
<form method="post" action="/api/preferences/talk-mode" class="row"
style="gap: var(--sp-2); margin-bottom: var(--sp-4)">
<label class="visually-hidden" for="talk-mode">{{ t("Your starting point") }}</label>
<select class="select" id="talk-mode" name="mode" style="flex: 1; min-width: 0">
<option value="" {{ 'selected' if not talk_mode }}>{{ t("Follow the instance") }}</option>
<option value="open" {{ 'selected' if talk_mode == 'open' }}>{{ t("Any model may talk to any other") }}</option>
<option value="closed" {{ 'selected' if talk_mode == 'closed' }}>{{ t("No model may talk to another") }}</option>
</select>
<button class="btn" type="submit">{{ t("Save") }}</button>
</form>
{% set rules_action = "/api/preferences/talk-rules" %}
{% set rules = talk_rules %}
{% set matrix = talk_matrix %}
{% set matrix_models = talk_matrix_models %}
{% include "partials/_talk_rules.html" %}
</div>
{#
A person's own helper designations. Shown to everybody who could
send a helper, so the instance's arrangement is visible; editable
only with `helpers.designate`, because a designation decides where
a person's tasks are sent.
#}
{% if helper_settings_shown %}
<div class="card">
<h2 class="card__title">{{ t("Your helper models") }}</h2>
<p class="card__lede">
{% if may_designate %}
{{ t("Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.") }}
{% else %}
{{ t("Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.") }}
{% endif %}
</p>
{% if own_designations %}
<ul class="model-list">
{% for row in own_designations %}
<li class="model-list__item">
<div style="min-width: 0">
<strong class="mono">{{ row.main_model }}</strong> → <strong class="mono">{{ row.helper_model }}</strong>
</div>
<div class="btn-row">
<span class="badge {{ 'badge--leaf' if row.offer }}">{{ t("offered to the model") if row.offer else t("by hand only") }}</span>
<form id="del-helper-{{ row.id }}" method="post" action="/api/preferences/helpers/{{ row.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="del-helper-{{ row.id }}"
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% if may_designate %}
<form method="post" action="/api/preferences/helpers" class="talk-rule-form">
<div class="field">
<label class="field__label" for="helper-main">{{ t("This model") }}</label>
<select class="select" id="helper-main" name="main_model">
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("The chat's own model.") }}</p>
</div>
<div class="field">
<label class="field__label" for="helper-model">{{ t("May send helpers to") }}</label>
<select class="select" id="helper-model" name="helper_model">
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint"></p>
</div>
<div class="btn-row">
<label class="checkbox">
<input type="checkbox" name="offer" value="true" checked>
<span>{{ t("Offer it to the model") }}</span>
</label>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
</div>
</form>
{% endif %}
</div>
{% endif %}
</section>
{# --- Appearance --- #}
@@ -376,6 +487,9 @@
<input class="input" name="content" value="{{ memory.content }}"
maxlength="{{ memory_limit }}" style="flex: 1"
aria-label="{{ t('What this memory says') }}">
{% if several_groups %}
<span class="badge" title="{{ t('Data group') }}">{{ group_names.get(memory.data_group_id or 'default', '') }}</span>
{% endif %}
<button class="btn btn--sm" type="submit">{{ t("Save") }}</button>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/api/library/memories/{{ memory.id }}/delete"
@@ -404,6 +518,14 @@
maxlength="{{ memory_limit }}"
aria-label="{{ t('Something worth remembering') }}"
placeholder="{{ t('Prefers metric units and a 24-hour clock.') }}">
{% if several_groups %}
<select class="select" name="data_group_id" aria-label="{{ t('Data group') }}"
style="flex: none">
{% for group in data_groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
{% endif %}
<button class="btn btn--primary" type="submit">{{ t("Remember") }}</button>
</form>
<p class="field__hint">
@@ -427,7 +549,9 @@
{% for personality in personalities %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ personality.model_key }}</strong>
{% set key = split_key(personality.model_key) %}
<strong>{{ key[0] }}</strong>
{% if several_groups %}<span class="badge">{{ group_names.get(key[1], key[1]) }}</span>{% endif %}
{% if personality.author == "model" %}
<span class="badge badge--leaf">{{ t("its own words") }}</span>
{% endif %}
@@ -472,7 +596,9 @@
{% for impression in impressions %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ impression.model_key }}</strong>
{% set key = split_key(impression.model_key) %}
<strong>{{ key[0] }}</strong>
{% if several_groups %}<span class="badge">{{ group_names.get(key[1], key[1]) }}</span>{% endif %}
<div class="text-sm">{{ impression.content }}</div>
</div>
<form method="post"
@@ -492,6 +618,88 @@
</section>
{% endif %}
{# --- Data groups --- #}
{#
Which provider may read which of this person's data. Shown to anybody
once there is more than one group, because "which provider can read
my notes?" is a question anybody may ask; editable only with
`data.manage`, because the answer changes what a provider can see.
#}
{% if several_groups or may_manage_groups %}
<section class="tabs__panel" data-tab="tab-data">
<div class="card">
<h2 class="card__title">{{ t("Your data groups") }}</h2>
<p class="card__lede">{{ t("A model reads only the memories, notes, skills, knowledge, reports and personality of the data group its connection is in, and a chat stays in the group it was started in. Two providers in different groups never see each other's.") }}</p>
<ul class="model-list">
{% for group in data_groups %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ group.name }}</strong>
{% if group.personal %}<span class="badge">{{ t("yours") }}</span>{% endif %}
<div class="text-xs faint">
{% for label, n in group_counts[group.id].items() %}{{ n }} {{ group_labels.get(label, label) }}{% if not loop.last %} · {% endif %}{% endfor %}
</div>
</div>
{% if group.personal and may_manage_groups %}
<form method="post" action="/api/preferences/data-groups/{{ group.id }}/delete">
<button class="btn btn--sm btn--danger" type="submit"
data-confirm-button="{{ t('Delete the data group “%(name)s”?', name=group.name) }}"
aria-label="{{ t('Delete this') }}" title="{{ t('Delete this') }}">
{{ icon("trash", "icon--sm") }}
</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% if may_manage_groups %}
<form method="post" action="/api/preferences/data-groups/new" class="row"
style="gap: var(--sp-2); margin-top: var(--sp-4)">
<input class="input" name="name" required maxlength="120" style="flex: 1"
aria-label="{{ t('New data group name') }}"
placeholder="{{ t('New data group name') }}">
<button class="btn" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Create") }}</button>
</form>
<p class="field__hint">{{ t("A group of your own, for keeping one provider away from the rest of your data. Nobody else can see it.") }}</p>
{% endif %}
</div>
<div class="card">
<h2 class="card__title">{{ t("Which group each connection reads") }}</h2>
{% if may_manage_groups %}
<p class="card__lede">{{ t("Your choice applies to you alone. Following the instance keeps up with whatever the administrator sets. Moving a connection moves no data: from then on it reads the other group, and your chats on it that were started in the old group can no longer use it.") }}</p>
<form method="post" action="/api/preferences/data-groups" class="form-grid">
{% for row in group_connections %}
<div class="field">
<label class="field__label" for="dg-{{ row.connection.id }}">{{ row.connection.name }}</label>
<select class="select" id="dg-{{ row.connection.id }}" name="group__{{ row.connection.id }}">
<option value="">{{ t("Follow the instance (%(group)s)", group=group_names.get(row.instance, '')) }}</option>
{% for group in data_groups %}
<option value="{{ group.id }}" {{ 'selected' if row.chosen == group.id }}>{{ group.name }}</option>
{% endfor %}
</select>
<p class="field__hint">{{ t("Reads: %(group)s", group=group_names.get(row.in_force, '')) }}</p>
</div>
{% endfor %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</form>
{% else %}
<p class="card__lede">{{ t("Set by the administrator. Changing it for yourself needs the permission to manage your own data groups.") }}</p>
<ul class="model-list">
{% for row in group_connections %}
<li class="model-list__item">
<strong>{{ row.connection.name }}</strong>
<span class="badge">{{ group_names.get(row.in_force, '') }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</section>
{% endif %}
{# --- Security --- #}
<section class="tabs__panel" data-tab="tab-security">
<div class="card">
+20
View File
@@ -63,6 +63,26 @@ def stable_hue(value: str) -> int:
templates.env.filters["stable_hue"] = stable_hue
def context_size(tokens: int | None) -> str:
"""A context window as a model list shows it: 131072 -> "131K".
Decimal thousands, because that is how the number is quoted everywhere a
person reads it, and a picker that said "128K" for a 131072-token model
would disagree with the admin page's own figure. Empty for an unknown size,
so the column slot is still emitted and the next row does not shift.
"""
if not tokens or tokens <= 0:
return ""
if tokens < 1000:
return str(tokens)
if tokens < 1_000_000:
return f"{round(tokens / 1000)}K"
return f"{tokens / 1_000_000:.1f}".removesuffix(".0") + "M"
templates.env.filters["context_size"] = context_size
# A user's own message: escaped here and marked up, so `@mentions` read as
# references rather than as punctuation. A filter rather than a context value
# because the message templates are included from four different handlers and
+13
View File
@@ -448,3 +448,16 @@ def test_only_an_administrator_may_customise(db, client, registered):
for path in ("identity", "flavour", "css", "themes"):
assert client.post(f"/admin/customization/{path}", data={}).status_code == 403
def test_the_doors_of_durin_are_a_riddle_not_a_greeting():
""""Speak, friend, and enter" invites a friend to speak. The inscription is a
riddle whose answer is to say the word *friend*, so the shipped line has no
commas. The owner caught it, and the commas must not come back in a
tidy-up."""
from lembas.services.branding import FLAVOUR
for key in ("chat_empty", "error_403"):
line = FLAVOUR[key][2]
assert line.startswith("Speak friend and enter.")
assert "Speak, friend" not in line
+9
View File
@@ -58,3 +58,12 @@ def test_send_from_anywhere_never_means_stop():
stops."""
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
assert 'composerAction === "send"' in window
def test_temp_keeps_what_the_new_chat_screen_already_chose():
"""`/temp` went to a bare `/chat?temporary=1`, so on a new-chat screen with
a model picked it quietly swapped back to the default model."""
window = SOURCE[SOURCE.index('name: "temp"') :][:600]
assert 'window.location = "/chat?temporary=1"' not in window
assert "window.location.search" in window
assert 'query.set("temporary", "1")' in window
+67
View File
@@ -177,6 +177,73 @@ def test_the_round_is_recorded_on_every_row(db, started):
assert len(anchors) == 1
def test_the_reply_that_opened_the_round_is_stamped_too(db, started):
"""The opening bubble says `1 of 3` like every other one.
It is the one contribution the crowd does not start -- the composer does --
so until the round begins there is nothing to stamp it with. Before this, a
two-model round rendered as an unmarked reply followed by one saying `2 of 2`,
with no 1 anywhere.
"""
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
assert crowd_service.state_of(opening) is None, "nothing to say before it finishes"
assert _advance(db, chat, opening)
db.expire_all()
state = crowd_service.state_of(opening)
assert state is not None
assert (state.phase, state.index) == (crowd_service.PHASE_OUT, 0)
assert state.of == 3
def test_the_opening_stamp_belongs_to_the_same_round(db, started):
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
order = []
assert _advance(db, chat, opening)
db.expire_all()
order = _incomplete(db, chat)
opened = crowd_service.state_of(opening)
first = crowd_service.state_of(order[0])
# Same question, same clock -- or the chips group two bubbles of one round
# under two different rounds.
assert opened.turn == first.turn
assert opened.started_at == first.started_at
assert opened.round == first.round == 1
def test_the_opening_stamp_is_not_scheduling_state(db, started):
"""It must read as "no round yet" everywhere that decides what happens next.
Fed to the scheduler it would be a member at index 0, which inherits the old
`started_at` -- so regenerating the opening an hour later would end the round
with "out of time" before anybody spoke -- and it would hand that reply a
member's tools and a member's instruction instead of an ordinary first answer.
"""
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
assert _advance(db, chat, opening)
db.expire_all()
assert crowd_service.state_of(opening) is not None
assert crowd_service.scheduling_state(opening) is None
assert crowd_service.is_opening(crowd_service.state_of(opening))
assert generation_service._opens_the_turn(opening)
def test_a_later_speaker_is_not_mistaken_for_the_opening(db, started):
chat = _crowd_chat(db)
order = _run_round(db, chat, started)
for message in order:
state = crowd_service.state_of(message)
assert not crowd_service.is_opening(state)
# `==` and not `is`: `state_of` builds a fresh Turn on every call.
assert crowd_service.scheduling_state(message) == state
def test_each_speaker_carries_its_own_connection(db, started):
"""So `speaker_for` resolves the pair rather than guessing at the id."""
chat = _crowd_chat(db)
+106
View File
@@ -70,6 +70,87 @@ def _members(db, chat) -> list[str]:
]
# --- Reachable where somebody would look --------------------------------------
#
# The feature shipped in 1.6.0 switched on and unreachable: the only control was
# inside the Chat settings panel, behind the ⋯ menu, in a chat that already
# existed. The owner enabled it, went looking, and reported that there was nothing
# to find. A control nobody can find is a feature nobody has, so these assert the
# two places it has to be rather than the one place it was.
def test_the_composer_offers_the_crowd_in_a_chat(client, db):
"""Beside the tool switches, where the comparable decisions are."""
chat = _chat(db)
page = client.get(f"/chat/{chat.id}").text
assert 'name="crowd_model_ids"' in page
assert 'form="crowd-form"' in page
assert '<form id="crowd-form">' in page
def test_the_composer_offers_the_crowd_before_the_chat_exists(client, db):
"""On the new-chat screen there is no row to attach anybody to, so the choice
rides along with the first message — the mechanism the scope switches use."""
page = client.get("/chat").text
assert 'name="crowd_model_ids"' in page
# Riding along, so no sibling form and no PATCH: the composer's own POST
# carries it.
assert '<form id="crowd-form">' not in page
assert 'value="second-model"' in page
def test_starting_a_chat_with_a_crowd_keeps_it(client, db):
"""The end of that path: the first message creates the chat *and* its crowd."""
from sqlalchemy import select as sa_select
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["", "second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in sorted(chat.crowd, key=lambda r: r.position)] == [
"second-model",
"third-model",
]
def test_starting_a_chat_refuses_a_model_the_person_cannot_reach(client, db):
"""The same rule as the panel, in the same one function, so there is nowhere
for the two to disagree."""
from sqlalchemy import select as sa_select
group = Group(name="Wheel")
db.add(group)
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
restricted.public = False
restricted.groups = [group]
user = _user(db)
user.role = "user"
db.commit()
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in chat.crowd] == ["second-model"]
def test_the_composer_control_is_absent_while_the_feature_is_off(client, db):
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
assert 'name="crowd_model_ids"' not in client.get("/chat").text
# --- Choosing -----------------------------------------------------------------
def test_the_panel_offers_the_other_models(client, db):
chat = _chat(db)
@@ -200,6 +281,31 @@ def test_a_bubble_on_the_way_out_says_which_speaker_it_is(db):
assert "2 of 3" in html
def test_the_bubble_that_opened_the_round_says_it_is_first(db):
"""The opening reply is stamped once the round begins, so it says `1 of 3`.
Before that it was the one contribution with no chip at all, which made a
two-model round read as an ordinary answer followed by one labelled `2 of 2`.
"""
chat = _chat(db)
html = _bubble(db, chat, phase=crowd_service.PHASE_OUT, index=0, of=3)
assert "1 of 3" in html
def test_the_chip_is_translated(db):
"""It is prose a person reads, and it was English on a Slovak instance."""
from lembas.web import i18n
chat = _chat(db)
i18n.activate("sk")
try:
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK, index=1, of=3)
finally:
i18n.activate("en")
assert "na ceste späť" in html
assert "on the way back" not in html
def test_a_bubble_on_the_way_back_says_so_and_is_quieter(db):
chat = _chat(db)
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK)
+251
View File
@@ -0,0 +1,251 @@
"""A chat stays in the data group it was started in.
Its history is the group's data, so every way a chat could reach a model in
another group is closed here: switching its model, the endpoint fallback, a
crowd member, a friend, the roster, a base, the `@` menu, and Messages. And if
a chat's own model is moved into another group afterwards, the next reply is
refused rather than sent.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import (
DEFAULT_GROUP,
TARGET_MESSAGES,
Chat,
Connection,
CrowdMember,
DataGroup,
Model,
User,
)
from lembas.services import chat as chat_service
from lembas.services import data_groups, schedules, settings_store
from lembas.services import subagent as subagent_service
from lembas.services.crypto import encrypt
from lembas.services.library import notes
HOSTED = "hosted"
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def setup(db, owner):
"""Two local models in the default group, one hosted model in its own."""
db.add(DataGroup(id=HOSTED, name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id=HOSTED,
)
db.add_all([local, cloud])
db.flush()
db.add_all(
[
Model(connection_id=local.id, model_id="local-a", position=0),
Model(connection_id=local.id, model_id="local-b", position=1),
Model(connection_id=cloud.id, model_id="cloud-model", position=2),
]
)
db.commit()
return local, cloud
def _chat(db, owner, model_id="local-a", connection=None, group=DEFAULT_GROUP) -> Chat:
chat = Chat(
user_id=owner.id,
model_id=model_id,
connection_id=connection.id if connection else None,
data_group_id=group,
title="t",
)
db.add(chat)
db.commit()
return chat
# --- Starting and switching ---------------------------------------------------------
def test_a_new_chat_takes_its_models_group(client, db, setup):
client.post("/api/chats/start", data={"content": "hi", "model_id": "cloud-model"})
chat = db.scalars(select(Chat).order_by(Chat.created_at.desc())).first()
assert chat.data_group_id == HOSTED
def test_a_chat_cannot_be_switched_to_another_groups_model(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
response = client.patch(f"/api/chats/{chat.id}", data={"model_id": "cloud-model"})
assert response.status_code == 409
assert "data group" in response.text
db.refresh(chat)
assert chat.model_id == "local-a"
def test_a_chat_can_switch_within_its_group(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
response = client.patch(f"/api/chats/{chat.id}", data={"model_id": "local-b"})
assert response.status_code in (200, 204)
db.refresh(chat)
assert chat.model_id == "local-b"
def test_the_picker_names_the_models_it_leaves_out(client, db, owner, setup):
"""Named, not silently missing -- and not offered as options either."""
local, _ = setup
chat = _chat(db, owner, connection=local)
page = client.get(f"/chat/{chat.id}").text
assert "In another data group" in page
assert 'data-picker-value="cloud-model"' not in page
def test_available_models_narrow_to_a_group(db, owner, setup):
ids = [m.model_id for m in chat_service.available_models(db, owner, HOSTED)]
assert ids == ["cloud-model"]
everything = [m.model_id for m in chat_service.available_models(db, owner)]
assert everything == ["local-a", "local-b", "cloud-model"]
# --- The endpoint fallback ----------------------------------------------------------
def test_the_fallback_never_repoints_a_chat_into_another_group(db, owner, setup):
"""A model id served by two connections: the chat's own going away must not
land it on the other provider, which would be handed the whole history."""
local, cloud = setup
db.add(Model(connection_id=cloud.id, model_id="local-a"))
local.enabled = False
db.commit()
chat = _chat(db, owner, connection=local)
with pytest.raises(chat_service.LLMError):
chat_service.resolve_endpoint(db, chat)
db.refresh(chat)
assert chat.connection_id == local.id
def test_a_chat_whose_model_moved_is_refused_rather_than_sent(db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
speaker = chat_service.speaker_for(db, chat)
assert data_groups.refusal(db, owner, chat, speaker) == ""
local.data_group_id = HOSTED
db.commit()
refusal = data_groups.refusal(db, owner, chat, speaker)
assert "Default" in refusal and "Hosted" in refusal
# --- Other models reaching the conversation ------------------------------------------
def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
"""For somebody without `rules.override`: a different group is a deny only a
rule opens. (An administrator holds every permission, so the owner is demoted.)"""
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
owner.role = "user"
db.commit()
local, _ = setup
chat = _chat(db, owner, connection=local)
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["local-b", "cloud-model"]})
members = [row.model_id for row in db.scalars(select(CrowdMember))]
assert members == ["local-b"]
def test_a_crowd_member_that_left_the_group_does_not_speak(db, owner, setup):
owner.role = "user"
db.commit()
local, cloud = setup
chat = _chat(db, owner, connection=local)
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
db.commit()
from lembas.services import crowd
speakers = [s.model_id for s in crowd.member_speakers(db, chat, owner)]
assert speakers == ["local-a"]
def test_the_roster_and_the_friend_stay_in_the_group(db, owner, setup):
roster = chat_service.roster_block(db, owner, exclude="local-a", group=DEFAULT_GROUP)
assert "local-b" in roster and "cloud-model" not in roster
friend, refusal = subagent_service._resolve_friend(
db, owner, "cloud-model", asking="local-a", group=DEFAULT_GROUP
)
assert friend is None
# The name asked for is echoed back; the list of who *can* be asked is not
# allowed to carry it.
offered = refusal.split("These are the ones you can:")[-1]
assert "cloud-model" not in offered and "local-b" in offered
def test_a_friend_reads_its_own_group(db, owner, setup):
local, cloud = setup
parent = _chat(db, owner, connection=local)
friend = db.scalar(select(Model).where(Model.model_id == "cloud-model"))
child = subagent_service._create_child(db, parent, title="q", write=False, friend=friend)
assert child.data_group_id == HOSTED
# --- Bases and the @ menu -------------------------------------------------------------
def test_a_base_from_another_group_cannot_be_attached(client, db, owner, setup):
from lembas.services.library import documents
local, _ = setup
chat = _chat(db, owner, connection=local)
base = documents.create_base(db, owner=owner, name="Hosted base", group=HOSTED)
response = client.post(f"/api/chats/{chat.id}/bases", data={"base_id": base.id})
assert response.status_code == 404
def test_the_mention_menu_offers_only_the_chats_group(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
notes.create(db, owner=owner, title="Home note", body="x")
notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
page = client.get(f"/api/files/mention-picker?q=&chat_id={chat.id}").text
assert "Home note" in page and "Hosted note" not in page
def test_on_the_new_chat_screen_the_chosen_model_decides(client, db, owner, setup):
notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
page = client.get("/api/files/mention-picker?q=&model_id=cloud-model").text
assert "Hosted note" in page
def test_a_note_from_another_group_cannot_be_attached(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
hosted = notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
response = client.post(
"/api/files/from-note", data={"note_id": hosted.id, "chat_id": chat.id}
)
assert "not available" in response.text
# --- Messages and schedules -------------------------------------------------------------
def test_a_schedule_from_another_group_cannot_post_to_messages(db, owner, setup):
rule = {"at": {"weekdays": [0], "times": ["15:00"]}}
with pytest.raises(schedules.ScheduleError, match="Messages"):
schedules.create(
db,
owner=owner,
title="t",
instruction="i",
rule=rule,
target=TARGET_MESSAGES,
model_id="cloud-model",
)
def test_a_schedule_is_stamped_with_its_models_group(db, owner, setup):
rule = {"at": {"weekdays": [0], "times": ["15:00"]}}
schedule = schedules.create(
db, owner=owner, title="t", instruction="i", rule=rule, model_id="cloud-model"
)
assert schedule.data_group_id == HOSTED
assert db.get(Chat, schedule.chat_id).data_group_id == HOSTED
+221
View File
@@ -0,0 +1,221 @@
"""A model reads one data group's data, and only that one -- from both sides.
Every store is checked twice: in the harness, where memories, skills and a
personality are *handed* to a model, and in the tool runners, where a model goes
looking. A test that only covered the search would miss the fetch by id, which is
the path a model takes after learning an id from somewhere it should not have.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import select
from lembas.db.models import (
AUTHOR_MODEL,
DEFAULT_GROUP,
Chat,
Connection,
DataGroup,
Impression,
Model,
User,
)
from lembas.services import harness, personas, reports
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
from lembas.services.library import documents, memories, notes, skills
HOSTED = "hosted"
TOOLS = {"tools": True}
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def chats(db, owner) -> tuple[Chat, Chat]:
"""One chat in the default group, one in the hosted group."""
db.add(DataGroup(id=HOSTED, name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id=HOSTED,
)
db.add_all([local, cloud])
db.flush()
db.add_all(
[
Model(connection_id=local.id, model_id="local-model", capabilities_json=TOOLS),
Model(connection_id=cloud.id, model_id="cloud-model", capabilities_json=TOOLS),
]
)
home = Chat(user_id=owner.id, model_id="local-model", connection_id=local.id,
data_group_id=DEFAULT_GROUP)
away = Chat(user_id=owner.id, model_id="cloud-model", connection_id=cloud.id,
data_group_id=HOSTED)
db.add_all([home, away])
db.commit()
return home, away
def _tools(*names):
return [tools_service.REGISTRY[name].schema for name in names]
def _context(db, owner, chat) -> tools_service.ToolContext:
return tools_service.context_for(db, owner, chat, tools=None)
async def _run(context, tool: str, **args):
return await tools_service.run_tool(context, tool, json.dumps(args))
# --- The harness: what a model is handed ------------------------------------------
def test_a_model_is_handed_only_its_own_groups_memories(db, owner, chats):
home, away = chats
memories.add(db, owner=owner, content="Home fact.", group=DEFAULT_GROUP)
memories.add(db, owner=owner, content="Hosted fact.", group=HOSTED)
at_home = harness.compose(db, owner, _tools("memory_add"), chat=home)
abroad = harness.compose(db, owner, _tools("memory_add"), chat=away)
assert "Home fact." in at_home and "Hosted fact." not in at_home
assert "Hosted fact." in abroad and "Home fact." not in abroad
def test_the_skill_index_is_one_groups(db, owner, chats):
home, away = chats
skills.create(db, owner=owner, name="home-skill", description="Home.", body="b")
skills.create(
db, owner=owner, name="away-skill", description="Away.", body="b", group=HOSTED
)
abroad = harness.compose(db, owner, _tools("skill_get"), chat=away)
assert "away-skill" in abroad
assert "home-skill" not in abroad
def test_a_personality_is_per_group(db, owner, chats):
home, away = chats
personas.write(
db,
model_key=personas.key_for("cloud-model", HOSTED),
owner=owner,
content="The hosted self.",
author=AUTHOR_MODEL,
)
abroad = harness.compose(db, owner, _tools("persona_write"), chat=away)
assert "The hosted self." in abroad
def test_a_base_in_another_group_is_not_named(db, owner, chats):
home, away = chats
base = documents.create_base(db, owner=owner, name="Home contracts")
away.knowledge_bases = [base]
db.commit()
abroad = harness.compose(db, owner, _tools("knowledge_search"), chat=away)
assert "Home contracts" not in abroad
# --- The tools: what a model can go and get ----------------------------------------
def test_the_tool_context_carries_the_chats_group(db, owner, chats):
home, away = chats
assert _context(db, owner, home).data_group == DEFAULT_GROUP
assert _context(db, owner, away).data_group == HOSTED
async def test_a_note_in_another_group_cannot_be_searched_or_fetched(db, owner, chats):
home, away = chats
secret = notes.create(db, owner=owner, title="Home only", body="mallorn", group=DEFAULT_GROUP)
context = _context(db, owner, away)
found = await _run(context, "notes_search", query="mallorn")
assert found.event["results"] == []
fetched = await _run(context, "notes_get", id=secret.id)
assert fetched.event["status"] == "error"
edited = await _run(context, "notes_edit", id=secret.id, body="gone")
assert edited.event["status"] == "error"
async def test_a_note_a_model_writes_lands_in_its_group(db, owner, chats):
home, away = chats
outcome = await _run(_context(db, owner, away), "notes_create", title="t", body="b")
assert outcome.event["status"] == "ok"
note = notes.get(db, outcome.event["results"][0]["id"], owner)
assert note.data_group_id == HOSTED
async def test_a_memory_is_recorded_in_the_group_and_forgotten_only_there(db, owner, chats):
home, away = chats
memories.add(db, owner=owner, content="Keep this at home.", group=DEFAULT_GROUP)
await _run(_context(db, owner, away), "memory_add", content="Hosted fact.")
assert [m.content for m in memories.all_for(db, owner, HOSTED)] == ["Hosted fact."]
await _run(_context(db, owner, away), "memory_forget", content="Keep this at home.")
assert [m.content for m in memories.all_for(db, owner, DEFAULT_GROUP)] == [
"Keep this at home."
]
# The same call from the memory's own group does forget it, so the refusal
# above is the group and not a mistyped argument.
await _run(_context(db, owner, home), "memory_forget", content="Keep this at home.")
db.expire_all()
assert memories.all_for(db, owner, DEFAULT_GROUP) == []
def test_the_same_fact_in_two_groups_is_two_memories(db, owner):
first = memories.add(db, owner=owner, content="Same.", group=DEFAULT_GROUP)
second = memories.add(db, owner=owner, content="Same.", group=HOSTED)
assert first.id != second.id
async def test_a_document_in_another_group_cannot_be_fetched_by_id(db, owner, chats):
home, away = chats
base = documents.create_base(db, owner=owner, name="Home")
document = documents.store_upload(
db, owner=owner, payload=b"The mallorn is golden.", filename="a.txt", base=base
)
context = _context(db, owner, away)
assert (await _run(context, "knowledge_search", query="mallorn")).event["results"] == []
assert (await _run(context, "knowledge_get", id=document.id)).event["status"] == "error"
async def test_a_report_in_another_group_cannot_be_read(db, owner, chats):
home, away = chats
report = reports.create(db, owner=owner, title="Home report", body="mallorn", unread=False)
context = _context(db, owner, away)
assert (await _run(context, "report_get", id=report.id)).event["status"] == "error"
async def test_a_skill_in_another_group_cannot_be_fetched(db, owner, chats):
home, away = chats
skills.create(db, owner=owner, name="home-skill", description="Home.", body="SECRET")
outcome = await _run(_context(db, owner, away), "skill_get", name="home-skill")
assert "SECRET" not in outcome.content
def test_a_skill_name_taken_in_another_group_says_so(db, owner):
skills.create(db, owner=owner, name="shared-name", description="d", body="b")
with pytest.raises(skills.SkillError, match="another data group"):
skills.create(
db, owner=owner, name="shared-name", description="d", body="b", group=HOSTED
)
async def test_an_impression_is_written_under_the_groups_key(db, owner, chats):
home, away = chats
await _run(_context(db, owner, away), "impression_write", content="Terse.")
row = db.scalar(select(Impression))
assert row.model_key == personas.key_for("cloud-model", HOSTED)
def test_each_group_gets_its_own_default_base(db, owner, chats):
home = documents.default_base(db, owner)
away = documents.default_base(db, owner, HOSTED)
assert home.id != away.id
assert away.data_group_id == HOSTED
assert home.name != away.name
+273
View File
@@ -0,0 +1,273 @@
"""The services that read a group's data, and the screens that arrange groups.
The embedder is sent the full text of everything it indexes and the reviewer is
sent every picture with its prompt, so a group can name its own of each and
falls back to the instance's when it names none. Then the three places groups
are arranged: the admin page, the person's own settings, and the library.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import (
DEFAULT_GROUP,
Connection,
DataGroup,
Model,
Note,
User,
)
from lembas.services import data_groups, settings_store
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
from lembas.services.images import tool as image_tool
from lembas.services.library import indexing, notes
HOSTED = "hosted"
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def setup(db, owner):
db.add(DataGroup(id=HOSTED, name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id=HOSTED,
)
db.add_all([local, cloud])
db.flush()
db.add_all(
[
Model(connection_id=local.id, model_id="local-embed",
capabilities_json={"embeddings": True}),
Model(connection_id=cloud.id, model_id="cloud-embed",
capabilities_json={"embeddings": True}),
Model(connection_id=local.id, model_id="local-eye",
capabilities_json={"vision": True}),
Model(connection_id=cloud.id, model_id="cloud-eye",
capabilities_json={"vision": True}),
]
)
db.commit()
return local, cloud
def _plain_user(db) -> User:
user = User(email="sam@shire.test", name="Sam", password_hash="x", role="user")
db.add(user)
db.commit()
return user
# --- The embedder -----------------------------------------------------------------
def test_a_group_without_its_own_embedder_uses_the_instances(db, setup):
settings_store.update(db, {"embedding_model_id": "local-embed"}, key=settings_store.EXTRACTION)
assert indexing.embedder(db, HOSTED).model_id == "local-embed"
def test_a_group_with_its_own_embedder_uses_that(db, setup):
settings_store.update(db, {"embedding_model_id": "local-embed"}, key=settings_store.EXTRACTION)
group = data_groups.get(db, HOSTED)
group.embedding_model_id = "cloud-embed"
db.commit()
assert indexing.embedder(db, HOSTED).model_id == "cloud-embed"
assert indexing.embedder(db, DEFAULT_GROUP).model_id == "local-embed"
def test_a_record_is_indexed_by_its_own_groups_embedder(db, owner, setup):
group = data_groups.get(db, HOSTED)
group.embedding_model_id = "cloud-embed"
db.commit()
note = notes.create(db, owner=owner, title="t", body="b", group=HOSTED)
assert indexing.group_of_row(db, note) == HOSTED
assert indexing.embedder(db, indexing.group_of_row(db, note)).model_id == "cloud-embed"
def test_any_configured_counts_a_groups_own_embedder(db, setup):
assert indexing.any_configured(db) is False
group = data_groups.get(db, HOSTED)
group.embedding_model_id = "cloud-embed"
db.commit()
assert indexing.any_configured(db) is True
# --- The reviewer -------------------------------------------------------------------
def test_the_reviewer_is_the_groups_own_when_it_names_one(db, setup):
group = data_groups.get(db, HOSTED)
group.review_model_id = "cloud-eye"
db.commit()
config = {"review_enabled": True, "review_model_id": "local-eye"}
hosted = tools_service.ToolContext(owner_id="x", image_config=config, data_group=HOSTED)
home = tools_service.ToolContext(owner_id="x", image_config=config)
assert image_tool._reviewer(hosted)[1] == "cloud-eye"
assert image_tool._reviewer(home)[1] == "local-eye"
# --- The admin page ---------------------------------------------------------------------
def test_the_admin_page_lists_groups_and_creates_one(client, db, setup):
assert "Hosted" in client.get("/admin/data-groups").text
response = client.post("/admin/data-groups", data={"name": "Work"}, follow_redirects=False)
assert response.status_code == 303
assert db.scalar(select(DataGroup).where(DataGroup.name == "Work")) is not None
def test_putting_a_connection_into_a_group_and_taking_it_out(client, db, setup):
local, cloud = setup
client.post(
f"/admin/data-groups/{HOSTED}",
data={"name": "Hosted", "connections_sent": "1", "connection_ids": [local.id]},
)
db.expire_all()
assert db.get(Connection, local.id).data_group_id == HOSTED
# Unticked: back to the default group, never to "no group".
assert db.get(Connection, cloud.id).data_group_id == DEFAULT_GROUP
def test_the_detail_page_flags_a_service_in_another_group(client, db, setup):
settings_store.update(db, {"embedding_model_id": "local-embed"}, key=settings_store.EXTRACTION)
page = client.get(f"/admin/data-groups/{HOSTED}").text
assert "its connection is in Default" in page
def test_deleting_a_group_in_use_is_refused_with_the_reason(client, db, setup):
response = client.post(f"/admin/data-groups/{HOSTED}/delete", follow_redirects=True)
assert "connections" in response.text
assert data_groups.get(db, HOSTED) is not None
def test_the_admin_page_is_for_administrators(client, db, setup, owner):
owner.role = "user"
db.commit()
assert client.get("/admin/data-groups").status_code in (303, 403, 404)
def test_the_connection_form_sets_the_group(client, db, setup):
local, _ = setup
client.post(
f"/admin/connections/{local.id}",
data={"name": "Local", "base_url": local.base_url, "data_group_id": HOSTED},
)
db.expire_all()
assert db.get(Connection, local.id).data_group_id == HOSTED
# --- The person's own settings -------------------------------------------------------------
def test_remapping_for_oneself_needs_the_permission(client, db, setup, owner):
local, _ = setup
owner.role = "user"
db.commit()
client.post("/api/preferences/data-groups", data={f"group__{local.id}": HOSTED})
db.expire_all()
assert data_groups.personal_map(db.get(User, owner.id)) == {}
settings_store.update(db, {"default_permissions": {data_groups.PERMISSION: True}})
client.post("/api/preferences/data-groups", data={f"group__{local.id}": HOSTED})
db.expire_all()
user = db.get(User, owner.id)
assert data_groups.personal_map(user) == {local.id: HOSTED}
assert data_groups.for_connection(db, user, local.id) == HOSTED
def test_a_personal_group_is_made_and_seen_only_by_its_owner(client, db, setup, owner):
client.post("/api/preferences/data-groups/new", data={"name": "Private"})
group = db.scalar(select(DataGroup).where(DataGroup.name == "Private"))
assert group.owner_id == owner.id
other = _plain_user(db)
assert group.id not in {g.id for g in data_groups.usable(db, other)}
def test_the_data_tab_appears_once_there_is_a_choice(client, db, setup):
page = client.get("/settings").text
assert 'id="tab-data"' in page
assert "Which group each connection reads" in page
# --- The library --------------------------------------------------------------------------------
def test_a_note_is_created_in_the_chosen_group(client, db, setup):
client.post("/api/library/notes", data={"title": "n", "body": "b", "data_group_id": HOSTED})
assert db.scalar(select(Note)).data_group_id == HOSTED
def test_a_group_the_person_may_not_use_falls_back_to_the_default(client, db, setup, owner):
other = _plain_user(db)
db.add(DataGroup(id="theirs", name="Theirs", owner_id=other.id))
db.commit()
client.post("/api/library/notes", data={"title": "n", "body": "b", "data_group_id": "theirs"})
assert db.scalar(select(Note)).data_group_id == DEFAULT_GROUP
def test_moving_a_note_needs_the_permission(client, db, setup, owner):
note = notes.create(db, owner=owner, title="n", body="b")
owner.role = "user"
db.commit()
client.post(f"/api/library/notes/{note.id}", data={"title": "n", "body": "b",
"data_group_id": HOSTED})
db.expire_all()
assert db.get(Note, note.id).data_group_id == DEFAULT_GROUP
settings_store.update(db, {"default_permissions": {data_groups.PERMISSION: True,
"library.use": True}})
client.post(f"/api/library/notes/{note.id}", data={"title": "n", "body": "b",
"data_group_id": HOSTED})
db.expire_all()
assert db.get(Note, note.id).data_group_id == HOSTED
def test_the_notes_list_filters_by_group(client, db, setup, owner):
notes.create(db, owner=owner, title="Home note", body="b")
notes.create(db, owner=owner, title="Hosted note", body="b", group=HOSTED)
page = client.get(f"/library/notes?group={HOSTED}").text
assert "Hosted note" in page and "Home note" not in page
def test_a_single_group_instance_shows_nothing_about_groups(client, db, owner):
"""The ordinary instance: no chip, no select, no tab -- for anybody who could
not make a personal group either. An administrator can, so the tab is theirs."""
notes.create(db, owner=owner, title="n", body="b")
owner.role = "user"
db.commit()
assert 'name="data_group_id"' not in client.get("/library/notes/new").text
assert 'id="tab-data"' not in client.get("/settings").text
# --- Two embedders of the same width -----------------------------------------------------
def test_a_query_skips_chunks_another_model_of_the_same_width_made(db, owner):
"""Width cannot tell two 1024-wide models apart, and with an embedder per
group two of them on one instance is ordinary. The query says which model
made it, and only that model's chunks are scored."""
from lembas.db.models import CHUNK_NOTE, Chunk
from lembas.services.library import chunks as chunk_service
from lembas.services.library import retrieval
ours = notes.create(db, owner=owner, title="Ours", body="x")
theirs = notes.create(db, owner=owner, title="Theirs", body="y")
for note, model_id, vector in ((ours, "embed-a", [0.6, 0.8]), (theirs, "embed-b", [1.0, 0.0])):
db.add(
Chunk(
owner_id=owner.id,
resource_type=CHUNK_NOTE,
resource_id=note.id,
ordinal=0,
text="t",
vector=chunk_service.pack(vector),
dims=2,
model_id=model_id,
)
)
db.commit()
query = retrieval.QueryVector([1.0, 0.0])
query.model_id = "embed-a"
assert [hit.id for hit in retrieval.semantic_ids(db, CHUNK_NOTE, query)] == [ours.id]
# A plain list keeps the old width-only behaviour, and the closer vector wins.
plain = retrieval.semantic_ids(db, CHUNK_NOTE, [1.0, 0.0])
assert [hit.id for hit in plain][0] == theirs.id
+293
View File
@@ -0,0 +1,293 @@
"""Data groups: how one is resolved, the startup sweep, deleting one, and upgrading.
What a group *isolates* is `test_data_group_isolation.py`; how a chat is pinned to
one is `test_data_group_chat_pin.py`. This file is the machinery underneath both:
the resolution order, which lives in one function and must keep living there, and
the upgrade from a database that has never heard of groups.
"""
from __future__ import annotations
import pytest
from sqlalchemy import inspect, select, text
from lembas.db.migrations import sync_schema
from lembas.db.models import (
DEFAULT_GROUP,
Chat,
Connection,
DataGroup,
Memory,
Model,
Note,
Persona,
User,
)
from lembas.db.session import get_engine
from lembas.services import data_groups, personas, settings_store
from lembas.services.crypto import encrypt
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def reader(db, registered) -> User:
"""A second account, not an administrator, so permissions actually apply."""
user = User(email="sam@shire.test", name="Sam", password_hash="x", role="user")
db.add(user)
db.commit()
return user
@pytest.fixture
def two(db) -> tuple[Connection, Connection]:
"""A local connection in the default group and a hosted one in its own."""
db.add(DataGroup(id="hosted", name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id="hosted",
)
db.add_all([local, cloud])
db.flush()
db.add_all(
[
Model(connection_id=local.id, model_id="local-model", position=0),
Model(connection_id=cloud.id, model_id="cloud-model", position=1),
]
)
db.commit()
return local, cloud
def _grant_manage(db, user: User) -> None:
settings_store.update(db, {"default_permissions": {data_groups.PERMISSION: True}})
# --- Resolution --------------------------------------------------------------------
def test_a_connection_with_no_group_is_in_the_default_one(db, two, reader):
local, _ = two
assert data_groups.for_connection(db, reader, local.id) == DEFAULT_GROUP
def test_the_administrators_choice_applies_to_everybody(db, two, reader):
_, cloud = two
assert data_groups.for_connection(db, reader, cloud.id) == "hosted"
assert data_groups.for_connection(db, None, cloud.id) == "hosted"
def test_a_personal_mapping_needs_the_permission(db, two, reader):
"""Stored and ignored without `data.manage`: taking the permission away puts a
person back on the instance's arrangement without anybody clearing anything."""
local, _ = two
reader.settings_json = {data_groups.SETTING_KEY: {local.id: "hosted"}}
db.commit()
assert data_groups.for_connection(db, reader, local.id) == DEFAULT_GROUP
_grant_manage(db, reader)
assert data_groups.for_connection(db, reader, local.id) == "hosted"
def test_a_mapping_to_somebody_elses_personal_group_is_ignored(db, two, reader, owner):
local, _ = two
db.add(DataGroup(id="theirs", name="Theirs", owner_id=owner.id))
reader.settings_json = {data_groups.SETTING_KEY: {local.id: "theirs"}}
db.commit()
_grant_manage(db, reader)
assert data_groups.for_connection(db, reader, local.id) == DEFAULT_GROUP
def test_a_connection_naming_a_deleted_group_falls_back_to_the_default(db, two, reader):
_, cloud = two
cloud.data_group_id = "gone"
db.commit()
assert data_groups.for_connection(db, reader, cloud.id) == DEFAULT_GROUP
def test_a_pair_without_a_connection_resolves_through_the_model(db, two, reader):
assert data_groups.for_pair(db, reader, "cloud-model") == "hosted"
assert data_groups.for_pair(db, reader, "local-model") == DEFAULT_GROUP
def test_a_chat_keeps_the_group_it_was_stamped_with(db, two, reader):
"""Derived once, then read. A model moved afterwards does not carry the chat."""
_, cloud = two
chat = Chat(user_id=reader.id, model_id="cloud-model", connection_id=cloud.id)
db.add(chat)
db.commit()
assert data_groups.for_chat(db, chat) == "hosted"
db.commit()
cloud.data_group_id = DEFAULT_GROUP
db.commit()
assert data_groups.for_chat(db, chat) == "hosted"
def test_usable_groups_are_the_instances_and_ones_own(db, two, reader, owner):
db.add_all(
[
DataGroup(id="mine", name="Mine", owner_id=reader.id),
DataGroup(id="not-mine", name="Not mine", owner_id=owner.id),
]
)
db.commit()
ids = {group.id for group in data_groups.usable(db, reader)}
assert ids == {DEFAULT_GROUP, "hosted", "mine"}
def test_one_group_means_there_is_nothing_to_choose(db, reader):
assert data_groups.several(db, reader) is False
db.add(DataGroup(id="second", name="Second"))
db.commit()
assert data_groups.several(db, reader) is True
# --- Deleting ------------------------------------------------------------------------
def test_the_default_group_cannot_be_deleted(db):
with pytest.raises(ValueError, match="default group"):
data_groups.delete(db, data_groups.ensure_default(db))
def test_a_group_with_records_in_it_cannot_be_deleted(db, reader):
group = DataGroup(id="busy", name="Busy")
db.add(group)
db.add(Note(owner_id=reader.id, title="n", body="b", data_group_id="busy"))
db.commit()
with pytest.raises(ValueError, match="1 notes"):
data_groups.delete(db, group)
def test_a_group_a_connection_is_in_cannot_be_deleted(db, two):
with pytest.raises(ValueError, match="connections"):
data_groups.delete(db, data_groups.get(db, "hosted"))
def test_deleting_an_empty_group_clears_every_mapping_to_it(db, reader):
group = DataGroup(id="empty", name="Empty")
db.add(group)
reader.settings_json = {data_groups.SETTING_KEY: {"some-connection": "empty"}}
db.commit()
data_groups.delete(db, group)
db.refresh(reader)
assert data_groups.personal_map(reader) == {}
assert data_groups.get(db, "empty") is None
# --- The sweep -------------------------------------------------------------------------
def test_the_sweep_puts_old_rows_in_the_default_group(db, reader):
db.add(Memory(owner_id=reader.id, content="old"))
db.commit()
assert db.scalar(select(Memory)).data_group_id is None
data_groups.sweep_unassigned(db)
assert db.scalar(select(Memory)).data_group_id == DEFAULT_GROUP
def test_the_sweep_gives_a_chat_its_models_group(db, two, reader):
"""Not simply the default: a chat some path created without stamping one
belongs where its model is, or its next turn is refused as a moved chat."""
chat = Chat(user_id=reader.id, model_id="cloud-model")
db.add(chat)
db.commit()
data_groups.sweep_unassigned(db)
db.refresh(chat)
assert chat.data_group_id == "hosted"
def test_a_row_the_sweep_has_not_reached_still_counts_as_default(db, reader):
db.add(Note(owner_id=reader.id, title="old", body=""))
db.commit()
found = db.scalars(select(Note).where(data_groups.condition(Note, DEFAULT_GROUP))).all()
assert [note.title for note in found] == ["old"]
# --- Personalities: namespaced, because the constraint cannot change ------------------
def test_the_default_group_keeps_the_bare_model_id():
assert personas.key_for("gpt-oss", DEFAULT_GROUP) == "gpt-oss"
assert personas.key_for("gpt-oss", None) == "gpt-oss"
def test_another_group_gets_its_own_key_and_splits_back():
key = personas.key_for("gpt-oss", "hosted")
assert key != "gpt-oss"
assert personas.split_key(key) == ("gpt-oss", "hosted")
assert personas.split_key("gpt-oss") == ("gpt-oss", DEFAULT_GROUP)
def test_a_group_falls_back_to_the_administrators_default(db, reader):
"""The admin default is keyed bare and reaches every group, until the model has
written one of its own with this person in that group."""
db.add(Persona(model_key="gpt-oss", owner_id=None, content="the default"))
db.commit()
key = personas.key_for("gpt-oss", "hosted")
assert personas.block(db, key, reader) == "the default"
personas.write(db, model_key=key, owner=reader, content="hosted self", author="model")
assert personas.block(db, key, reader) == "hosted self"
assert personas.block(db, "gpt-oss", reader) == "the default"
# --- Upgrading from 1.9.1 -----------------------------------------------------------------
# What 1.10.0 added: one table, and one column on each of these. Taken from the
# models rather than invented, and `test_the_recorded_shape_is_still_real`
# below is what stops the list rotting.
NEW_TABLE = "data_groups"
GROUPED_TABLES = (
"chats",
"connections",
"knowledge_bases",
"memories",
"notes",
"reports",
"schedules",
"skills",
)
def _rollback_to_1_9_1(engine) -> None:
with engine.begin() as connection:
connection.execute(text(f"DROP TABLE IF EXISTS {NEW_TABLE}"))
for table in GROUPED_TABLES:
connection.execute(text(f"ALTER TABLE {table} DROP COLUMN data_group_id"))
def test_the_recorded_shape_is_still_real():
from lembas.db.base import Base
grouped = {
table.name for table in Base.metadata.sorted_tables if "data_group_id" in table.columns
}
assert grouped == set(GROUPED_TABLES)
def test_a_1_9_1_database_with_data_upgrades(db, reader):
engine = get_engine()
db.add(Memory(owner_id=reader.id, content="kept"))
db.add(Persona(model_key="gpt-oss", owner_id=reader.id, content="mine"))
db.commit()
db.close()
_rollback_to_1_9_1(engine)
assert "data_group_id" not in {c["name"] for c in inspect(engine).get_columns("memories")}
changes = sync_schema(engine)
assert any(NEW_TABLE in change for change in changes)
for table in GROUPED_TABLES:
assert "data_group_id" in {c["name"] for c in inspect(engine).get_columns(table)}
# A nullable column arrives empty; the sweep is what files it.
memory = db.scalar(select(Memory))
assert memory.data_group_id is None
data_groups.sweep_unassigned(db)
db.refresh(memory)
assert memory.data_group_id == DEFAULT_GROUP
# The person's personality was keyed bare, and the default group still reads it.
user = db.get(User, reader.id)
assert personas.block(db, personas.key_for("gpt-oss", DEFAULT_GROUP), user) == "mine"
assert data_groups.get(db, DEFAULT_GROUP) is not None
+22
View File
@@ -582,3 +582,25 @@ def test_an_endpoint_with_no_props_leaves_the_list_alone(client, db, registered,
db.expire_all()
assert db.get(Model, model.id).reasoning_efforts == ["low", "high"]
def test_a_new_chat_offers_the_models_own_efforts(client: TestClient, db, registered):
"""`/chat?model=` offered the generic three whatever the model took.
On Bonsai (low, medium, xhigh, default xhigh) that drew `high`, which it
rejects, and no `xhigh`, so the configured default was not an option and
the picker fell through to "off". The chat created from that screen got
`xhigh` anyway, so the control said one thing and the first reply did
another. Reported from the live instance.
"""
model = _model(db)
model.model_id = "bonsai"
model.reasoning_efforts = ["low", "medium", "xhigh"]
model.params_json = {"reasoning_effort": "xhigh"}
db.commit()
html = client.get("/chat?model=bonsai").text.replace("\n", "").replace(" ", "")
assert '<option value="xhigh" selected>' in html
assert '<option value="high"' not in html
assert '<option value="off" selected' not in html
+342
View File
@@ -0,0 +1,342 @@
"""Helpers on another model: capacity, designations, the choice, and the screens.
`test_subagent.py` covers what a helper is *not* given; this covers which model
it runs on. The generation loop is stubbed the way that file stubs it, and the
tool is always reached through `resolve_tools`, because what may be run is what
was offered.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import select
from lembas.db.models import (
Chat,
ChatHelper,
Connection,
DataGroup,
HelperDesignation,
Model,
User,
)
from lembas.services import helpers, settings_store, talk
from lembas.services import subagent as subagent_service
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def setup(db, registered):
"""Helpers on, three models on one local connection and one hosted model."""
settings_store.update(db, {"enabled": True}, key=settings_store.SUBAGENTS)
settings_store.update(db, {"default_permissions": {"tools.subagent": True}})
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(name="Cloud", base_url="http://127.0.0.1:2", api_key_encrypted=encrypt(""))
db.add_all([local, cloud])
db.flush()
tools = {"tools": True}
db.add_all(
[
Model(connection_id=local.id, model_id="gpt-oss", capabilities_json=tools,
position=0, params_json={"reasoning_effort": "high"}),
Model(connection_id=local.id, model_id="qwen35", capabilities_json=tools,
position=1),
Model(connection_id=local.id, model_id="bonsai", capabilities_json=tools,
position=2, params_json={"reasoning_effort": "xhigh"},
reasoning_efforts=["low", "medium", "xhigh"]),
Model(connection_id=cloud.id, model_id="deepseek", capabilities_json=tools,
position=3),
]
)
db.commit()
subagent_service.clear()
yield {"local": local, "cloud": cloud}
subagent_service.clear()
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _chat(db, model_id="gpt-oss", connection=None) -> Chat:
connection_id = connection.id if connection else db.scalar(
select(Model.connection_id).where(Model.model_id == model_id)
)
chat = Chat(user_id=_user(db).id, title="t", model_id=model_id, connection_id=connection_id)
db.add(chat)
db.commit()
return chat
def _row(db, model_id) -> Model:
return db.scalar(select(Model).where(Model.model_id == model_id))
def _ids(found) -> list[str]:
return [c.model.model_id for c in found]
def _spawn(monkeypatch):
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER
from lembas.db.session import session_scope
from lembas.services import chat as chat_service
seen: dict[str, str] = {}
async def fake_wake(chat_id: str, content: str, *, model_id: str = "") -> str:
seen["chat_id"] = chat_id
with session_scope() as db:
child = db.get(Chat, chat_id)
chat_service.create_message(db, child, ROLE_USER, content)
reply = chat_service.create_message(db, child, ROLE_ASSISTANT, "Done.")
return reply.id
monkeypatch.setattr("lembas.services.wake.wake_chat", fake_wake)
monkeypatch.setattr("lembas.services.generation.running_for", lambda chat_id: None)
return seen
async def _run(db, chat: Chat, args: dict):
from lembas.services import generation as generation_service
class _Fake:
subagents = 0
user = _user(db)
resolved = tools_service.resolve_tools(db, chat, user)
context = tools_service.context_for(db, user, chat, tools=resolved)
original = generation_service.running_for
generation_service.running_for = (
lambda chat_id: _Fake() if chat_id == chat.id else original(chat_id)
)
try:
return await tools_service.run_tool(context, "subagent_run", json.dumps(args))
finally:
generation_service.running_for = original
def _schema(db, chat):
resolved = tools_service.resolve_tools(db, chat, _user(db))
tool = resolved.by_name.get("subagent_run")
return tool.parameters["properties"] if tool else None
# --- Capacity ---------------------------------------------------------------------------
def test_by_default_a_model_is_its_own_helper_and_the_tool_is_unchanged(db):
"""Both switches off, nothing designated: exactly the 1.11 behaviour."""
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
assert "model" not in _schema(db, chat)
def test_a_single_session_model_cannot_help_itself(db):
_row(db, "gpt-oss").single_session = True
db.commit()
chat = _chat(db)
assert helpers.candidates(db, chat, _user(db)) == []
# Nothing to send a helper to, so the tool is not offered at all.
assert _schema(db, chat) is None
def test_one_model_at_a_time_forbids_another_model_on_that_connection(db, setup):
main, other = _row(db, "gpt-oss"), _row(db, "qwen35")
assert helpers.capacity_refusal(main, other) == ""
setup["local"].one_model_at_a_time = True
db.commit()
assert "holds one model at a time" in helpers.capacity_refusal(main, other)
# ...but itself, and a model on another connection, are fine.
assert helpers.capacity_refusal(main, main) == ""
assert helpers.capacity_refusal(main, _row(db, "deepseek")) == ""
# --- Designations ------------------------------------------------------------------------
def test_an_offered_designation_joins_the_candidates_and_the_enum(db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
assert _schema(db, chat)["model"]["enum"] == ["gpt-oss", "qwen35"]
def test_a_by_hand_designation_is_used_only_once_added_to_the_chat(db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
db.commit()
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
def test_designations_are_per_main_model(db):
helpers.set_designation(db, None, "bonsai", "qwen35", offer=True)
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
def test_a_persons_designations_need_the_permission_and_replace_the_instances(db):
user = _user(db)
user.role = "user"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
helpers.set_designation(db, user, "gpt-oss", "qwen35", offer=False)
assert [d.owner_id for d in helpers.designations(db, user, "gpt-oss")] == [None]
settings_store.update(
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
)
rows = helpers.designations(db, user, "gpt-oss")
assert [(d.owner_id, d.offer) for d in rows] == [(user.id, False)]
def test_the_talk_rules_filter_designations(db):
user = _user(db)
user.role = "user"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
talk.set_rule(db, None, "gpt-oss", "qwen35", "deny")
assert _ids(helpers.candidates(db, _chat(db), user)) == ["gpt-oss"]
def test_another_data_group_needs_a_rule(db, setup):
user = _user(db)
user.role = "user"
db.add(DataGroup(id="hosted", name="Hosted"))
setup["cloud"].data_group_id = "hosted"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "deepseek", offer=True)
assert "deepseek" not in _ids(helpers.candidates(db, _chat(db), user))
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
assert "deepseek" in _ids(helpers.candidates(db, _chat(db), user))
# --- The choice, at call time -------------------------------------------------------------
async def test_a_named_helper_runs_on_that_model_with_its_own_effort(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
chat = _chat(db)
chat.params_json = {"reasoning_effort": "high"}
db.commit()
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
outcome = await _run(db, chat, {"task": "Check it.", "model": "bonsai"})
assert outcome.event["status"] == "ok"
child = db.get(Chat, seen["chat_id"])
assert child.model_id == "bonsai"
# Bonsai's own default, never the parent's `high`, which it would refuse.
assert child.params_json["reasoning_effort"] == "xhigh"
assert child.parent_chat_id == chat.id
async def test_no_model_named_is_the_main_model_itself(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _run(db, _chat(db), {"task": "Check it."})
assert db.get(Chat, seen["chat_id"]).model_id == "gpt-oss"
async def test_a_model_that_is_not_a_candidate_is_refused_with_the_list(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
_spawn(monkeypatch)
outcome = await _run(db, _chat(db), {"task": "Check it.", "model": "deepseek"})
assert outcome.event["status"] == "error"
assert "bonsai" in outcome.content
def test_without_itself_the_default_is_the_first_hand_added_helper(db):
_row(db, "gpt-oss").single_session = True
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
db.commit()
chat = _chat(db)
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
db.commit()
model, refusal = helpers.choose(db, chat, _user(db), "")
assert model.model_id == "qwen35" and refusal == ""
def test_the_harness_lists_where_a_helper_can_run(db):
from lembas.services import harness
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
chat = _chat(db)
resolved = tools_service.resolve_tools(db, chat, _user(db))
text = harness.compose(db, _user(db), resolved.schemas, chat=chat)
assert "Where a helper can run" in text
assert "qwen35 (qwen35)" in text
# --- The screens -----------------------------------------------------------------------------
def test_the_model_page_designates_a_helper(client, db):
model = _row(db, "gpt-oss")
client.post(f"/admin/models/{model.id}/helpers", data={"helper_model": "qwen35",
"offer": "true"})
row = db.scalar(select(HelperDesignation))
assert (row.main_model, row.helper_model, row.offer) == ("gpt-oss", "qwen35", True)
assert "offered to the model" in client.get(f"/admin/models/{model.id}/edit").text
def test_the_model_page_saves_single_session(client, db):
model = _row(db, "gpt-oss")
page = client.get(f"/admin/models/{model.id}/edit").text
assert 'name="single_session"' in page
client.post(f"/admin/models/{model.id}", data={"display_name": "", "enabled": "true",
"public": "true", "single_session": "true"})
db.expire_all()
assert _row(db, "gpt-oss").single_session is True
def test_the_connection_form_saves_one_model_at_a_time(client, db, setup):
local = setup["local"]
client.post(f"/admin/connections/{local.id}", data={"name": "Local",
"base_url": local.base_url, "one_model_at_a_time": "true"})
db.expire_all()
assert db.get(Connection, local.id).one_model_at_a_time is True
def test_the_composer_picker_adds_a_helper_by_hand(client, db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
chat = _chat(db)
page = client.get(f"/chat/{chat.id}").text
assert 'name="helper_model_ids"' in page and 'id="helpers-form"' in page
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": ["qwen35"]})
assert [h.model_id for h in db.scalars(select(ChatHelper))] == ["qwen35"]
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": [""]})
db.expire_all()
assert list(db.scalars(select(ChatHelper))) == []
def test_a_person_adds_their_own_designation_with_the_permission(client, db):
user = _user(db)
user.role = "user"
db.commit()
data = {"main_model": "gpt-oss", "helper_model": "qwen35", "offer": "true"}
client.post("/api/preferences/helpers", data=data)
assert list(db.scalars(select(HelperDesignation))) == []
settings_store.update(
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
)
client.post("/api/preferences/helpers", data=data)
assert db.scalar(select(HelperDesignation)).owner_id == user.id
# --- Upgrading ---------------------------------------------------------------------------------
def test_an_upgraded_database_reads_both_switches_as_off(db, setup):
"""Both are NOT NULL booleans, which `sync_schema` backfills with False -- which
is why they name the restrictive state: False has to mean "as before"."""
from sqlalchemy import text
from lembas.db.migrations import sync_schema
from lembas.db.session import get_engine
engine = get_engine()
db.close()
with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS chat_helpers"))
connection.execute(text("DROP TABLE IF EXISTS helper_designations"))
connection.execute(text("ALTER TABLE models DROP COLUMN single_session"))
connection.execute(text("ALTER TABLE connections DROP COLUMN one_model_at_a_time"))
sync_schema(engine)
assert _row(db, "gpt-oss").single_session is False
assert db.get(Connection, setup["local"].id).one_model_at_a_time is False
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
+16
View File
@@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared():
for token in ("--terminal-width-min", "--canvas-width-min"):
assert f"{token}:" in TOKENS
assert f"var({token})" in APP_CSS
def test_the_topbar_model_menu_belongs_to_the_bar_on_a_phone():
"""1.8.3. Anchored to the picker, the menu opened `right: 0` of a button that
sits mid-bar with the panel buttons to its right, so on a 390px phone a 24rem
menu started 132px left of the screen and every model's name was cut off.
Below the phone breakpoint the picker gives up `position`, which makes the bar
the containing block, and the menu is pinned between the bar's two edges."""
body = _media_body(APP_CSS, "48rem")
assert re.search(r"\.topbar\s*\{\s*position:\s*relative", body)
assert re.search(r"\.topbar__actions \.picker\s*\{\s*position:\s*static", body)
menu = re.search(r"\.topbar__actions \.picker__menu\s*\{([^}]*)\}", body)
assert menu, "the topbar's menu is not placed on a phone"
for declaration in ("left:", "right:", "width: auto"):
assert declaration in menu.group(1), f"{declaration} missing from the phone menu"
+132
View File
@@ -0,0 +1,132 @@
"""The two places a person reads the list of models: the chat's picker and /settings.
Until 1.8.2 both printed every capability switch as a tag -- twenty `tool_*`
entries per model -- and in /settings the tags sat beside the name and squeezed
it to a word per line underneath them. The picker is now name, context window
and an eye for vision; /settings keeps the tags, underneath.
The layout is by construction (the rows share the list's column tracks), and
that only holds while every row emits every slot, so a model with no context
length and no vision is asserted to still have both cells.
"""
from __future__ import annotations
import re
import pytest
from lembas.db.models import Connection, Model
from lembas.services.crypto import encrypt
from lembas.web.templating import context_size
@pytest.mark.parametrize(
("tokens", "shown"),
[
(None, ""),
(0, ""),
(512, "512"),
(4096, "4K"),
(32768, "33K"),
(131072, "131K"),
(262144, "262K"),
(1_000_000, "1M"),
(1_048_576, "1M"),
(2_000_000, "2M"),
(1_500_000, "1.5M"),
],
)
def test_a_context_window_is_shortened_the_way_it_is_quoted(tokens, shown):
assert context_size(tokens) == shown
@pytest.fixture
def models(db, registered):
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add_all(
[
Model(
connection_id=connection.id,
model_id="sees",
display_name="Sees",
position=0,
context_length=131072,
capabilities_json={"vision": True, "tools": True, "tool_fetch": True},
),
Model(
connection_id=connection.id,
model_id="blind",
display_name="Blind",
position=1,
capabilities_json={"tools": True, "tool_fetch": True},
),
]
)
db.commit()
def _options(html: str) -> dict[str, str]:
"""The model picker's options by model id -- not the @-mention menu's."""
found = {}
for body in re.findall(r'<button class="picker__option\b.*?</button>', html, re.S):
value = re.search(r'data-picker-value="([^"]+)"', body)
if value:
found[value.group(1)] = body
return found
def test_the_picker_shows_name_context_and_vision_and_no_tags(client, models):
html = client.get("/chat?model=sees").text
options = _options(html)
assert set(options) == {"sees", "blind"}
sees = options["sees"]
assert "Sees" in sees
assert "CTX 131K" in sees
assert "#i-eye" in sees
assert 'class="tag"' not in sees
assert "tool_fetch" not in sees
def test_every_picker_row_emits_every_slot(client, models):
blind = _options(client.get("/chat?model=sees").text)["blind"]
assert "#i-eye" not in blind
assert "CTX" not in blind
slots = ("picker__avatar", "picker__option-name", "model-ctx", "model-vision", "picker__tick")
for slot in slots:
assert slot in blind, slot
def test_settings_lists_the_models_with_their_tags_below_the_name(client, models):
html = client.get("/settings").text
listing = html[html.index('class="model-list model-list--models"'):]
listing = listing[: listing.index("</ul>")]
assert listing.count('class="model-list__item"') == 2
assert "CTX 131K" in listing
assert listing.count("#i-eye") == 1
assert listing.count('class="model-list__more model-list__tags"') == 2
assert "tool_fetch" in listing
def test_opening_the_picker_on_a_touchscreen_does_not_raise_the_keyboard():
"""1.8.3. With more than eight models the menu has a filter, and `open()`
focused it -- which on a phone raises the keyboard over half the list the
finger came to choose from. The focus is gated on `(hover: none)`, the same
query the stylesheet uses for touch."""
from pathlib import Path
import lembas
js = (Path(lembas.__file__).parent / "web/static/js/ui.js").read_text(encoding="utf-8")
start = js.index("function open(picker)")
body = js[start : js.index("function applyFilter", start)]
assert "filter.focus()" in body, "the filter is no longer focused anywhere -- test is blind"
gated = r'if \(filter && !window\.matchMedia\("\(hover: none\)"\)\.matches\)\s*\{'
assert re.search(gated + r"\s*filter\.focus\(\)", body), (
"the filter is focused on open without asking whether this is a touchscreen"
)
+152
View File
@@ -0,0 +1,152 @@
"""The load-state dot in the model menu: only what an endpoint states.
llama-swap reports `"status": {"value": "loaded" | "unloaded"}` on every entry
of `GET /v1/models`, verified against the live one on 2026-09-28. A hosted API
such as DeepSeek has no such field, so its models must get no state at all --
not "unloaded", which would be a claim nobody made.
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from lembas.db.models import Connection, Model
from lembas.services import model_state
LLAMA_SWAP = [
{"id": "bonsai", "status": {"value": "loaded"}},
{"id": "gpt-oss", "status": {"value": "unloaded"}},
{"id": "qwen36", "status": {"value": "starting"}},
]
HOSTED = [{"id": "deepseek-flash", "object": "model"}]
@pytest.fixture(autouse=True)
def _fresh_cache():
model_state.forget()
yield
model_state.forget()
@pytest.mark.parametrize(
("entry", "state"),
[
({"status": {"value": "loaded"}}, "loaded"),
({"status": {"value": "ready"}}, "loaded"),
({"status": "loaded"}, "loaded"),
({"status": {"value": "starting"}}, "loading"),
({"status": {"value": "unloaded"}}, "unloaded"),
({"status": {"value": "stopped"}}, "unloaded"),
({}, ""),
({"status": {}}, ""),
({"status": 3}, ""),
],
)
def test_state_is_read_from_the_entry_or_not_at_all(entry, state):
assert model_state.state_of({"id": "x", **entry}) == state
def _two_connections(db):
local = Connection(name="llama", base_url="http://llama.test/v1", api_key_encrypted="")
hosted = Connection(name="deepseek", base_url="http://hosted.test/v1", api_key_encrypted="")
db.add_all([local, hosted])
db.commit()
served = ((local, ("bonsai", "gpt-oss", "qwen36")), (hosted, ("deepseek-flash",)))
for connection, ids in served:
for model_id in ids:
db.add(Model(connection_id=connection.id, model_id=model_id))
db.commit()
return local, hosted
def _fake_endpoints(monkeypatch, calls):
async def fake(endpoint):
calls.append(endpoint.base_url)
return LLAMA_SWAP if "llama" in endpoint.base_url else HOSTED
monkeypatch.setattr(model_state, "list_models", fake)
def test_only_models_whose_endpoint_states_one_get_a_state(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
states = client.get("/api/models/state").json()["states"]
assert states == {"bonsai": "loaded", "gpt-oss": "unloaded", "qwen36": "loading"}
assert "deepseek-flash" not in states
# One request per connection, not per model.
assert sorted(calls) == ["http://hosted.test/v1", "http://llama.test/v1"]
def test_a_silent_endpoint_is_not_asked_again_on_every_open(
client: TestClient, db, registered, monkeypatch
):
"""A hosted API answers with no state every time. Asking it on each click
only to hear nothing again is a request to a third party for no reason."""
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
client.get("/api/models/state")
client.get("/api/models/state")
assert calls.count("http://hosted.test/v1") == 1
def test_an_unreachable_endpoint_is_a_menu_without_dots(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
async def broken(endpoint):
raise OSError("connection refused")
monkeypatch.setattr(model_state, "list_models", broken)
response = client.get("/api/models/state")
assert response.status_code == 200
assert response.json() == {"states": {}}
def test_a_slow_endpoint_cannot_hold_the_menu(db, monkeypatch):
connection = Connection(name="slow", base_url="http://slow.test/v1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
async def slow(endpoint):
await asyncio.sleep(10)
return LLAMA_SWAP
monkeypatch.setattr(model_state, "list_models", slow)
monkeypatch.setattr(model_state, "TIMEOUT", 0.05)
models = db.query(Model).all()
assert asyncio.run(model_state.states_for(models)) == {}
def test_the_menu_has_a_slot_for_every_model(client: TestClient, db, registered):
"""Every option emits the slot, whatever its endpoint says: the dot is
placed by ui.js after the menu opens, so a model with no slot could never
show one."""
_two_connections(db)
html = client.get("/chat").text
for model_id in ("bonsai", "gpt-oss", "qwen36", "deepseek-flash"):
start = html.index(f'data-model-id="{model_id}"')
option = html[start : html.index("</button>", start)]
assert 'data-model-state=""' in option
assert "data-label-loaded=" in html
def test_the_state_needs_a_signed_in_reader(client: TestClient):
response = client.get("/api/models/state", follow_redirects=False)
assert response.status_code in (401, 303, 307)

Some files were not shown because too many files have changed in this diff Show More