Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216e3a20d1
|
||
|
|
c27fe47d4d
|
||
|
|
9970bb43c6
|
||
|
|
e65ea90fe6
|
||
|
|
cb8a223fa4
|
||
|
|
dfd8418d95
|
||
|
|
db22962164
|
+212
@@ -16,6 +16,218 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.8.3
|
||||||
|
|
||||||
The model picker on a phone, which could not be read once a chat was open.
|
The model picker on a phone, which could not be read once a chat was open.
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ def authored_sideways() -> tuple[str, ...]:
|
|||||||
return tuple(selectors)
|
return tuple(selectors)
|
||||||
|
|
||||||
|
|
||||||
|
# The chat `build_client` seeds, so a run can name `/chat/<SHOOT_CHAT>`.
|
||||||
|
SHOOT_CHAT = "5" * 32
|
||||||
|
|
||||||
|
|
||||||
def build_client():
|
def build_client():
|
||||||
import lembas.config as config_mod
|
import lembas.config as config_mod
|
||||||
|
|
||||||
@@ -293,6 +297,51 @@ def build_client():
|
|||||||
for name in ("gemma4-moe", "qwen3-coder"):
|
for name in ("gemma4-moe", "qwen3-coder"):
|
||||||
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
|
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)`
|
# 🚨 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
|
# 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
|
# screen ever taken by this script was of a page with its cards missing. That
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.8.3"
|
__version__ = "1.12.0"
|
||||||
|
|||||||
+14
-1
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
|
|
||||||
from lembas.api.deps import AdminUser, Db
|
from lembas.api.deps import AdminUser, Db
|
||||||
from lembas.db.models import Connection, Model, User
|
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.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
|
||||||
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
|
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
|
||||||
from lembas.web import i18n
|
from lembas.web import i18n
|
||||||
@@ -109,6 +109,7 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s
|
|||||||
},
|
},
|
||||||
"message": message,
|
"message": message,
|
||||||
"unchanged": UNCHANGED_SENTINEL,
|
"unchanged": UNCHANGED_SENTINEL,
|
||||||
|
"data_group_choices": data_groups.instance_groups(db),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,8 +176,19 @@ async def update_connection(
|
|||||||
unload_url: str = Form(""),
|
unload_url: str = Form(""),
|
||||||
unload_method: str = Form("POST"),
|
unload_method: str = Form("POST"),
|
||||||
extra_headers: str = Form(""),
|
extra_headers: str = Form(""),
|
||||||
|
data_group_id: str = Form(""),
|
||||||
|
one_model_at_a_time: bool = Form(False),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
connection = _connection(db, connection_id)
|
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.name = name.strip()[:120] or connection.name
|
||||||
connection.base_url = base_url.strip().rstrip("/")
|
connection.base_url = base_url.strip().rstrip("/")
|
||||||
connection.enabled = enabled
|
connection.enabled = enabled
|
||||||
@@ -227,6 +239,7 @@ async def test_connection(
|
|||||||
"message": message,
|
"message": message,
|
||||||
"message_kind": "error" if error else "success",
|
"message_kind": "error" if error else "success",
|
||||||
"unchanged": UNCHANGED_SENTINEL,
|
"unchanged": UNCHANGED_SENTINEL,
|
||||||
|
"data_group_choices": data_groups.instance_groups(db),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
|
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
|
||||||
|
|
||||||
# Read out by the speech test. Short, and the one line this project would pick.
|
# 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:
|
def _page_context(db: Db) -> dict:
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||||
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
||||||
from lembas.services import chat as chat_service
|
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 personas as personas_service
|
||||||
from lembas.services import settings_store, uploads
|
from lembas.services import settings_store, uploads
|
||||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
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
|
# and undo what a model wrote *before* they switched it off, which is
|
||||||
# exactly when they would come looking.
|
# exactly when they would come looking.
|
||||||
"persona": personas_service.get(db, model.model_id, None),
|
"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,
|
"persona_limit": personas_service.MAX_PERSONA_CHARS,
|
||||||
"position_of": index + 1,
|
"position_of": index + 1,
|
||||||
"total": len(ordered),
|
"total": len(ordered),
|
||||||
@@ -261,6 +272,7 @@ async def update_model(
|
|||||||
enabled: bool = Form(False),
|
enabled: bool = Form(False),
|
||||||
pinned: bool = Form(False),
|
pinned: bool = Form(False),
|
||||||
public: bool = Form(False),
|
public: bool = Form(False),
|
||||||
|
single_session: bool = Form(False),
|
||||||
position: str = Form(""),
|
position: str = Form(""),
|
||||||
context_length: str = Form(""),
|
context_length: str = Form(""),
|
||||||
default_effort: str = Form(""),
|
default_effort: str = Form(""),
|
||||||
@@ -284,6 +296,7 @@ async def update_model(
|
|||||||
model.enabled = enabled
|
model.enabled = enabled
|
||||||
model.pinned = pinned
|
model.pinned = pinned
|
||||||
model.public = public
|
model.public = public
|
||||||
|
model.single_session = single_session
|
||||||
|
|
||||||
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||||
# holds whatever sampling defaults an administrator has set and this form
|
# 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")
|
@router.post("/admin/models/{model_id}/persona")
|
||||||
async def update_persona(
|
async def update_persona(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|||||||
@@ -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)
|
||||||
+48
-7
@@ -34,9 +34,10 @@ from lembas.security import permissions
|
|||||||
from lembas.services import audio as audio_service
|
from lembas.services import audio as audio_service
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import compaction as compaction_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 files as files_service
|
||||||
from lembas.services import generation as generation_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 metrics as metrics_service
|
||||||
from lembas.services import prompts as prompts_service
|
from lembas.services import prompts as prompts_service
|
||||||
from lembas.services import reports as reports_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,
|
folder_id=folder.id if folder is not None else None,
|
||||||
model_id=chosen[0] if chosen else "",
|
model_id=chosen[0] if chosen else "",
|
||||||
connection_id=chosen[1] if chosen else None,
|
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,
|
temporary=temporary,
|
||||||
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
||||||
ssh_profile_id=profile.id if profile is not None else None,
|
ssh_profile_id=profile.id if profile is not None else None,
|
||||||
@@ -301,6 +310,7 @@ async def start_chat(
|
|||||||
# -- the same mechanism the scope switches above use, and the reason the control
|
# -- the same mechanism the scope switches above use, and the reason the control
|
||||||
# lives inside the composer's form rather than in the topbar.
|
# lives inside the composer's form rather than in the topbar.
|
||||||
crowd_model_ids: list[str] = Form(default=[]),
|
crowd_model_ids: list[str] = Form(default=[]),
|
||||||
|
helper_model_ids: list[str] = Form(default=[]),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Create a chat from its first message.
|
"""Create a chat from its first message.
|
||||||
|
|
||||||
@@ -335,6 +345,7 @@ async def start_chat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_apply_crowd(db, chat, user, crowd_model_ids)
|
_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)
|
_adopt_draft(db, user, draft_id, chat)
|
||||||
|
|
||||||
@@ -657,8 +668,12 @@ async def attach_base(
|
|||||||
if not permissions.has(db, user, "library.use"):
|
if not permissions.has(db, user, "library.use"):
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
|
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(
|
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:
|
if base is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||||
@@ -1534,10 +1549,15 @@ def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> No
|
|||||||
and a model offered by two connections is two rows with different capabilities.
|
and a model offered by two connections is two rows with different capabilities.
|
||||||
"""
|
"""
|
||||||
from lembas.db.models import CrowdMember
|
from lembas.db.models import CrowdMember
|
||||||
|
from lembas.services import talk
|
||||||
|
|
||||||
settings = settings_store.crowd(db)
|
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 = {
|
reachable = {
|
||||||
model.model_id: model for model in chat_service.available_models(db, user)
|
model.model_id: model
|
||||||
|
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
|
||||||
}
|
}
|
||||||
wanted: list[str] = []
|
wanted: list[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
@@ -2125,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 --
|
# Checked against what this user can reach, not merely what exists --
|
||||||
# otherwise the picker is advisory and a crafted request bypasses it.
|
# 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(
|
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,
|
None,
|
||||||
)
|
)
|
||||||
if match is 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.")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
|
||||||
chat.model_id = model_id
|
chat.model_id = model_id
|
||||||
chat.connection_id = match.connection_id
|
chat.connection_id = match.connection_id
|
||||||
@@ -2152,9 +2189,9 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
chat.knowledge_bases = (
|
chat.knowledge_bases = (
|
||||||
list(
|
list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
documents_service.visible_bases(db, user).where(
|
documents_service.visible_bases(
|
||||||
KnowledgeBase.id.in_(wanted)
|
db, user, data_groups.for_chat(db, chat)
|
||||||
)
|
).where(KnowledgeBase.id.in_(wanted))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if wanted
|
if wanted
|
||||||
@@ -2166,6 +2203,10 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
# every box clears the crowd.
|
# every box clears the crowd.
|
||||||
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
||||||
|
|
||||||
|
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}
|
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
||||||
if submitted_params:
|
if submitted_params:
|
||||||
if not allowed.get("chat.params"):
|
if not allowed.get("chat.params"):
|
||||||
|
|||||||
+57
-18
@@ -21,8 +21,8 @@ from sqlalchemy import select
|
|||||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||||
from lembas.db.models import Attachment, Chat, Document, KnowledgeBase, Note
|
from lembas.db.models import Attachment, Chat, Document, KnowledgeBase, Note
|
||||||
from lembas.security import permissions
|
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 files as files_service
|
||||||
from lembas.services import settings_store
|
|
||||||
from lembas.services.fetch import FetchError, fetch
|
from lembas.services.fetch import FetchError, fetch
|
||||||
from lembas.services.library import documents as documents_service
|
from lembas.services.library import documents as documents_service
|
||||||
from lembas.services.library import notes as notes_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"))])
|
@router.post("/from-knowledge", dependencies=[Depends(require_permission("files.upload"))])
|
||||||
async def attach_from_knowledge(
|
async def attach_from_knowledge(
|
||||||
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
|
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
|
||||||
chat_id: str = Form(""),
|
chat_id: str = Form(""), model_id: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Attach a library document to the message being composed.
|
"""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
|
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.
|
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:
|
if document is None:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -163,7 +164,12 @@ def _not_available(request: Request, what: str) -> Response:
|
|||||||
|
|
||||||
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
|
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
|
||||||
async def attach_from_note(
|
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:
|
) -> Response:
|
||||||
"""Attach a note the model wrote earlier.
|
"""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
|
document, and a transcript that changes underneath itself because somebody
|
||||||
tidied a note later is the thing all of this is arranged to prevent.
|
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:
|
if note is None:
|
||||||
return _not_available(request, "note")
|
return _not_available(request, "note")
|
||||||
|
|
||||||
@@ -226,7 +234,12 @@ async def attach_from_scratch(
|
|||||||
|
|
||||||
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
|
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
|
||||||
async def attach_from_skill(
|
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:
|
) -> Response:
|
||||||
"""Hand a skill over directly, rather than hoping the model fetches it.
|
"""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
|
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.
|
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:
|
if skill is None:
|
||||||
return _not_available(request, "skill")
|
return _not_available(request, "skill")
|
||||||
|
|
||||||
@@ -259,6 +274,7 @@ async def attach_from_attachment(
|
|||||||
user: RequiredUser,
|
user: RequiredUser,
|
||||||
attachment_id: str = Form(""),
|
attachment_id: str = Form(""),
|
||||||
chat_id: str = Form(""),
|
chat_id: str = Form(""),
|
||||||
|
model_id: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Point at something already in this conversation, without uploading again.
|
"""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)
|
original = db.get(Attachment, attachment_id)
|
||||||
if original is None or original.user_id != user.id:
|
if original is None or original.user_id != user.id:
|
||||||
return _not_available(request, "attachment")
|
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(
|
return _chip(
|
||||||
request,
|
request,
|
||||||
@@ -280,15 +302,24 @@ async def attach_from_attachment(
|
|||||||
|
|
||||||
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
|
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
|
||||||
async def knowledge_picker(
|
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:
|
) -> 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():
|
if q.strip():
|
||||||
found = documents_service.search(db, user, q, limit=20)
|
found = documents_service.search(db, user, q, limit=20, group=group)
|
||||||
else:
|
else:
|
||||||
found = list(
|
found = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
documents_service.visible(db, user)
|
documents_service.visible(db, user, group=group)
|
||||||
.order_by(Document.created_at.desc())
|
.order_by(Document.created_at.desc())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
@@ -311,6 +342,7 @@ async def mention_picker(
|
|||||||
chat_id: str = "",
|
chat_id: str = "",
|
||||||
profile_id: str = "",
|
profile_id: str = "",
|
||||||
project_dir: str = "",
|
project_dir: str = "",
|
||||||
|
model_id: str = "",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""What `@` offers: files under the project directory, and the library.
|
"""What `@` offers: files under the project directory, and the library.
|
||||||
|
|
||||||
@@ -348,24 +380,29 @@ async def mention_picker(
|
|||||||
skills: list = []
|
skills: list = []
|
||||||
bases: list = []
|
bases: list = []
|
||||||
if permissions.has(db, user, "library.use"):
|
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:
|
if needle:
|
||||||
documents = documents_service.search(db, user, q, limit=10)
|
documents = documents_service.search(db, user, q, limit=10, group=group)
|
||||||
notes = notes_service.search(db, user, q, limit=5)
|
notes = notes_service.search(db, user, q, limit=5, group=group)
|
||||||
skills = skills_service.search(db, user, q, limit=5)
|
skills = skills_service.search(db, user, q, limit=5, group=group)
|
||||||
else:
|
else:
|
||||||
documents = list(
|
documents = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
documents_service.visible(db, user)
|
documents_service.visible(db, user, group=group)
|
||||||
.order_by(Document.created_at.desc())
|
.order_by(Document.created_at.desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
notes = list(
|
notes = list(
|
||||||
db.scalars(
|
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
|
# 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
|
# chat to it and the model searches inside it. Dumping the contents of
|
||||||
@@ -377,7 +414,9 @@ async def mention_picker(
|
|||||||
bases = [
|
bases = [
|
||||||
base
|
base
|
||||||
for base in db.scalars(
|
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()
|
if not needle or needle in base.name.lower()
|
||||||
][:5]
|
][:5]
|
||||||
|
|||||||
+146
-16
@@ -33,8 +33,8 @@ from lembas.db.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from lembas.security import permissions
|
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 files as files_service
|
||||||
from lembas.services import settings_store, sharing
|
|
||||||
from lembas.services.fetch import FetchError, fetch
|
from lembas.services.fetch import FetchError, fetch
|
||||||
from lembas.services.library import documents as documents_service
|
from lembas.services.library import documents as documents_service
|
||||||
from lembas.services.library import memories as memories_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}
|
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:
|
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
|
||||||
"""What the share placeholder needs, which is now three facts.
|
"""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.
|
# FastAPI matches in registration order and this has bitten before.
|
||||||
@router.get("/library/knowledge")
|
@router.get("/library/knowledge")
|
||||||
async def knowledge_list(
|
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.
|
"""The bases, not the documents. A library is a set of places first.
|
||||||
|
|
||||||
@@ -100,6 +145,7 @@ async def knowledge_list(
|
|||||||
if shared
|
if shared
|
||||||
else documents_service.visible_bases(db, user)
|
else documents_service.visible_bases(db, user)
|
||||||
)
|
)
|
||||||
|
query = _group_filter(query, KnowledgeBase, group)
|
||||||
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
||||||
counts = {
|
counts = {
|
||||||
base.id: db.scalar(
|
base.id: db.scalar(
|
||||||
@@ -117,6 +163,8 @@ async def knowledge_list(
|
|||||||
"counts": counts,
|
"counts": counts,
|
||||||
"shared": shared,
|
"shared": shared,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"group": group,
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -124,11 +172,19 @@ async def knowledge_list(
|
|||||||
|
|
||||||
@router.post("/api/library/bases")
|
@router.post("/api/library/bases")
|
||||||
async def create_base(
|
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:
|
) -> Response:
|
||||||
try:
|
try:
|
||||||
base = documents_service.create_base(
|
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:
|
except ValueError as exc:
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -163,6 +219,7 @@ async def knowledge_detail(request: Request, db: Db, user: RequiredUser, documen
|
|||||||
.order_by(KnowledgeBase.name)
|
.order_by(KnowledgeBase.name)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -202,6 +259,7 @@ async def base_detail(
|
|||||||
"q": q,
|
"q": q,
|
||||||
"pager": pager,
|
"pager": pager,
|
||||||
**_shared_context(db, user, base, "base"),
|
**_shared_context(db, user, base, "base"),
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(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:
|
if name:
|
||||||
base.name = name
|
base.name = name
|
||||||
base.description = str(form.get("description", "")).strip()[:2000]
|
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()
|
db.commit()
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
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()
|
wanted = str(form.get("base_id", "")).strip()
|
||||||
if wanted and wanted != document.base_id:
|
if wanted and wanted != document.base_id:
|
||||||
destination = documents_service.get_base(db, wanted, user)
|
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
|
document.base_id = destination.id
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -352,6 +422,7 @@ async def notes_list(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
shared: bool = False,
|
shared: bool = False,
|
||||||
|
group: str = "",
|
||||||
):
|
):
|
||||||
"""`shared=1` narrows to what other people have given this reader.
|
"""`shared=1` narrows to what other people have given this reader.
|
||||||
|
|
||||||
@@ -363,7 +434,12 @@ async def notes_list(
|
|||||||
"""
|
"""
|
||||||
if q.strip():
|
if q.strip():
|
||||||
rows = notes_service.search(
|
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)}
|
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||||
else:
|
else:
|
||||||
@@ -372,6 +448,7 @@ async def notes_list(
|
|||||||
if shared
|
if shared
|
||||||
else notes_service.visible(db, user)
|
else notes_service.visible(db, user)
|
||||||
)
|
)
|
||||||
|
query = _group_filter(query, Note, group)
|
||||||
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -382,17 +459,25 @@ async def notes_list(
|
|||||||
"q": q,
|
"q": q,
|
||||||
"shared": shared,
|
"shared": shared,
|
||||||
"pager": pager,
|
"pager": pager,
|
||||||
|
"group": group,
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library/notes/new")
|
@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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"library/note_detail.html",
|
"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,
|
"note": note,
|
||||||
"body_html": render_markdown(note.body),
|
"body_html": render_markdown(note.body),
|
||||||
**_shared_context(db, user, note, "note"),
|
**_shared_context(db, user, note, "note"),
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(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")
|
@router.post("/api/library/notes")
|
||||||
async def create_note(
|
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:
|
) -> 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)
|
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.")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "That note is not yours to change.")
|
||||||
|
|
||||||
form = await request.form()
|
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", "")))
|
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)
|
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
@@ -453,6 +551,7 @@ async def skills_list(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
shared: bool = False,
|
shared: bool = False,
|
||||||
|
group: str = "",
|
||||||
):
|
):
|
||||||
"""`shared=1` narrows to what other people have given this reader.
|
"""`shared=1` narrows to what other people have given this reader.
|
||||||
|
|
||||||
@@ -464,7 +563,12 @@ async def skills_list(
|
|||||||
"""
|
"""
|
||||||
if q.strip():
|
if q.strip():
|
||||||
rows = skills_service.search(
|
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)}
|
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||||
else:
|
else:
|
||||||
@@ -473,6 +577,7 @@ async def skills_list(
|
|||||||
if shared
|
if shared
|
||||||
else skills_service.visible(db, user)
|
else skills_service.visible(db, user)
|
||||||
)
|
)
|
||||||
|
query = _group_filter(query, Skill, group)
|
||||||
rows, pager = _page(db, query.order_by(Skill.name), page)
|
rows, pager = _page(db, query.order_by(Skill.name), page)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -483,17 +588,25 @@ async def skills_list(
|
|||||||
"q": q,
|
"q": q,
|
||||||
"shared": shared,
|
"shared": shared,
|
||||||
"pager": pager,
|
"pager": pager,
|
||||||
|
"group": group,
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library/skills/new")
|
@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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"library/skill_detail.html",
|
"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,
|
"skill": skill,
|
||||||
"revisions": skill.revisions,
|
"revisions": skill.revisions,
|
||||||
**_shared_context(db, user, skill, "skill"),
|
**_shared_context(db, user, skill, "skill"),
|
||||||
|
**_groups(db, user),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -522,10 +636,17 @@ async def create_skill(
|
|||||||
name: str = Form(""),
|
name: str = Form(""),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
body: str = Form(""),
|
body: str = Form(""),
|
||||||
|
data_group_id: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
try:
|
try:
|
||||||
skill = skills_service.create(
|
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:
|
except skills_service.SkillError as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from 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.")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "That skill is not yours to change.")
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
_move(db, user, skill, form.get("data_group_id"))
|
||||||
skills_service.update(
|
skills_service.update(
|
||||||
db,
|
db,
|
||||||
skill,
|
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
|
# Lives in Settings rather than in the library: it is a set of short facts about
|
||||||
# the reader, not content they collected.
|
# the reader, not content they collected.
|
||||||
@router.post("/api/library/memories")
|
@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:
|
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:
|
except ValueError as exc:
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
+237
-8
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
from zoneinfo import available_timezones
|
from zoneinfo import available_timezones
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
@@ -17,6 +18,7 @@ from lembas.db.models import (
|
|||||||
KIND_TASK,
|
KIND_TASK,
|
||||||
KINDS,
|
KINDS,
|
||||||
Chat,
|
Chat,
|
||||||
|
Connection,
|
||||||
Folder,
|
Folder,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
Message,
|
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 canvas as canvas_service
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import compaction as compaction_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 reports as reports_service
|
||||||
from lembas.services import settings_store
|
|
||||||
from lembas.services import suggestions as suggestions_service
|
from lembas.services import suggestions as suggestions_service
|
||||||
from lembas.services.library import documents as documents_service
|
from lembas.services.library import documents as documents_service
|
||||||
from lembas.services.schedule import clock
|
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)
|
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
|
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 {
|
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,
|
"current_model": current,
|
||||||
# Assistant bubbles show the avatar of the model that wrote them, which
|
# 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
|
# may not be the model the chat is set to now. Keyed by model_id, the
|
||||||
@@ -76,7 +88,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
"knowledge_bases": (
|
"knowledge_bases": (
|
||||||
list(
|
list(
|
||||||
db.scalars(
|
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")
|
if permissions.has(db, user, "library.use")
|
||||||
@@ -84,8 +98,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
),
|
),
|
||||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||||
**_crowd_context(
|
**_crowd_context(
|
||||||
db, user, chat, models, current.model_id if current is not None else ""
|
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
|
# 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
|
# 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
|
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||||
@@ -199,6 +214,143 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> 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(
|
def _crowd_context(
|
||||||
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -219,13 +371,39 @@ def _crowd_context(
|
|||||||
|
|
||||||
settings = settings_store.crowd(db)
|
settings = settings_store.crowd(db)
|
||||||
if not settings["enabled"]:
|
if not settings["enabled"]:
|
||||||
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
|
return {
|
||||||
|
"crowd_available": [],
|
||||||
|
"crowd_held_back": [],
|
||||||
|
"crowd_member_ids": [],
|
||||||
|
"crowd_skipped": [],
|
||||||
|
}
|
||||||
|
|
||||||
# On the new-chat screen the "own" model is whichever one the picker is
|
# 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:
|
# 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.
|
# adding it would have it answer twice in a row.
|
||||||
own = chat.model_id if chat is not None else default_model_id
|
own = chat.model_id if chat is not None else default_model_id
|
||||||
others = [model for model in models if model.model_id != own]
|
# 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
|
||||||
|
]
|
||||||
members = (
|
members = (
|
||||||
[
|
[
|
||||||
row.model_id
|
row.model_id
|
||||||
@@ -234,16 +412,17 @@ def _crowd_context(
|
|||||||
if chat is not None
|
if chat is not None
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
reachable = {model.model_id for model in others}
|
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])
|
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
|
||||||
rounds = int(settings["max_rounds"])
|
rounds = int(settings["max_rounds"])
|
||||||
return {
|
return {
|
||||||
"crowd_available": others,
|
"crowd_available": others,
|
||||||
|
"crowd_held_back": held_back,
|
||||||
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
||||||
"crowd_skipped": (
|
"crowd_skipped": (
|
||||||
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
|
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
|
# One round is out and back: everybody answers, everybody but the last is
|
||||||
# asked whether they disagree, and the main model closes.
|
# asked whether they disagree, and the main model closes.
|
||||||
"crowd_replies": max(1, speakers * 2 - 1),
|
"crowd_replies": max(1, speakers * 2 - 1),
|
||||||
"crowd_rounds": rounds,
|
"crowd_rounds": rounds,
|
||||||
@@ -727,6 +906,24 @@ async def chat_index(
|
|||||||
if preselected is None and context["models"]:
|
if preselected is None and context["models"]:
|
||||||
preselected = context["models"][0]
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"chat/index.html",
|
"chat/index.html",
|
||||||
@@ -736,7 +933,34 @@ async def chat_index(
|
|||||||
"bodies": {},
|
"bodies": {},
|
||||||
**context,
|
**context,
|
||||||
"current_model": preselected,
|
"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,
|
"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_kind": kind if kind in KINDS else KIND_CHAT,
|
||||||
"starting_folder": starting_folder,
|
"starting_folder": starting_folder,
|
||||||
"suggestions": suggestions_service.visible(db),
|
"suggestions": suggestions_service.visible(db),
|
||||||
@@ -925,6 +1149,11 @@ async def settings_page(
|
|||||||
# would leave no way to delete them.
|
# would leave no way to delete them.
|
||||||
"personalities": personas_service.personas_of(db, user),
|
"personalities": personas_service.personas_of(db, user),
|
||||||
"impressions": personas_service.impressions_for(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
|
# Sorted rather than left in set order, because a list of six
|
||||||
# hundred zones that is not alphabetical is one nobody can use.
|
# hundred zones that is not alphabetical is one nobody can use.
|
||||||
"languages": i18n.LANGUAGES,
|
"languages": i18n.LANGUAGES,
|
||||||
|
|||||||
@@ -293,3 +293,171 @@ async def change_password(
|
|||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
return response
|
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)
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ async def describe_schedule(request: Request, db: Db, user: RequiredUser):
|
|||||||
described = str(form.get("request") or "").strip()
|
described = str(form.get("request") or "").strip()
|
||||||
|
|
||||||
template = prompts_service.resolve(db, "task.schedule_compile")
|
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:
|
if resolved is None:
|
||||||
compiled = compile_service.Compiled(
|
compiled = compile_service.Compiled(
|
||||||
instruction=described,
|
instruction=described,
|
||||||
|
|||||||
@@ -31,11 +31,14 @@ from lembas.db.models.chat import (
|
|||||||
ROLE_TOOL,
|
ROLE_TOOL,
|
||||||
ROLE_USER,
|
ROLE_USER,
|
||||||
Chat,
|
Chat,
|
||||||
|
ChatHelper,
|
||||||
CrowdMember,
|
CrowdMember,
|
||||||
Folder,
|
Folder,
|
||||||
Message,
|
Message,
|
||||||
)
|
)
|
||||||
from lembas.db.models.connection import Connection, Model, model_groups
|
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.image import ImageWorkflow
|
||||||
from lembas.db.models.library import (
|
from lembas.db.models.library import (
|
||||||
AUTHOR_MODEL,
|
AUTHOR_MODEL,
|
||||||
@@ -83,6 +86,7 @@ from lembas.db.models.schedule import (
|
|||||||
)
|
)
|
||||||
from lembas.db.models.setting import Setting
|
from lembas.db.models.setting import Setting
|
||||||
from lembas.db.models.suggestion import Suggestion
|
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 (
|
from lembas.db.models.tool import (
|
||||||
RESPONSE_JSON,
|
RESPONSE_JSON,
|
||||||
RESPONSE_MODES,
|
RESPONSE_MODES,
|
||||||
@@ -164,9 +168,19 @@ __all__ = [
|
|||||||
"Report",
|
"Report",
|
||||||
"Schedule",
|
"Schedule",
|
||||||
"Chat",
|
"Chat",
|
||||||
|
"ChatHelper",
|
||||||
"CrowdMember",
|
"CrowdMember",
|
||||||
|
"HelperDesignation",
|
||||||
"Job",
|
"Job",
|
||||||
"Connection",
|
"Connection",
|
||||||
|
"DEFAULT_GROUP",
|
||||||
|
"DataGroup",
|
||||||
|
"ANY_MODEL",
|
||||||
|
"EFFECT_ALLOW",
|
||||||
|
"EFFECT_DENY",
|
||||||
|
"EFFECTS",
|
||||||
|
"TalkRule",
|
||||||
|
"InDataGroup",
|
||||||
"CustomTool",
|
"CustomTool",
|
||||||
"CHUNK_DOCUMENT",
|
"CHUNK_DOCUMENT",
|
||||||
"CHUNK_KINDS",
|
"CHUNK_KINDS",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||||
|
from lembas.db.models.data_group import InDataGroup
|
||||||
from lembas.db.types import JSONDict, JSONList
|
from lembas.db.types import JSONDict, JSONList
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -173,7 +174,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
return f"<Folder {self.name}>"
|
return f"<Folder {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
class Chat(UUIDPrimaryKey, Timestamps, Base):
|
class Chat(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||||
__tablename__ = "chats"
|
__tablename__ = "chats"
|
||||||
|
|
||||||
user_id: Mapped[str] = mapped_column(
|
user_id: Mapped[str] = mapped_column(
|
||||||
@@ -324,6 +325,12 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="CrowdMember.position",
|
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:
|
def __repr__(self) -> str:
|
||||||
return f"<Chat {self.title!r}>"
|
return f"<Chat {self.title!r}>"
|
||||||
@@ -371,6 +378,30 @@ class CrowdMember(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
return f"<CrowdMember {self.model_id} at {self.position}>"
|
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):
|
class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||||
|
from lembas.db.models.data_group import InDataGroup
|
||||||
from lembas.db.types import JSONDict, JSONList
|
from lembas.db.types import JSONDict, JSONList
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.
|
"""A configured upstream endpoint speaking the OpenAI HTTP API.
|
||||||
|
|
||||||
Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's
|
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_url: Mapped[str] = mapped_column(String(500), default="")
|
||||||
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
|
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.
|
# Result of the most recent "Test & refresh", surfaced in the admin list.
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
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)
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
# Pinned models are offered first, before the full list.
|
# Pinned models are offered first, before the full list.
|
||||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
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 models are usable by anyone; otherwise access comes from `groups`.
|
||||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}>"
|
||||||
@@ -38,6 +38,7 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
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
|
# Who wrote a record. Not decoration: a skill the model wrote itself is the one
|
||||||
# worth looking at twice when its behaviour changes unexpectedly.
|
# 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.
|
"""A named collection of documents.
|
||||||
|
|
||||||
Sharing lives here rather than on the individual document: "this folder is
|
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}>"
|
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.
|
"""Something the model wrote down, or a person did.
|
||||||
|
|
||||||
Longer and more specific than a memory. Not injected: a handful of notes
|
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}>"
|
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.
|
"""One short fact, in front of the model on every turn.
|
||||||
|
|
||||||
Deliberately not shareable and deliberately small. The length cap is
|
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}>"
|
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.
|
"""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
|
`description` is the load-bearing field: it is what gets injected, and it is
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import Boolean, ForeignKey, String, Text
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
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`.
|
# Where a report came from. Not a foreign key to anything -- see `source_id`.
|
||||||
SOURCE_SCHEDULE = "schedule"
|
SOURCE_SCHEDULE = "schedule"
|
||||||
@@ -14,7 +15,7 @@ SOURCE_MANUAL = "manual"
|
|||||||
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_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.
|
"""A finished piece of work, filed.
|
||||||
|
|
||||||
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||||
|
from lembas.db.models.data_group import InDataGroup
|
||||||
from lembas.db.types import JSONDict
|
from lembas.db.types import JSONDict
|
||||||
|
|
||||||
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
|
# 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)
|
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
|
||||||
|
|
||||||
|
|
||||||
class Schedule(UUIDPrimaryKey, Timestamps, Base):
|
class Schedule(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||||
"""One standing instruction and when it comes due.
|
"""One standing instruction and when it comes due.
|
||||||
|
|
||||||
The row carries no recurrence logic at all: `rule_json` is read by
|
The row carries no recurrence logic at all: `rule_json` is read by
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -18,10 +18,12 @@ from lembas.api import (
|
|||||||
admin_audio,
|
admin_audio,
|
||||||
admin_branding,
|
admin_branding,
|
||||||
admin_crowd,
|
admin_crowd,
|
||||||
|
admin_data_groups,
|
||||||
admin_extraction,
|
admin_extraction,
|
||||||
admin_images,
|
admin_images,
|
||||||
admin_models,
|
admin_models,
|
||||||
admin_prompts,
|
admin_prompts,
|
||||||
|
admin_rules,
|
||||||
admin_schedules,
|
admin_schedules,
|
||||||
admin_search,
|
admin_search,
|
||||||
admin_suggestions,
|
admin_suggestions,
|
||||||
@@ -38,6 +40,7 @@ from lembas.api import (
|
|||||||
folders,
|
folders,
|
||||||
library,
|
library,
|
||||||
messages,
|
messages,
|
||||||
|
models,
|
||||||
pages,
|
pages,
|
||||||
preferences,
|
preferences,
|
||||||
push,
|
push,
|
||||||
@@ -83,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
try:
|
try:
|
||||||
from lembas.db.session import session_scope
|
from lembas.db.session import session_scope
|
||||||
from lembas.services.chat import sweep_temporary
|
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.files import sweep_orphans
|
||||||
from lembas.services.library.documents import sweep_unfiled
|
from lembas.services.library.documents import sweep_unfiled
|
||||||
from lembas.services.library.indexing import sweep_orphans as sweep_chunks
|
from lembas.services.library.indexing import sweep_orphans as sweep_chunks
|
||||||
@@ -93,6 +97,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
# Documents that predate knowledge bases have nowhere to live until
|
# Documents that predate knowledge bases have nowhere to live until
|
||||||
# this runs; see services/library/documents.py.
|
# this runs; see services/library/documents.py.
|
||||||
sweep_unfiled(db)
|
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
|
# Temporary chats older than a day. Startup only, like the sweeps
|
||||||
# above it -- see services/chat.py:sweep_temporary.
|
# above it -- see services/chat.py:sweep_temporary.
|
||||||
sweep_temporary(db)
|
sweep_temporary(db)
|
||||||
@@ -204,6 +211,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(folders.router)
|
app.include_router(folders.router)
|
||||||
app.include_router(library.router)
|
app.include_router(library.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
|
app.include_router(models.router)
|
||||||
app.include_router(reports.router)
|
app.include_router(reports.router)
|
||||||
app.include_router(schedules.router)
|
app.include_router(schedules.router)
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
@@ -223,6 +231,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(admin_tools.router)
|
app.include_router(admin_tools.router)
|
||||||
app.include_router(admin_agents.router)
|
app.include_router(admin_agents.router)
|
||||||
app.include_router(admin_crowd.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(push.router)
|
||||||
app.include_router(branding.router)
|
app.include_router(branding.router)
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,45 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
|||||||
True,
|
True,
|
||||||
"Library",
|
"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,
|
# Gates whose read and write halves are separate permissions. Keyed on the gate,
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
|
|||||||
"chat_empty": (
|
"chat_empty": (
|
||||||
"Empty chat",
|
"Empty chat",
|
||||||
"Above the composer on a chat with nothing in it yet.",
|
"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_title": (
|
||||||
"Offline heading",
|
"Offline heading",
|
||||||
@@ -87,7 +91,7 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
|
|||||||
"error_403": (
|
"error_403": (
|
||||||
"403 — not yours",
|
"403 — not yours",
|
||||||
"Shown on a page somebody is not allowed to see.",
|
"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": (
|
"error_404": (
|
||||||
"404 — not found",
|
"404 — not found",
|
||||||
|
|||||||
+54
-13
@@ -20,6 +20,7 @@ from lembas.db.models import (
|
|||||||
Connection,
|
Connection,
|
||||||
Message,
|
Message,
|
||||||
Model,
|
Model,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from lembas.services import files as files_service
|
from lembas.services import files as files_service
|
||||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
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:
|
if connection is None or not connection.enabled:
|
||||||
# The original connection is gone or disabled. Any enabled connection
|
# The original connection is gone or disabled. Any enabled connection
|
||||||
# still offering this model id will do.
|
# still offering this model id will do -- **in the chat's own data
|
||||||
model = db.scalar(
|
# group**, for the chat's own model. Any connection at all would repoint
|
||||||
select(Model)
|
# the conversation onto whichever provider happened to serve the same
|
||||||
.join(Connection)
|
# id, and hand it the whole history on the way.
|
||||||
.where(
|
from lembas.services import data_groups
|
||||||
Model.model_id == speaker.model_id,
|
|
||||||
Model.enabled.is_(True),
|
candidates = list(
|
||||||
Connection.enabled.is_(True),
|
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:
|
if model is None:
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"No enabled connection currently offers the model "
|
f"No enabled connection currently offers the model "
|
||||||
@@ -841,16 +859,28 @@ def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
|||||||
return chosen.model_id, chosen.connection_id
|
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.
|
"""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
|
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
|
shortcuts in the sidebar, and a picker whose order silently differs from
|
||||||
the one configured in the admin screen is just confusing.
|
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
|
from lembas.security import permissions
|
||||||
|
|
||||||
reachable = permissions.models_visible_to(db, user)
|
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))
|
return sorted(reachable, key=lambda m: (m.position, m.model_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -865,7 +895,9 @@ MAX_ROSTER_CHARS = 2400
|
|||||||
MAX_ROSTER_ENTRY = 300
|
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.
|
"""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
|
`exclude` is a `model_id` and is normally the chat's own: a model does not
|
||||||
@@ -874,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
|
would be both a leak and a dead end, since asking it anything is refused by
|
||||||
the same check.
|
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]
|
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 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
|
The id is in brackets because it is what has to be typed back into
|
||||||
@@ -888,7 +929,7 @@ def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
|
|||||||
"""
|
"""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
budget = MAX_ROSTER_CHARS
|
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())
|
parts = ((model.description or "").strip(), (model.notes or "").strip())
|
||||||
about = " ".join(part for part in parts if part)
|
about = " ".join(part for part in parts if part)
|
||||||
about = " ".join(about.split())[:MAX_ROSTER_ENTRY]
|
about = " ".join(about.split())[:MAX_ROSTER_ENTRY]
|
||||||
|
|||||||
@@ -272,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
|
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.
|
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 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 = {
|
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)]
|
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
|
||||||
seen = {chat.model_id}
|
seen = {chat.model_id}
|
||||||
@@ -290,10 +298,11 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
|
|||||||
|
|
||||||
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
|
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."""
|
"""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 = {
|
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 [
|
return [
|
||||||
member.model_id
|
member.model_id
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -34,7 +34,7 @@ from lembas.services import canvas as canvas_service
|
|||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import compaction as compaction_service
|
from lembas.services import compaction as compaction_service
|
||||||
from lembas.services import crowd as crowd_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 metrics as metrics_service
|
||||||
from lembas.services import prompts as prompts_service
|
from lembas.services import prompts as prompts_service
|
||||||
from lembas.services import push as push_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
|
# on has to survive that. It is also the only thing that can make the
|
||||||
# bubble's avatar and the model actually asked agree.
|
# bubble's avatar and the model actually asked agree.
|
||||||
speaker = chat_service.speaker_for(db, chat, message)
|
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)
|
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
|
# Before the request is built, not while it streams. Every other
|
||||||
# budget here can only be noticed part way through and so ends with
|
# budget here can only be noticed part way through and so ends with
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ def context_variables(
|
|||||||
# set one behaves exactly as it always did.
|
# set one behaves exactly as it always did.
|
||||||
stamp = clock.now_for(user)
|
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] = {
|
values: dict[str, str] = {
|
||||||
"today": stamp.strftime("%A %-d %B %Y"),
|
"today": stamp.strftime("%A %-d %B %Y"),
|
||||||
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
|
"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",
|
"unbounded": "" if settings_store.chat_rounds(db) else "yes",
|
||||||
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
|
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
|
||||||
"tool_names": _tool_names(offered),
|
"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": (
|
||||||
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
|
if "skills" in families
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
@@ -276,6 +292,9 @@ def context_variables(
|
|||||||
# opinion. One fragment covering all three would say nothing useful to
|
# opinion. One fragment covering all three would say nothing useful to
|
||||||
# any of them.
|
# any of them.
|
||||||
"friend": "",
|
"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 --
|
# Who else is here. Filled below, where the chat's own model is known --
|
||||||
# a model does not need telling that it exists.
|
# a model does not need telling that it exists.
|
||||||
"model_roster": "",
|
"model_roster": "",
|
||||||
@@ -304,8 +323,15 @@ def context_variables(
|
|||||||
# Naming the bases a chat is scoped to matters: without it the model
|
# 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
|
# cannot tell "there is nothing about this" from "I am only allowed to
|
||||||
# see the contracts folder", and phrases a miss as the former.
|
# see the contracts folder", and phrases a miss as the former.
|
||||||
if "knowledge" in families and chat.knowledge_bases:
|
# Only the attached bases in this speaker's group: a base from another
|
||||||
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
|
# 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)
|
values["document_names"] = _document_names(db, chat)
|
||||||
|
|
||||||
# The one thing a tool description cannot carry, because a description
|
# 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.
|
# reason the roster and the tool are one checkbox rather than two.
|
||||||
if "friend" in families:
|
if "friend" in families:
|
||||||
values["model_roster"] = chat_service.roster_block(
|
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:
|
if "persona" in families:
|
||||||
# This person's own personality for this model, falling back to the
|
# This person's own personality for this model, falling back to the
|
||||||
# administrator's default until the model has written one with them;
|
# administrator's default until the model has written one with them;
|
||||||
# and this model's impression of them, which has no default and never
|
# and this model's impression of them, which has no default and never
|
||||||
# could.
|
# could.
|
||||||
key = speaking.model_id
|
key = personas_service.key_for(speaking.model_id, group)
|
||||||
values["persona"] = personas_service.block(db, key, user)
|
values["persona"] = personas_service.block(db, key, user)
|
||||||
values["person_view"] = personas_service.view_block(db, key, user)
|
values["person_view"] = personas_service.view_block(db, key, user)
|
||||||
|
|
||||||
return values
|
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]:
|
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||||
"""What a scheduled task's chat is for, and how often it comes round.
|
"""What a scheduled task's chat is for, and how often it comes round.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -330,7 +330,23 @@ def _reviewer(context: ToolContext) -> tuple[Endpoint, str] | None:
|
|||||||
try:
|
try:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
model = None
|
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
|
# 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
|
# before that was the rule -- a value written by an older release
|
||||||
# is a primary key and must keep working.
|
# is a primary key and must keep working.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
from lembas.config import settings
|
from lembas.config import settings
|
||||||
from lembas.db.models import (
|
from lembas.db.models import (
|
||||||
CHUNK_DOCUMENT,
|
CHUNK_DOCUMENT,
|
||||||
|
DEFAULT_GROUP,
|
||||||
SOURCE_LINK,
|
SOURCE_LINK,
|
||||||
SOURCE_UPLOAD,
|
SOURCE_UPLOAD,
|
||||||
Document,
|
Document,
|
||||||
@@ -69,19 +70,27 @@ def stored_path(stored_name: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
# --- Bases -------------------------------------------------------------------
|
# --- Bases -------------------------------------------------------------------
|
||||||
def visible_bases(db: DBSession, user: User | None):
|
def visible_bases(db: DBSession, user: User | None, group: str | None = None):
|
||||||
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
|
"""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)
|
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 None
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def create_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:
|
) -> KnowledgeBase:
|
||||||
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
|
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
|
||||||
existing = db.scalar(
|
existing = db.scalar(
|
||||||
@@ -90,28 +99,58 @@ def create_base(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing is not None:
|
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}.")
|
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.add(base)
|
||||||
db.commit()
|
db.commit()
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
|
def default_base(db: DBSession, owner: User, group: str = DEFAULT_GROUP) -> KnowledgeBase:
|
||||||
"""The base a document goes into when none was chosen.
|
"""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
|
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(
|
base = db.scalar(
|
||||||
select(KnowledgeBase)
|
select(KnowledgeBase)
|
||||||
.where(KnowledgeBase.owner_id == owner.id)
|
.where(
|
||||||
|
KnowledgeBase.owner_id == owner.id,
|
||||||
|
data_groups.condition(KnowledgeBase, group),
|
||||||
|
)
|
||||||
.order_by(KnowledgeBase.created_at)
|
.order_by(KnowledgeBase.created_at)
|
||||||
)
|
)
|
||||||
if base is not None:
|
if base is not None:
|
||||||
return base
|
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.add(base)
|
||||||
db.commit()
|
db.commit()
|
||||||
return base
|
return base
|
||||||
@@ -172,10 +211,15 @@ def store_upload(
|
|||||||
filename: str,
|
filename: str,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
base: KnowledgeBase | None = None,
|
base: KnowledgeBase | None = None,
|
||||||
|
group: str = DEFAULT_GROUP,
|
||||||
) -> Document:
|
) -> 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)
|
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}"
|
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
|
||||||
(library_dir() / stored_name).write_bytes(prepared.payload)
|
(library_dir() / stored_name).write_bytes(prepared.payload)
|
||||||
@@ -205,14 +249,19 @@ def store_upload(
|
|||||||
|
|
||||||
|
|
||||||
def store_page(
|
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:
|
) -> Document:
|
||||||
"""Add a fetched web page to the library.
|
"""Add a fetched web page to the library.
|
||||||
|
|
||||||
Saved as text rather than as the original HTML: the point of keeping it is
|
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.
|
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(
|
document = Document(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
base_id=base.id,
|
base_id=base.id,
|
||||||
@@ -233,15 +282,22 @@ def store_page(
|
|||||||
|
|
||||||
|
|
||||||
# --- Reading -----------------------------------------------------------------
|
# --- 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.
|
"""Documents this user may see, optionally narrowed to some bases.
|
||||||
|
|
||||||
Visibility comes from the base, not the document: a document is readable by
|
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
|
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_(
|
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)
|
query = select(Document).where(condition)
|
||||||
if base_ids:
|
if base_ids:
|
||||||
@@ -251,12 +307,14 @@ def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = No
|
|||||||
return query
|
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)
|
document = db.get(Document, document_id)
|
||||||
if document is None:
|
if document is None:
|
||||||
return None
|
return None
|
||||||
base = db.get(KnowledgeBase, document.base_id) if document.base_id else 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 None
|
||||||
return document
|
return document
|
||||||
|
|
||||||
@@ -310,6 +368,7 @@ def search(
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
base_ids: list[str] | None = None,
|
base_ids: list[str] | None = None,
|
||||||
vector: list[float] | None = None,
|
vector: list[float] | None = None,
|
||||||
|
group: str | None = None,
|
||||||
) -> list[Document]:
|
) -> list[Document]:
|
||||||
"""Documents matching `needle` that this user may see, best match first.
|
"""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)}
|
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||||
rows = list(
|
rows = list(
|
||||||
db.scalars(
|
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)))
|
rows.sort(key=lambda document: order.get(document.id, len(order)))
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from lembas.db.models import (
|
|||||||
Chunk,
|
Chunk,
|
||||||
Connection,
|
Connection,
|
||||||
Document,
|
Document,
|
||||||
|
KnowledgeBase,
|
||||||
Model,
|
Model,
|
||||||
Note,
|
Note,
|
||||||
Report,
|
Report,
|
||||||
@@ -92,19 +93,40 @@ class Embedder:
|
|||||||
batch: int = 16
|
batch: int = 16
|
||||||
|
|
||||||
|
|
||||||
def embedder(db: DBSession) -> Embedder | None:
|
def embedder(db: DBSession, group: str | None = None) -> Embedder | None:
|
||||||
"""The configured embedding model, or 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
|
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,
|
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
|
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.
|
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)
|
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()
|
wanted = str(values.get("embedding_model_id") or "").strip()
|
||||||
if not wanted:
|
if not wanted:
|
||||||
return None
|
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)
|
select(Model)
|
||||||
.join(Connection)
|
.join(Connection)
|
||||||
.where(
|
.where(
|
||||||
@@ -112,8 +134,9 @@ def embedder(db: DBSession) -> Embedder | None:
|
|||||||
Model.enabled.is_(True),
|
Model.enabled.is_(True),
|
||||||
Connection.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:
|
if model is None:
|
||||||
log.info("embedding model %r is configured but not available", wanted)
|
log.info("embedding model %r is configured but not available", wanted)
|
||||||
return None
|
return None
|
||||||
@@ -123,7 +146,30 @@ def embedder(db: DBSession) -> Embedder | None:
|
|||||||
return Embedder(
|
return Embedder(
|
||||||
endpoint=Endpoint.from_connection(connection),
|
endpoint=Endpoint.from_connection(connection),
|
||||||
model_id=model.model_id,
|
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:
|
if row is None:
|
||||||
forget_resource(db, kind, resource_id)
|
forget_resource(db, kind, resource_id)
|
||||||
return 0
|
return 0
|
||||||
worker = embedder(db)
|
worker = embedder(db, group_of_row(db, row))
|
||||||
if worker is None:
|
if worker is None:
|
||||||
return 0
|
return 0
|
||||||
body = text_of(row)
|
body = text_of(row)
|
||||||
@@ -438,7 +484,7 @@ async def rebuild_all(*, force: bool = True) -> None:
|
|||||||
_PROGRESS = Progress(running=True)
|
_PROGRESS = Progress(running=True)
|
||||||
try:
|
try:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
if embedder(db) is None:
|
if not any_configured(db):
|
||||||
_PROGRESS.error = "No embedding model is configured."
|
_PROGRESS.error = "No embedding model is configured."
|
||||||
return
|
return
|
||||||
work: list[tuple[str, str]] = []
|
work: list[tuple[str, str]] = []
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import logging
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,14 +39,19 @@ MAX_TOTAL_CHARS = 4000
|
|||||||
MAX_RECORDS = 200
|
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:
|
if user is None:
|
||||||
return []
|
return []
|
||||||
return list(
|
query = select(Memory).where(Memory.owner_id == user.id)
|
||||||
db.scalars(
|
if group is not None:
|
||||||
select(Memory).where(Memory.owner_id == user.id).order_by(Memory.created_at)
|
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:
|
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
|
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.
|
"""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
|
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:
|
if not content:
|
||||||
raise ValueError("A memory cannot be empty.")
|
raise ValueError("A memory cannot be empty.")
|
||||||
content = content[:MAX_MEMORY_CHARS]
|
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(
|
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()
|
).first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
count = db.scalar(
|
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:
|
if (count or 0) >= MAX_RECORDS:
|
||||||
# Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the
|
# 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(
|
memory = Memory(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
|
data_group_id=group,
|
||||||
content=content,
|
content=content,
|
||||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL,
|
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()
|
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.
|
"""The memories as they appear in the prompt, within the budget.
|
||||||
|
|
||||||
Oldest first, and truncation drops the *newest* -- a fact that has survived
|
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
|
a long time is more likely to be a standing preference than something said
|
||||||
once this morning.
|
once this morning.
|
||||||
"""
|
"""
|
||||||
records = all_for(db, user)
|
records = all_for(db, user, group)
|
||||||
if not records:
|
if not records:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
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 import sharing
|
||||||
from lembas.services.library import retrieval
|
from lembas.services.library import retrieval
|
||||||
|
|
||||||
@@ -26,20 +26,25 @@ MAX_BODY_CHARS = 40_000
|
|||||||
SNIPPET_CHARS = 800
|
SNIPPET_CHARS = 800
|
||||||
|
|
||||||
|
|
||||||
def visible(db: DBSession, user: User | None):
|
def visible(db: DBSession, user: User | None, group: str | None = None):
|
||||||
return select(Note).where(sharing.visible_to(Note, user))
|
"""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)
|
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 None
|
||||||
return note
|
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(
|
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,
|
limit: int = 10,
|
||||||
vector: list[float] | None = None,
|
vector: list[float] | None = None,
|
||||||
|
group: str | None = None,
|
||||||
) -> list[Note]:
|
) -> list[Note]:
|
||||||
"""Notes matching `needle` that this user may see, best match first.
|
"""Notes matching `needle` that this user may see, best match first.
|
||||||
|
|
||||||
@@ -62,16 +68,23 @@ def search(
|
|||||||
if not hits:
|
if not hits:
|
||||||
return []
|
return []
|
||||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
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)))
|
rows.sort(key=lambda note: order.get(note.id, len(order)))
|
||||||
return rows[:limit]
|
return rows[:limit]
|
||||||
|
|
||||||
|
|
||||||
def create(
|
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 = Note(
|
note = Note(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
|
data_group_id=group or DEFAULT_GROUP,
|
||||||
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
|
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
|
||||||
body=body.strip()[:MAX_BODY_CHARS],
|
body=body.strip()[:MAX_BODY_CHARS],
|
||||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
|
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def embeddable(db: DBSession) -> bool:
|
|||||||
return indexing.enabled(db)
|
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.
|
"""The configured embedder, resolved while a session is open.
|
||||||
|
|
||||||
Split from the awaiting half deliberately. A caller that must not hold a
|
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
|
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:
|
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:
|
except LLMError as exc:
|
||||||
log.info("could not embed a query: %s", exc)
|
log.info("could not embed a query: %s", exc)
|
||||||
return None
|
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:
|
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
|
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
|
of embedding model with a rebuild still pending, and scoring across two
|
||||||
spaces produces a confident wrong answer rather than a missing one.
|
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:
|
if not vector:
|
||||||
return []
|
return []
|
||||||
width = len(vector)
|
width = len(vector)
|
||||||
rows = db.execute(
|
query = select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(
|
||||||
select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(Chunk.resource_type == kind)
|
Chunk.resource_type == kind
|
||||||
).all()
|
)
|
||||||
|
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] = {}
|
best: dict[str, float] = {}
|
||||||
for resource_id, blob, dims in rows:
|
for resource_id, blob, dims in rows:
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ from collections.abc import Iterable
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
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 import sharing
|
||||||
from lembas.services.library import retrieval
|
from lembas.services.library import retrieval
|
||||||
|
|
||||||
@@ -55,18 +63,23 @@ def slugify(name: str) -> str:
|
|||||||
return cleaned[:60]
|
return cleaned[:60]
|
||||||
|
|
||||||
|
|
||||||
def visible(db: DBSession, user: User | None):
|
def visible(db: DBSession, user: User | None, group: str | None = None):
|
||||||
return select(Skill).where(sharing.visible_to(Skill, user))
|
"""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)
|
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 None
|
||||||
return skill
|
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.
|
"""Look one up the way the model refers to it.
|
||||||
|
|
||||||
Scoped to what this person can **see**, which is theirs plus anything
|
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:
|
if user is None:
|
||||||
return 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:
|
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(
|
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]:
|
) -> list[Skill]:
|
||||||
"""Skills that should appear in the index, oldest first for a stable order.
|
"""Skills that should appear in the index, oldest first for a stable order.
|
||||||
|
|
||||||
@@ -112,7 +129,7 @@ def enabled_for(
|
|||||||
return []
|
return []
|
||||||
hidden = {slugify(name) for name in exclude}
|
hidden = {slugify(name) for name in exclude}
|
||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
visible(db, user)
|
visible(db, user, group)
|
||||||
.where(Skill.enabled.is_(True))
|
.where(Skill.enabled.is_(True))
|
||||||
.order_by(Skill.name)
|
.order_by(Skill.name)
|
||||||
.limit(MAX_INDEX_SKILLS + len(hidden))
|
.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]
|
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.
|
"""How many skills are available here at all.
|
||||||
|
|
||||||
Zero is what withdraws `skill_get` and `skill_edit`: reading and improving
|
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
|
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.
|
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(
|
def search(
|
||||||
@@ -137,6 +160,7 @@ def search(
|
|||||||
*,
|
*,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
vector: list[float] | None = None,
|
vector: list[float] | None = None,
|
||||||
|
group: str | None = None,
|
||||||
) -> list[Skill]:
|
) -> list[Skill]:
|
||||||
"""Skills matching `needle` that this user may see, best match first.
|
"""Skills matching `needle` that this user may see, best match first.
|
||||||
|
|
||||||
@@ -149,7 +173,7 @@ def search(
|
|||||||
if not hits:
|
if not hits:
|
||||||
return []
|
return []
|
||||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
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)))
|
rows.sort(key=lambda skill: order.get(skill.id, len(order)))
|
||||||
return rows[:limit]
|
return rows[:limit]
|
||||||
|
|
||||||
@@ -175,6 +199,7 @@ def create(
|
|||||||
description: str,
|
description: str,
|
||||||
body: str,
|
body: str,
|
||||||
author: str = AUTHOR_USER,
|
author: str = AUTHOR_USER,
|
||||||
|
group: str = DEFAULT_GROUP,
|
||||||
) -> Skill:
|
) -> Skill:
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
if not SKILL_NAME_PATTERN.match(slug):
|
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, "
|
"A skill name must be two or more letters, numbers or hyphens, "
|
||||||
"such as 'weekly-report'."
|
"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.")
|
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
|
||||||
if not description.strip():
|
if not description.strip():
|
||||||
raise SkillError(
|
raise SkillError(
|
||||||
@@ -192,6 +227,7 @@ def create(
|
|||||||
|
|
||||||
skill = Skill(
|
skill = Skill(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
|
data_group_id=group or DEFAULT_GROUP,
|
||||||
name=slug,
|
name=slug,
|
||||||
description=description.strip()[:MAX_DESCRIPTION_CHARS],
|
description=description.strip()[:MAX_DESCRIPTION_CHARS],
|
||||||
body=body.strip()[:MAX_BODY_CHARS],
|
body=body.strip()[:MAX_BODY_CHARS],
|
||||||
@@ -257,9 +293,15 @@ def delete(db: DBSession, skill: Skill) -> None:
|
|||||||
db.commit()
|
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."""
|
"""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:
|
if not skills:
|
||||||
return ""
|
return ""
|
||||||
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
|
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -31,6 +31,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
from lembas.db.models import (
|
from lembas.db.models import (
|
||||||
AUTHOR_MODEL,
|
AUTHOR_MODEL,
|
||||||
AUTHOR_USER,
|
AUTHOR_USER,
|
||||||
|
DEFAULT_GROUP,
|
||||||
Impression,
|
Impression,
|
||||||
Persona,
|
Persona,
|
||||||
PersonaRevision,
|
PersonaRevision,
|
||||||
@@ -54,8 +55,39 @@ MAX_VIEW_CHARS = 800
|
|||||||
# a model editing itself every turn cannot grow the table without limit.
|
# a model editing itself every turn cannot grow the table without limit.
|
||||||
MAX_REVISIONS = 20
|
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:
|
def get(db: DBSession, model_key: str, owner: User | None) -> Persona | None:
|
||||||
"""One personality row, exactly as asked for and with no fallback.
|
"""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)
|
own = get(db, model_key, owner)
|
||||||
if own is not None:
|
if own is not None:
|
||||||
return own
|
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]:
|
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__ = [
|
__all__ = [
|
||||||
|
"KEY_SEPARATOR",
|
||||||
"MAX_PERSONA_CHARS",
|
"MAX_PERSONA_CHARS",
|
||||||
"MAX_REVISIONS",
|
"MAX_REVISIONS",
|
||||||
"MAX_VIEW_CHARS",
|
"MAX_VIEW_CHARS",
|
||||||
@@ -312,8 +346,10 @@ __all__ = [
|
|||||||
"get",
|
"get",
|
||||||
"impression",
|
"impression",
|
||||||
"impressions_for",
|
"impressions_for",
|
||||||
|
"key_for",
|
||||||
"personas_for",
|
"personas_for",
|
||||||
"personas_of",
|
"personas_of",
|
||||||
|
"split_key",
|
||||||
"view_block",
|
"view_block",
|
||||||
"revert",
|
"revert",
|
||||||
"write",
|
"write",
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ VARIABLES: tuple[Variable, ...] = (
|
|||||||
"page, bounded, and empty unless this model may ask one of them a "
|
"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.",
|
"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(
|
Variable(
|
||||||
"persona",
|
"persona",
|
||||||
"Its personality with this person",
|
"Its personality with this person",
|
||||||
@@ -1366,6 +1374,28 @@ BUILTIN: tuple[Fragment, ...] = (
|
|||||||
"because a helper made it."
|
"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(
|
Fragment(
|
||||||
key="tool.subagent_agent",
|
key="tool.subagent_agent",
|
||||||
label="Helpers on a machine",
|
label="Helpers on a machine",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
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 import sharing
|
||||||
from lembas.services.library import retrieval
|
from lembas.services.library import retrieval
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ MAX_BODY_CHARS = 60_000
|
|||||||
SNIPPET_CHARS = 400
|
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.
|
"""Every report this person owns or has been shared.
|
||||||
|
|
||||||
Takes no session because it builds a query rather than running one, and
|
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 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
|
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.
|
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)
|
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 None
|
||||||
return report
|
return report
|
||||||
|
|
||||||
@@ -67,8 +71,12 @@ def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Report]:
|
def recent(
|
||||||
return list(db.scalars(visible(user).order_by(Report.created_at.desc()).limit(limit)))
|
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(
|
def search(
|
||||||
@@ -78,6 +86,7 @@ def search(
|
|||||||
*,
|
*,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
vector: list[float] | None = None,
|
vector: list[float] | None = None,
|
||||||
|
group: str | None = None,
|
||||||
) -> list[Report]:
|
) -> list[Report]:
|
||||||
"""Reports matching `needle`, best match first.
|
"""Reports matching `needle`, best match first.
|
||||||
|
|
||||||
@@ -94,7 +103,7 @@ def search(
|
|||||||
if not hits:
|
if not hits:
|
||||||
return []
|
return []
|
||||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
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)))
|
rows.sort(key=lambda report: order.get(report.id, len(order)))
|
||||||
return rows[:limit]
|
return rows[:limit]
|
||||||
|
|
||||||
@@ -165,6 +174,7 @@ def create(
|
|||||||
model_id: str = "",
|
model_id: str = "",
|
||||||
error: str = "",
|
error: str = "",
|
||||||
unread: bool = True,
|
unread: bool = True,
|
||||||
|
group: str = DEFAULT_GROUP,
|
||||||
) -> Report:
|
) -> Report:
|
||||||
"""File a report.
|
"""File a report.
|
||||||
|
|
||||||
@@ -178,6 +188,7 @@ def create(
|
|||||||
"""
|
"""
|
||||||
report = Report(
|
report = Report(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
|
data_group_id=group or DEFAULT_GROUP,
|
||||||
title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS],
|
title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS],
|
||||||
summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS],
|
summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS],
|
||||||
body=(body or "").strip()[:MAX_BODY_CHARS],
|
body=(body or "").strip()[:MAX_BODY_CHARS],
|
||||||
|
|||||||
@@ -214,17 +214,29 @@ async def compile_request(
|
|||||||
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
|
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.
|
"""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
|
Built on a throwaway `Chat` that is never added to a session, exactly as
|
||||||
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
||||||
`connection_id` and nothing else, so it works unchanged and did not have to
|
`connection_id` and nothing else, so it works unchanged and did not have to
|
||||||
learn what a compile is.
|
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 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:
|
if not models:
|
||||||
return None
|
return None
|
||||||
chosen = next((m for m in models if m.pinned), models[0])
|
chosen = next((m for m in models if m.pinned), models[0])
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import logging
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from lembas.db.models import (
|
from lembas.db.models import (
|
||||||
|
DEFAULT_GROUP,
|
||||||
ROLE_ASSISTANT,
|
ROLE_ASSISTANT,
|
||||||
TARGET_CHAT,
|
TARGET_CHAT,
|
||||||
TARGET_MESSAGES,
|
TARGET_MESSAGES,
|
||||||
@@ -187,6 +188,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
|
|||||||
source_id=chat_id,
|
source_id=chat_id,
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
error="The run did not produce a reply.",
|
error="The run did not produce a reply.",
|
||||||
|
group=schedule.data_group_id or DEFAULT_GROUP,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
reports_service.create(
|
reports_service.create(
|
||||||
@@ -198,6 +200,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
|
|||||||
source_id=chat_id,
|
source_id=chat_id,
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
model_id=message.model_id or "",
|
model_id=message.model_id or "",
|
||||||
|
group=schedule.data_group_id or DEFAULT_GROUP,
|
||||||
)
|
)
|
||||||
return
|
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.
|
# not write it, and the bubble should not imply they did.
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import messages as messages_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)
|
conversation = messages_service.for_user(db, owner)
|
||||||
chat_service.create_message(
|
chat_service.create_message(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from lembas.db.models import (
|
from lembas.db.models import (
|
||||||
|
KIND_MESSAGES,
|
||||||
KIND_TASK,
|
KIND_TASK,
|
||||||
ORIGIN_USER,
|
ORIGIN_USER,
|
||||||
ORIGINS,
|
ORIGINS,
|
||||||
TARGET_CHAT,
|
TARGET_CHAT,
|
||||||
|
TARGET_MESSAGES,
|
||||||
TARGETS,
|
TARGETS,
|
||||||
Chat,
|
Chat,
|
||||||
Schedule,
|
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()
|
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:
|
def count_for(db: DBSession, user: User) -> int:
|
||||||
return int(
|
return int(
|
||||||
db.scalar(
|
db.scalar(
|
||||||
@@ -103,6 +150,13 @@ def create(
|
|||||||
# on, so it is refused at the only moment somebody is present to be told.
|
# 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.")
|
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)
|
limit = int(settings_store.schedules(db).get("max_per_user") or 20)
|
||||||
if count_for(db, owner) >= limit:
|
if count_for(db, owner) >= limit:
|
||||||
raise ScheduleError(
|
raise ScheduleError(
|
||||||
@@ -115,6 +169,7 @@ def create(
|
|||||||
kind=KIND_TASK,
|
kind=KIND_TASK,
|
||||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||||
model_id=model_id or "",
|
model_id=model_id or "",
|
||||||
|
data_group_id=group,
|
||||||
# Said on the row as well as implied by the kind. `tools.unattended`
|
# 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
|
# 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
|
# 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,
|
target=target if target in TARGETS else TARGET_CHAT,
|
||||||
chat_id=chat.id,
|
chat_id=chat.id,
|
||||||
model_id=model_id or "",
|
model_id=model_id or "",
|
||||||
|
data_group_id=group,
|
||||||
origin=origin if origin in ORIGINS else ORIGIN_USER,
|
origin=origin if origin in ORIGINS else ORIGIN_USER,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
next_fire_at=rule_service.next_after(
|
next_fire_at=rule_service.next_after(
|
||||||
@@ -162,6 +218,10 @@ def update(
|
|||||||
if instruction is not None:
|
if instruction is not None:
|
||||||
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
|
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
|
||||||
if target is not None and target in TARGETS:
|
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
|
schedule.target = target
|
||||||
if rule is not None:
|
if rule is not None:
|
||||||
clean = rule_service.validate(rule)
|
clean = rule_service.validate(rule)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ IMAGES = "images"
|
|||||||
SCHEDULES = "schedules"
|
SCHEDULES = "schedules"
|
||||||
SUBAGENTS = "subagents"
|
SUBAGENTS = "subagents"
|
||||||
CROWD = "crowd"
|
CROWD = "crowd"
|
||||||
|
RULES = "rules"
|
||||||
BRANDING = "branding"
|
BRANDING = "branding"
|
||||||
EXTRACTION = "extraction"
|
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]:
|
def _crowd_defaults() -> dict[str, Any]:
|
||||||
"""Several models answering one turn, in order, then again in reverse.
|
"""Several models answering one turn, in order, then again in reverse.
|
||||||
|
|
||||||
@@ -431,6 +442,7 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
SCHEDULES: _schedules_defaults,
|
SCHEDULES: _schedules_defaults,
|
||||||
SUBAGENTS: _subagents_defaults,
|
SUBAGENTS: _subagents_defaults,
|
||||||
CROWD: _crowd_defaults,
|
CROWD: _crowd_defaults,
|
||||||
|
RULES: _rules_defaults,
|
||||||
# Whose instance this is. The defaults live in `services/branding.py`
|
# 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
|
# 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
|
# 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
|
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:
|
def images_ready(db: DBSession) -> bool:
|
||||||
"""Whether image generation can actually happen.
|
"""Whether image generation can actually happen.
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
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.
|
"""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
|
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
|
filtering, ordering and pagination without this module knowing about any of
|
||||||
it.
|
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:
|
if user is None:
|
||||||
# Signed out sees nothing. Not an empty library -- no library.
|
# 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 [""]),
|
(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]:
|
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
|
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:
|
if user is None or resource is None:
|
||||||
return False
|
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:
|
if resource.owner_id == user.id:
|
||||||
return True
|
return True
|
||||||
users, groups = principal_ids(user)
|
users, groups = principal_ids(user)
|
||||||
|
|||||||
+113
-46
@@ -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.models import KIND_AGENT, KIND_CHAT, Chat, Model, User
|
||||||
from lembas.db.session import session_scope
|
from lembas.db.session import session_scope
|
||||||
from lembas.security import permissions
|
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
|
from lembas.services.agent import policy as agent_policy
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
@@ -196,6 +196,7 @@ def _create_child(
|
|||||||
title: str,
|
title: str,
|
||||||
write: bool,
|
write: bool,
|
||||||
friend: Model | None = None,
|
friend: Model | None = None,
|
||||||
|
helper: Model | None = None,
|
||||||
) -> Chat:
|
) -> Chat:
|
||||||
"""The hidden chat one helper or one friend runs in.
|
"""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
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
peer = friend is not None
|
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(
|
child = Chat(
|
||||||
user_id=parent.user_id,
|
user_id=parent.user_id,
|
||||||
# An ordinary chat for a friend even when the asking one is an agent
|
# 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.
|
# and a peer being asked a question is not working on one.
|
||||||
kind=KIND_CHAT if peer else parent.kind,
|
kind=KIND_CHAT if peer else parent.kind,
|
||||||
title=title[:200] or ("Question" if peer else "Helper"),
|
title=title[:200] or ("Question" if peer else "Helper"),
|
||||||
model_id=friend.model_id if peer else parent.model_id,
|
model_id=other.model_id if other is not None else parent.model_id,
|
||||||
connection_id=friend.connection_id if peer else parent.connection_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.
|
# Never in a listing, and swept a day later even if it is kept.
|
||||||
temporary=True,
|
temporary=True,
|
||||||
parent_chat_id=parent.id,
|
parent_chat_id=parent.id,
|
||||||
@@ -245,8 +259,11 @@ def _create_child(
|
|||||||
child.agent_mode = MODE_WRITING if write else MODE_READING
|
child.agent_mode = MODE_WRITING if write else MODE_READING
|
||||||
if peer:
|
if peer:
|
||||||
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
|
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
|
||||||
effort = str((friend.params_json or {}).get("reasoning_effort") or "")
|
if other is not None:
|
||||||
if effort not in chat_service.efforts_for(friend):
|
# 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 = ""
|
effort = ""
|
||||||
else:
|
else:
|
||||||
effort = chat_service.resolved_effort(parent)
|
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]
|
title = str(args.get("title") or "").strip() or task[:60]
|
||||||
briefing = str(args.get("context") or "")
|
briefing = str(args.get("context") or "")
|
||||||
want_write = bool(args.get("write"))
|
want_write = bool(args.get("write"))
|
||||||
|
wanted_model = str(args.get("model") or "")
|
||||||
|
|
||||||
if not task:
|
if not task:
|
||||||
return _error(
|
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
|
if owner is None: # pragma: no cover - a chat outliving its owner
|
||||||
return _error("That account no longer exists.", task=task)
|
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
|
# 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*
|
# 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
|
# 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:
|
if refusal:
|
||||||
return _error(refusal, task=task)
|
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
|
child_id = child.id
|
||||||
|
|
||||||
_LIVE.add(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 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
|
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
|
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.
|
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
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
question_for = wanted.strip()
|
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:
|
if not candidates:
|
||||||
return None, (
|
return None, (
|
||||||
"There is no other model here to ask. Answer from what you know."
|
"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, (
|
return None, (
|
||||||
"Name the model to ask, exactly as it is written in brackets in the "
|
"Name the model to ask, exactly as it is written in brackets in the "
|
||||||
"list you were given:\n"
|
"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()
|
lowered = question_for.lower()
|
||||||
@@ -603,7 +642,7 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
|
|||||||
return None, (
|
return None, (
|
||||||
f"There is no model called {question_for!r} that you can reach. "
|
f"There is no model called {question_for!r} that you can reach. "
|
||||||
"These are the ones you can:\n"
|
"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
|
if owner is None: # pragma: no cover - a chat outliving its owner
|
||||||
return _friend_error("That account no longer exists.", question=question)
|
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:
|
if friend is None:
|
||||||
return _friend_error(refusal, question=question)
|
return _friend_error(refusal, question=question)
|
||||||
|
|
||||||
@@ -791,10 +836,27 @@ def friend_tool_defs() -> list[ToolDef]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def tool_defs() -> list[ToolDef]:
|
def tool_defs(models: list[str] | None = None) -> list[ToolDef]:
|
||||||
"""The one tool, built here so `services/tools.py` need not know the wording."""
|
"""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
|
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 [
|
return [
|
||||||
ToolDef(
|
ToolDef(
|
||||||
name="subagent_run",
|
name="subagent_run",
|
||||||
@@ -815,39 +877,7 @@ def tool_defs() -> list[ToolDef]:
|
|||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": 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."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["task"],
|
"required": ["task"],
|
||||||
},
|
},
|
||||||
run=_run_subagent,
|
run=_run_subagent,
|
||||||
@@ -873,3 +903,40 @@ __all__ = [
|
|||||||
"live_count",
|
"live_count",
|
||||||
"tool_defs",
|
"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."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -32,7 +32,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
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.db.session import session_scope
|
||||||
from lembas.services import personas as personas_service
|
from lembas.services import personas as personas_service
|
||||||
from lembas.services import prompts as prompts_service
|
from lembas.services import prompts as prompts_service
|
||||||
@@ -289,6 +289,12 @@ class ToolContext:
|
|||||||
image_checkpoint: str = ""
|
image_checkpoint: str = ""
|
||||||
model_id: str = ""
|
model_id: str = ""
|
||||||
connection_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
|
@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.
|
# session held across one is the trade `_maybe_compact` already refuses.
|
||||||
# None for every "no" -- no model configured, endpoint down -- and the
|
# None for every "no" -- no model configured, endpoint down -- and the
|
||||||
# search is then exactly the keyword one it has always been.
|
# 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:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
user = db.get(User, context.owner_id)
|
||||||
found = documents_service.search(
|
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 = {
|
event = {
|
||||||
"name": "knowledge_search",
|
"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()
|
document_id = str(args.get("id") or "").strip()
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if document is None:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such document, or it is not available to you.",
|
"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)
|
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.
|
"""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
|
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
|
from lembas.services.library import retrieval
|
||||||
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
worker = retrieval.worker_for(db)
|
worker = retrieval.worker_for(db, group)
|
||||||
return await retrieval.embed_with(worker, query)
|
return await retrieval.embed_with(worker, query)
|
||||||
|
|
||||||
|
|
||||||
# --- Notes -------------------------------------------------------------------
|
# --- Notes -------------------------------------------------------------------
|
||||||
async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
query = str(args.get("query") or "").strip()
|
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:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
user = db.get(User, context.owner_id)
|
||||||
found = (
|
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
|
if query
|
||||||
else notes_service.recent(db, user, limit=8)
|
else notes_service.recent(db, user, limit=8, group=context.data_group)
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": "notes_search",
|
"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:
|
async def _run_notes_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if note is None:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such note, or it is not available to you.",
|
"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:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
user = db.get(User, context.owner_id)
|
||||||
note = notes_service.create(
|
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(
|
return ToolOutcome(
|
||||||
f"Saved note {note.id} — {note.title!r}.",
|
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:
|
async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if note is None or note.owner_id != context.owner_id:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such note, or it belongs to someone else. A note "
|
"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:
|
async def _run_notes_delete(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if note is None or note.owner_id != context.owner_id:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such note, or it belongs to someone else.",
|
"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.")
|
return _persona_error("persona_write", "There is nobody here to be this with.")
|
||||||
row = personas_service.write(
|
row = personas_service.write(
|
||||||
db,
|
db,
|
||||||
model_key=context.model_id,
|
model_key=personas_service.key_for(context.model_id, context.data_group),
|
||||||
owner=user,
|
owner=user,
|
||||||
content=content,
|
content=content,
|
||||||
author=AUTHOR_MODEL,
|
author=AUTHOR_MODEL,
|
||||||
@@ -782,7 +801,9 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
|
|||||||
if user is None:
|
if user is None:
|
||||||
return _persona_error("impression_write", "There is nobody here to describe.")
|
return _persona_error("impression_write", "There is nobody here to describe.")
|
||||||
if not content:
|
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:
|
if row is not None:
|
||||||
personas_service.clear_impression(db, row)
|
personas_service.clear_impression(db, row)
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
@@ -791,7 +812,7 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
|
|||||||
)
|
)
|
||||||
row = personas_service.write_impression(
|
row = personas_service.write_impression(
|
||||||
db,
|
db,
|
||||||
model_key=context.model_id,
|
model_key=personas_service.key_for(context.model_id, context.data_group),
|
||||||
owner=user,
|
owner=user,
|
||||||
content=content,
|
content=content,
|
||||||
author=AUTHOR_MODEL,
|
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)
|
user = db.get(User, context.owner_id)
|
||||||
try:
|
try:
|
||||||
memory = memories_service.add(
|
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:
|
except ValueError as exc:
|
||||||
return ToolOutcome(
|
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()
|
wanted = str(args.get("content") or "").strip().lower()
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if not wanted:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"Say which memory to remove, quoting its text.",
|
"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=SOURCE_CHAT,
|
||||||
source_id=context.chat_id or "",
|
source_id=context.chat_id or "",
|
||||||
model_id=context.model_id or "",
|
model_id=context.model_id or "",
|
||||||
|
group=context.data_group,
|
||||||
)
|
)
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
f"Filed report {report.id} — {report.title!r}. "
|
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:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
user = db.get(User, context.owner_id)
|
||||||
found = (
|
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
|
if query
|
||||||
else reports_service.recent(db, user, limit=8)
|
else reports_service.recent(db, user, limit=8, group=context.data_group)
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": "report_search",
|
"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:
|
async def _run_report_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if report is None:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such report.",
|
"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()
|
name = str(args.get("name") or "").strip()
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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
|
# 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 --
|
# 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.
|
# 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 ""),
|
description=str(args.get("description") or ""),
|
||||||
body=str(args.get("body") or ""),
|
body=str(args.get("body") or ""),
|
||||||
author=AUTHOR_MODEL,
|
author=AUTHOR_MODEL,
|
||||||
|
group=context.data_group,
|
||||||
)
|
)
|
||||||
except skills_service.SkillError as exc:
|
except skills_service.SkillError as exc:
|
||||||
return ToolOutcome(
|
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:
|
async def _run_skill_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
user = db.get(User, context.owner_id)
|
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:
|
if skill is None or skill.owner_id != context.owner_id:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
"There is no such skill, or it belongs to someone else.",
|
"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()
|
return schedule_tool.tool_defs()
|
||||||
|
|
||||||
|
|
||||||
def _subagent_defs() -> list[ToolDef]:
|
def _subagent_registry_defs() -> list[ToolDef]:
|
||||||
"""The subagent tool. Imported inside the call for the reason above."""
|
|
||||||
from lembas.services import subagent as subagent_service
|
from lembas.services import subagent as subagent_service
|
||||||
|
|
||||||
return subagent_service.tool_defs()
|
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]:
|
def _friend_defs() -> list[ToolDef]:
|
||||||
"""The ask-a-friend tool. Same module, same reason for the late import."""
|
"""The ask-a-friend tool. Same module, same reason for the late import."""
|
||||||
from lembas.services import subagent as subagent_service
|
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
|
# reaches the model. That omission has cost two features their
|
||||||
# instructions already.
|
# instructions already.
|
||||||
*_schedule_defs(),
|
*_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(),
|
*_friend_defs(),
|
||||||
*_crowd_defs(),
|
*_crowd_defs(),
|
||||||
]
|
]
|
||||||
@@ -1859,7 +1906,7 @@ def resolve_tools(
|
|||||||
*_agent_defs(db, chat, user),
|
*_agent_defs(db, chat, user),
|
||||||
*(_image_defs(db, image_values) if images_ready else []),
|
*(_image_defs(db, image_values) if images_ready else []),
|
||||||
*(_schedule_defs() if schedules_on 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 []),
|
*(_friend_defs() if subagents_on else []),
|
||||||
# Only on the closing turn, and only with a round left. Not gated on a
|
# 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
|
# 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
|
# something on here would still be reaching for a tool the gates had
|
||||||
# already removed.
|
# already removed.
|
||||||
off = scoped_off(chat)
|
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.
|
# What a crowd speaker may do, which is narrower than what the chat may.
|
||||||
if crowd_turn is not None:
|
if crowd_turn is not None:
|
||||||
@@ -2095,6 +2151,7 @@ def context_for(
|
|||||||
model.
|
model.
|
||||||
"""
|
"""
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import data_groups
|
||||||
from lembas.services.agent import session as agent_session
|
from lembas.services.agent import session as agent_session
|
||||||
|
|
||||||
if chat is not None and speaker is None:
|
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 "",
|
image_checkpoint=(chat.image_checkpoint or "") if chat is not None else "",
|
||||||
model_id=(speaker.model_id or "") if speaker 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 "",
|
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 [],
|
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
|
||||||
skills_off=scoped_skills_off(chat),
|
skills_off=scoped_skills_off(chat),
|
||||||
tools=tools.by_name if tools is not None else None,
|
tools=tools.by_name if tools is not None else None,
|
||||||
|
|||||||
@@ -681,6 +681,8 @@ MESSAGES.update(
|
|||||||
"Model": "Model",
|
"Model": "Model",
|
||||||
"Context window": "Kontextové okno",
|
"Context window": "Kontextové okno",
|
||||||
"Sees images": "Vidí obrázky",
|
"Sees images": "Vidí obrázky",
|
||||||
|
"Loaded": "Načítaný",
|
||||||
|
"Loading": "Načítava sa",
|
||||||
"Groups": "Skupiny",
|
"Groups": "Skupiny",
|
||||||
"Members": "Členovia",
|
"Members": "Členovia",
|
||||||
"Account": "Účet",
|
"Account": "Účet",
|
||||||
@@ -1788,3 +1790,144 @@ MESSAGES.update(
|
|||||||
"https://mcp.example.com/mcp": "https://mcp.example.com/mcp",
|
"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.',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
|
|||||||
pinned to the scrollport with `background-attachment: local`, which is the old
|
pinned to the scrollport with `background-attachment: local`, which is the old
|
||||||
trick and works everywhere -- the `local` layers scroll with the content and
|
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 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 {
|
.tabs__bar {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, var(--bg) 40%, transparent),
|
linear-gradient(to right, var(--bg) 50%, transparent),
|
||||||
linear-gradient(to left, var(--bg) 40%, transparent),
|
linear-gradient(to left, var(--bg) 50%, transparent),
|
||||||
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
||||||
linear-gradient(to left, 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-position: left center, right center, left center, right center;
|
||||||
background-repeat: no-repeat;
|
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;
|
background-attachment: local, local, scroll, scroll;
|
||||||
/* A tab is a destination, so a flick should land on one rather than between
|
/* A tab is a destination, so a flick should land on one rather than between
|
||||||
two. */
|
two. */
|
||||||
|
|||||||
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
|
|||||||
child will not shrink below its content without it, so a scroller missing it
|
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
|
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
|
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,
|
.scroll-region,
|
||||||
.sidebar__scroll,
|
.sidebar__scroll,
|
||||||
.inspector__body,
|
.inspector__body,
|
||||||
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
|
|||||||
.thread-scroll,
|
.thread-scroll,
|
||||||
.admin-scroll,
|
.admin-scroll,
|
||||||
.main > .tabs > .tabs__body {
|
.main > .tabs > .tabs__body {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1764,6 +1775,33 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
gap: inherit;
|
gap: inherit;
|
||||||
}
|
}
|
||||||
.picker__list--models .picker__option .picker__avatar { margin-top: 0; }
|
.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; }
|
.picker__list--models .picker__option-name { min-width: 0; }
|
||||||
.model-ctx {
|
.model-ctx {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -1773,6 +1811,26 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
.model-vision { display: flex; color: var(--ink-faint); min-width: 1rem; }
|
.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__tick { display: flex; margin-top: 0; min-width: 1rem; visibility: hidden; }
|
||||||
.picker__list--models .picker__option.is-selected .picker__tick { visibility: visible; }
|
.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 {
|
.picker__empty {
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1896,3 +1954,47 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
|
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
|
||||||
box-shadow 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; }
|
||||||
|
|||||||
@@ -1003,9 +1003,17 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
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 {
|
.composer__inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
max-width: var(--thread-max-width);
|
max-width: var(--thread-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@
|
|||||||
--radius-xl: 18px;
|
--radius-xl: 18px;
|
||||||
--radius-full: 999px;
|
--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 ----------------------------------------------------------
|
--- Controls ----------------------------------------------------------
|
||||||
Every button, input and select resolves its height from these. That is the
|
Every button, input and select resolves its height from these. That is the
|
||||||
|
|||||||
@@ -279,6 +279,13 @@
|
|||||||
return document.getElementById("attachments");
|
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() {
|
function chatId() {
|
||||||
var input = document.getElementById("file-input");
|
var input = document.getElementById("file-input");
|
||||||
var url = (input && input.dataset.uploadUrl) || "";
|
var url = (input && input.dataset.uploadUrl) || "";
|
||||||
@@ -335,7 +342,9 @@
|
|||||||
var close = dialog.querySelector("button");
|
var close = dialog.querySelector("button");
|
||||||
|
|
||||||
function load(query) {
|
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",
|
credentials: "same-origin",
|
||||||
})
|
})
|
||||||
.then(function (response) { return response.text(); })
|
.then(function (response) { return response.text(); })
|
||||||
@@ -355,6 +364,7 @@
|
|||||||
var body = new FormData();
|
var body = new FormData();
|
||||||
body.append("document_id", option.dataset.attachKnowledge);
|
body.append("document_id", option.dataset.attachKnowledge);
|
||||||
body.append("chat_id", chatId());
|
body.append("chat_id", chatId());
|
||||||
|
body.append("model_id", modelId());
|
||||||
postForChip("/api/files/from-knowledge", body);
|
postForChip("/api/files/from-knowledge", body);
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
@@ -1099,27 +1109,59 @@
|
|||||||
The worker no longer takes over open pages on its own -- see sw.js -- so
|
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
|
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
|
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 watchForUpdate(registration) {
|
||||||
function offer(worker) {
|
function offer(worker) {
|
||||||
if (!worker || !navigator.serviceWorker.controller) return;
|
if (!worker || !navigator.serviceWorker.controller) return;
|
||||||
worker.addEventListener("statechange", function () {
|
worker.addEventListener("statechange", function () {
|
||||||
if (worker.state !== "installed") return;
|
if (worker.state !== "installed") return;
|
||||||
window.lembas.notify(
|
if (isNewerThanThisPage(worker)) offerReload(worker);
|
||||||
"A new version is ready. Reload to use it.",
|
|
||||||
{ kind: "info", action: { label: "Reload", run: function () {
|
|
||||||
worker.postMessage({ type: "SKIP_WAITING" });
|
|
||||||
} } }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
if (registration.waiting && navigator.serviceWorker.controller &&
|
||||||
window.lembas.notify(
|
isNewerThanThisPage(registration.waiting)) {
|
||||||
"A new version is ready. Reload to use it.",
|
offerReload(registration.waiting);
|
||||||
{ kind: "info", action: { label: "Reload", run: function () {
|
|
||||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
|
||||||
} } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
registration.addEventListener("updatefound", function () {
|
registration.addEventListener("updatefound", function () {
|
||||||
offer(registration.installing);
|
offer(registration.installing);
|
||||||
@@ -1130,17 +1172,24 @@
|
|||||||
the right answer to it -- the page is now being served by a worker whose
|
the right answer to it -- the page is now being served by a worker whose
|
||||||
cache it did not start from.
|
cache it did not start from.
|
||||||
|
|
||||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
Three guards, and the second is the one that is easy to miss. A flag,
|
||||||
`controllerchange` can fire more than once. And `hadController`, because on
|
because `controllerchange` can fire more than once. And `hadController`,
|
||||||
a *first* visit there is no worker at all: the one that installs then calls
|
because on a *first* visit there is no worker at all: the one that
|
||||||
`clients.claim()`, which fires this event for the first time -- so without
|
installs then calls `clients.claim()`, which fires this event for the
|
||||||
it, the very first page anybody loads reloads itself in front of them for
|
first time -- so without it, the very first page anybody loads reloads
|
||||||
no reason they could possibly work out. */
|
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;
|
var reloading = false;
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
var hadController = !!navigator.serviceWorker.controller;
|
var hadController = !!navigator.serviceWorker.controller;
|
||||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||||
if (reloading || !hadController) return;
|
if (reloading || !hadController) return;
|
||||||
|
var controller = navigator.serviceWorker.controller;
|
||||||
|
if (controller && !isNewerThanThisPage(controller)) return;
|
||||||
reloading = true;
|
reloading = true;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,7 +222,16 @@
|
|||||||
{
|
{
|
||||||
name: "temp",
|
name: "temp",
|
||||||
summary: "Start a temporary chat, gone after a day",
|
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",
|
name: "stop",
|
||||||
|
|||||||
@@ -62,7 +62,14 @@
|
|||||||
/* A chat under way carries its connection on the composer; a new one is
|
/* 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. */
|
still choosing it, so the select and the hidden field are the truth. */
|
||||||
profileId: picker ? picker.value : (box && box.dataset.profileId) || "",
|
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) +
|
"/api/files/mention-picker?q=" + encodeURIComponent(query) +
|
||||||
"&chat_id=" + encodeURIComponent(where.chatId) +
|
"&chat_id=" + encodeURIComponent(where.chatId) +
|
||||||
"&profile_id=" + encodeURIComponent(where.profileId) +
|
"&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" })
|
fetch(url, { credentials: "same-origin" })
|
||||||
.then(function (response) { return response.text(); })
|
.then(function (response) { return response.text(); })
|
||||||
@@ -266,6 +274,7 @@
|
|||||||
|
|
||||||
var body = new FormData();
|
var body = new FormData();
|
||||||
body.append("chat_id", where.chatId);
|
body.append("chat_id", where.chatId);
|
||||||
|
body.append("model_id", where.modelId);
|
||||||
if (option.dataset.mentionFile) {
|
if (option.dataset.mentionFile) {
|
||||||
body.append("profile_id", where.profileId);
|
body.append("profile_id", where.profileId);
|
||||||
body.append("path", option.dataset.mentionFile);
|
body.append("path", option.dataset.mentionFile);
|
||||||
|
|||||||
@@ -334,6 +334,45 @@
|
|||||||
// Keep the chosen model in view when the list is long.
|
// Keep the chosen model in view when the list is long.
|
||||||
var current = menu.querySelector(".picker__option.is-selected");
|
var current = menu.querySelector(".picker__option.is-selected");
|
||||||
if (current) current.scrollIntoView({ block: "nearest" });
|
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) {
|
function applyFilter(menu, needle) {
|
||||||
@@ -1133,7 +1172,19 @@ document.addEventListener("lembas:notify", function (event) {
|
|||||||
shrinks the document and scrollTop is clamped to the new maximum, which
|
shrinks the document and scrollTop is clamped to the new maximum, which
|
||||||
for a short panel is somewhere below everything. */
|
for a short panel is somewhere below everything. */
|
||||||
var outer = scroller(bar);
|
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>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label class="field__label" for="unload-{{ connection.id }}">{{ t("Unload URL") }}</label>
|
<label class="field__label" for="unload-{{ connection.id }}">{{ t("Unload URL") }}</label>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -105,6 +121,15 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="enabled" value="true"
|
<input type="checkbox" name="enabled" value="true"
|
||||||
|
|||||||
@@ -50,6 +50,12 @@
|
|||||||
{{ icon("sliders", "icon--sm") }}
|
{{ icon("sliders", "icon--sm") }}
|
||||||
<span class="nav-item__label">{{ t("Models") }}</span>
|
<span class="nav-item__label">{{ t("Models") }}</span>
|
||||||
</a>
|
</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
|
{# 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
|
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. #}
|
took it for, reasonably, since that is what the page is called. #}
|
||||||
@@ -57,6 +63,10 @@
|
|||||||
{{ icon("users", "icon--sm") }}
|
{{ icon("users", "icon--sm") }}
|
||||||
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
||||||
</a>
|
</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">
|
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
|
||||||
{{ icon("speaker", "icon--sm") }}
|
{{ icon("speaker", "icon--sm") }}
|
||||||
<span class="nav-item__label">{{ t("Audio") }}</span>
|
<span class="nav-item__label">{{ t("Audio") }}</span>
|
||||||
|
|||||||
@@ -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>
|
</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">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
<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
|
{# 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. #}
|
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">
|
<section class="card">
|
||||||
<h2 class="card__title">{{ t("Default personality") }}</h2>
|
<h2 class="card__title">{{ t("Default personality") }}</h2>
|
||||||
<p class="card__lede">
|
<p class="card__lede">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -152,6 +152,9 @@
|
|||||||
what `app.js` turns into a sentence on the settings page.
|
what `app.js` turns into a sentence on the settings page.
|
||||||
#}
|
#}
|
||||||
<script>
|
<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" };
|
window.lembasWorker = { state: "unsupported" };
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
/* Reported separately from an outright failure: the fix is different. */
|
/* Reported separately from an outright failure: the fix is different. */
|
||||||
|
|||||||
@@ -314,7 +314,60 @@
|
|||||||
because a browser submits only the ticked boxes and `start_chat`
|
because a browser submits only the ticked boxes and `start_chat`
|
||||||
needs to know which ones were not.
|
needs to know which ones were not.
|
||||||
#}
|
#}
|
||||||
{% if crowd_available %}
|
{#
|
||||||
|
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>
|
<div class="picker picker--up" data-picker>
|
||||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||||
aria-haspopup="menu" aria-expanded="false"
|
aria-haspopup="menu" aria-expanded="false"
|
||||||
@@ -366,6 +419,38 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% if crowd_member_ids %}
|
||||||
{# One sentence and not three, with the numbers as placeholders: a
|
{# One sentence and not three, with the numbers as placeholders: a
|
||||||
translation puts the parts in its own order, and two of these
|
translation puts the parts in its own order, and two of these
|
||||||
@@ -593,7 +678,10 @@
|
|||||||
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
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
|
htmx shows nothing when a request 405s -- which is how these controls
|
||||||
spent the first half of their lives doing nothing. #}
|
spent the first half of their lives doing nothing. #}
|
||||||
{% if chat and crowd_available %}
|
{% 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
|
{# 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
|
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
|
||||||
of that sentence matter. #}
|
of that sentence matter. #}
|
||||||
|
|||||||
@@ -38,14 +38,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="picker__list picker__list--models">
|
<div class="picker__list picker__list--models"
|
||||||
|
data-label-loaded="{{ t('Loaded') }}" data-label-loading="{{ t('Loading') }}">
|
||||||
{% for model in models %}
|
{% for model in models %}
|
||||||
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
||||||
type="button" role="option"
|
type="button" role="option"
|
||||||
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
|
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
|
||||||
data-picker-value="{{ model.model_id }}"
|
data-picker-value="{{ model.model_id }}"
|
||||||
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}"
|
||||||
{{ model_avatar(model, cls="picker__avatar") }}
|
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>
|
||||||
<span class="picker__option-name">
|
<span class="picker__option-name">
|
||||||
<span class="truncate">{{ model.label }}</span>
|
<span class="truncate">{{ model.label }}</span>
|
||||||
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||||
@@ -66,6 +80,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<p class="picker__empty" data-picker-empty hidden>{{ t("No model matches that.") }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if chat %}
|
{% if chat %}
|
||||||
@@ -77,7 +107,9 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# No chat yet: selecting navigates so the whole composer re-renders with the
|
{# No chat yet: selecting navigates so the whole composer re-renders with the
|
||||||
right vision warning and the right hidden model_id. #}
|
right vision warning and the right hidden model_id. The URL carries the
|
||||||
<span hidden data-picker-navigate="/chat?model="></span>
|
other preselections -- temporary, folder, kind -- and ends in `model=`,
|
||||||
|
which ui.js completes. #}
|
||||||
|
<span hidden data-picker-navigate="{{ model_navigate_url }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,11 +65,12 @@
|
|||||||
<div class="topbar__actions">
|
<div class="topbar__actions">
|
||||||
{#
|
{#
|
||||||
A link, not a script: the flag lives in the URL, so it survives a
|
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") %}
|
{% if not chat and can.get("chat.create") %}
|
||||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
<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') }}"
|
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 %}">
|
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") }}
|
{{ 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" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "knowledge" %}
|
{% set section = "knowledge" %}
|
||||||
|
|
||||||
{% block title %}{{ base.name }} - {{ brand.name }}{% endblock %}
|
{% block title %}{{ base.name }} - {{ brand.name }}{% endblock %}
|
||||||
@@ -102,6 +103,7 @@
|
|||||||
value="{{ base.description }}" {{ 'disabled' if not is_owner }}>
|
value="{{ base.description }}" {{ 'disabled' if not is_owner }}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if is_owner %}{{ group_field(base) }}{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% include "library/_share.html" %}
|
{% include "library/_share.html" %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "library/_layout.html" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "knowledge" %}
|
{% set section = "knowledge" %}
|
||||||
|
|
||||||
{% block title %}Knowledge - {{ brand.name }}{% endblock %}
|
{% block title %}Knowledge - {{ brand.name }}{% endblock %}
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
<a class="filter-tab {{ 'is-active' if shared }}"
|
<a class="filter-tab {{ 'is-active' if shared }}"
|
||||||
href="/library/knowledge?shared=1">Shared with me</a>
|
href="/library/knowledge?shared=1">Shared with me</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{ group_filter("/library/knowledge", group, {"shared": "1" if shared else ""}) }}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
|
{{ group_chip(base) }}
|
||||||
{% if base.owner_id != user.id %}<span class="badge">{{ t("shared with you") }}</span>{% endif %}
|
{% if base.owner_id != user.id %}<span class="badge">{{ t("shared with you") }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
placeholder="{{ t('What belongs in here.') }}">
|
placeholder="{{ t('What belongs in here.') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ group_field(None, group) }}
|
||||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "library/_layout.html" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "notes" %}
|
{% set section = "notes" %}
|
||||||
|
|
||||||
{% block title %}{{ note.title if note else "New note" }} - {{ brand.name }}{% endblock %}
|
{% block title %}{{ note.title if note else "New note" }} - {{ brand.name }}{% endblock %}
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
{{ 'disabled' if note and not is_owner }}
|
{{ 'disabled' if note and not is_owner }}
|
||||||
placeholder="{{ t('Markdown.') }}">{{ note.body if note else '' }}</textarea>
|
placeholder="{{ t('Markdown.') }}">{{ note.body if note else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not note or is_owner %}{{ group_field(note, group|default("")) }}{% endif %}
|
||||||
{% if note and note.author == "model" %}
|
{% if note and note.author == "model" %}
|
||||||
<p class="field__hint">
|
<p class="field__hint">
|
||||||
{{ icon("sparkle", "icon--sm") }}
|
{{ icon("sparkle", "icon--sm") }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "library/_layout.html" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "notes" %}
|
{% set section = "notes" %}
|
||||||
|
|
||||||
{% block title %}Notes - {{ brand.name }}{% endblock %}
|
{% 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 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>
|
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
|
||||||
</div>
|
</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)">
|
<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"
|
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||||
placeholder="{{ t('Search notes…') }}">
|
placeholder="{{ t('Search notes…') }}">
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
<div class="text-xs faint">{{ note.body[:160] }}{{ "…" if note.body|length > 160 }}</div>
|
<div class="text-xs faint">{{ note.body[:160] }}{{ "…" if note.body|length > 160 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<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.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 %}
|
{% if note.owner_id != user.id %}<span class="badge">{{ t("shared") }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "library/_layout.html" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "skills" %}
|
{% set section = "skills" %}
|
||||||
|
|
||||||
{% block title %}{{ skill.name if skill else "New skill" }} - {{ brand.name }}{% endblock %}
|
{% block title %}{{ skill.name if skill else "New skill" }} - {{ brand.name }}{% endblock %}
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
{{ 'disabled' if skill and not is_owner }}
|
{{ 'disabled' if skill and not is_owner }}
|
||||||
placeholder="{{ t('Markdown. Steps, conventions, things to avoid.') }}">{{ skill.body if skill else '' }}</textarea>
|
placeholder="{{ t('Markdown. Steps, conventions, things to avoid.') }}">{{ skill.body if skill else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not skill or is_owner %}{{ group_field(skill, group|default("")) }}{% endif %}
|
||||||
|
|
||||||
{% if skill %}
|
{% if skill %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "library/_layout.html" %}
|
{% extends "library/_layout.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon %}
|
||||||
|
{% from "library/_group.html" import group_chip, group_field, group_filter with context %}
|
||||||
{% set section = "skills" %}
|
{% set section = "skills" %}
|
||||||
|
|
||||||
{% block title %}Skills - {{ brand.name }}{% endblock %}
|
{% 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 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>
|
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
|
||||||
</div>
|
</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)">
|
<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"
|
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||||
placeholder="{{ t('Search skills…') }}">
|
placeholder="{{ t('Search skills…') }}">
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
<div class="text-xs faint">{{ skill.description }}</div>
|
<div class="text-xs faint">{{ skill.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
|
{{ group_chip(skill) }}
|
||||||
{% if not skill.enabled %}<span class="badge">{{ t("off") }}</span>{% endif %}
|
{% 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.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 %}
|
{% 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 %}
|
||||||
@@ -57,6 +57,11 @@
|
|||||||
<label class="tabs__tab" for="tab-memory">{{ icon("sparkle", "icon--sm") }} Memory</label>
|
<label class="tabs__tab" for="tab-memory">{{ icon("sparkle", "icon--sm") }} Memory</label>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +185,100 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</section>
|
||||||
|
|
||||||
{# --- Appearance --- #}
|
{# --- Appearance --- #}
|
||||||
@@ -388,6 +487,9 @@
|
|||||||
<input class="input" name="content" value="{{ memory.content }}"
|
<input class="input" name="content" value="{{ memory.content }}"
|
||||||
maxlength="{{ memory_limit }}" style="flex: 1"
|
maxlength="{{ memory_limit }}" style="flex: 1"
|
||||||
aria-label="{{ t('What this memory says') }}">
|
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" type="submit">{{ t("Save") }}</button>
|
||||||
<button class="btn btn--sm btn--danger" type="submit"
|
<button class="btn btn--sm btn--danger" type="submit"
|
||||||
formaction="/api/library/memories/{{ memory.id }}/delete"
|
formaction="/api/library/memories/{{ memory.id }}/delete"
|
||||||
@@ -416,6 +518,14 @@
|
|||||||
maxlength="{{ memory_limit }}"
|
maxlength="{{ memory_limit }}"
|
||||||
aria-label="{{ t('Something worth remembering') }}"
|
aria-label="{{ t('Something worth remembering') }}"
|
||||||
placeholder="{{ t('Prefers metric units and a 24-hour clock.') }}">
|
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>
|
<button class="btn btn--primary" type="submit">{{ t("Remember") }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="field__hint">
|
<p class="field__hint">
|
||||||
@@ -439,7 +549,9 @@
|
|||||||
{% for personality in personalities %}
|
{% for personality in personalities %}
|
||||||
<li class="model-list__item">
|
<li class="model-list__item">
|
||||||
<div style="min-width: 0">
|
<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" %}
|
{% if personality.author == "model" %}
|
||||||
<span class="badge badge--leaf">{{ t("its own words") }}</span>
|
<span class="badge badge--leaf">{{ t("its own words") }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -484,7 +596,9 @@
|
|||||||
{% for impression in impressions %}
|
{% for impression in impressions %}
|
||||||
<li class="model-list__item">
|
<li class="model-list__item">
|
||||||
<div style="min-width: 0">
|
<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 class="text-sm">{{ impression.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
@@ -504,6 +618,88 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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 --- #}
|
{# --- Security --- #}
|
||||||
<section class="tabs__panel" data-tab="tab-security">
|
<section class="tabs__panel" data-tab="tab-security">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -448,3 +448,16 @@ def test_only_an_administrator_may_customise(db, client, registered):
|
|||||||
|
|
||||||
for path in ("identity", "flavour", "css", "themes"):
|
for path in ("identity", "flavour", "css", "themes"):
|
||||||
assert client.post(f"/admin/customization/{path}", data={}).status_code == 403
|
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
|
||||||
|
|||||||
@@ -58,3 +58,12 @@ def test_send_from_anywhere_never_means_stop():
|
|||||||
stops."""
|
stops."""
|
||||||
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
|
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
|
||||||
assert 'composerAction === "send"' in window
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -582,3 +582,25 @@ def test_an_endpoint_with_no_props_leaves_the_list_alone(client, db, registered,
|
|||||||
|
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Model, model.id).reasoning_efforts == ["low", "high"]
|
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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
|
|||||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||||
assert 'path + "?v=" + VERSION' in source
|
assert 'path + "?v=" + VERSION' in source
|
||||||
assert "versioned(path)" in source
|
assert "versioned(path)" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_update_offer_asks_whether_this_page_is_older(client: TestClient, registered):
|
||||||
|
"""After a release the toast offered a reload on every page, including one
|
||||||
|
just fetched with Ctrl+Shift+R, and reloading never made it go away. It
|
||||||
|
fired whenever a worker was waiting. But a page loaded after the update
|
||||||
|
already IS the update: it comes from the network, and every asset it names
|
||||||
|
carries `?v=`. The worker that waits is nearly always the previous one,
|
||||||
|
still holding the tab, because a reload never lets it run out of pages.
|
||||||
|
|
||||||
|
So the offer, and the automatic reload when another tab accepts it, compare
|
||||||
|
the worker's release with the page's own. Driven under Node against stubs
|
||||||
|
before committing: 1.8.3 offered a reload to a current page and to a page
|
||||||
|
that another tab's Reload had just made current. This does neither, and
|
||||||
|
still offers it to a page from an older release.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import lembas
|
||||||
|
|
||||||
|
page = client.get("/chat").text
|
||||||
|
assert f"window.lembasRelease = \"{lembas.__version__}\";" in page
|
||||||
|
|
||||||
|
source = (STATIC_DIR / "js" / "app.js").read_text()
|
||||||
|
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
|
||||||
|
start = code.index("function watchForUpdate")
|
||||||
|
block = code[start : code.index("navigator.serviceWorker.ready.then(watchForUpdate)")]
|
||||||
|
# Both ways a waiting worker is found, and the controllerchange reload.
|
||||||
|
assert block.count("isNewerThanThisPage(") == 3
|
||||||
|
assert 'searchParams.get("v")' in code
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""`talk.decide`, the one function behind every talk rule, as a table.
|
||||||
|
|
||||||
|
Pure -- plain values in, a verdict out -- so every combination is asserted here
|
||||||
|
with no database, and the admin matrix, which calls the same function, cannot
|
||||||
|
disagree with what is enforced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lembas.services import talk
|
||||||
|
from lembas.services.talk import Rule, decide, match
|
||||||
|
|
||||||
|
ALLOW = Rule("main", "target", "allow")
|
||||||
|
DENY = Rule("main", "target", "deny")
|
||||||
|
|
||||||
|
|
||||||
|
def _v(**kwargs):
|
||||||
|
kwargs.setdefault("instance_mode", talk.MODE_OPEN)
|
||||||
|
kwargs.setdefault("instance_rule", None)
|
||||||
|
return decide(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- The instance's layer ------------------------------------------------------------
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "rule", "offered"),
|
||||||
|
[
|
||||||
|
(talk.MODE_OPEN, None, True),
|
||||||
|
(talk.MODE_OPEN, DENY, False),
|
||||||
|
(talk.MODE_OPEN, ALLOW, True),
|
||||||
|
(talk.MODE_CLOSED, None, False),
|
||||||
|
(talk.MODE_CLOSED, ALLOW, True),
|
||||||
|
(talk.MODE_CLOSED, DENY, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_instance_mode_and_its_rules(mode, rule, offered):
|
||||||
|
verdict = _v(instance_mode=mode, instance_rule=rule)
|
||||||
|
assert verdict.offered is offered
|
||||||
|
assert verdict.addable is offered
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_data_group_is_a_deny_only_an_explicit_allow_opens():
|
||||||
|
assert _v(same_group=False).offered is False
|
||||||
|
assert _v(same_group=False).why == talk.WHY_GROUP
|
||||||
|
assert _v(same_group=False, instance_rule=ALLOW).offered is True
|
||||||
|
# An open mode is not an explicit allow.
|
||||||
|
assert _v(same_group=False, instance_mode=talk.MODE_OPEN).offered is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- A person without the override can only narrow ---------------------------------
|
||||||
|
def test_a_persons_own_deny_takes_it_off_what_is_offered_but_not_off_what_is_addable():
|
||||||
|
verdict = _v(user_rule=DENY)
|
||||||
|
assert verdict.offered is False
|
||||||
|
assert verdict.addable is True
|
||||||
|
assert verdict.why == talk.WHY_YOUR_RULE
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_persons_closed_mode_narrows_too():
|
||||||
|
verdict = _v(user_mode=talk.MODE_CLOSED)
|
||||||
|
assert (verdict.offered, verdict.addable, verdict.why) == (False, True, talk.WHY_YOUR_CLOSED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_the_override_a_persons_allow_cannot_widen():
|
||||||
|
verdict = _v(instance_rule=DENY, user_rule=ALLOW)
|
||||||
|
assert (verdict.offered, verdict.addable) == (False, False)
|
||||||
|
assert verdict.why == talk.WHY_INSTANCE_RULE
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_the_override_open_mode_cannot_widen_a_closed_instance():
|
||||||
|
verdict = _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN)
|
||||||
|
assert (verdict.offered, verdict.addable) == (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- With the override, the person's layer wins ---------------------------------------
|
||||||
|
def test_with_the_override_a_persons_allow_beats_the_instances_deny():
|
||||||
|
verdict = _v(instance_rule=DENY, user_rule=ALLOW, override=True)
|
||||||
|
assert (verdict.offered, verdict.addable) == (True, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_the_override_a_persons_rule_opens_another_group():
|
||||||
|
assert _v(same_group=False, user_rule=ALLOW, override=True).offered is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_the_override_open_mode_widens_a_closed_instance_but_not_across_groups():
|
||||||
|
assert _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN, override=True).offered
|
||||||
|
across = _v(same_group=False, user_mode=talk.MODE_OPEN, override=True)
|
||||||
|
assert across.offered is False and across.why == talk.WHY_GROUP
|
||||||
|
# ...unless some rule explicitly allows it.
|
||||||
|
assert _v(
|
||||||
|
same_group=False, user_mode=talk.MODE_OPEN, override=True, instance_rule=ALLOW
|
||||||
|
).offered
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_the_override_and_no_layer_of_their_own_the_instance_decides():
|
||||||
|
assert _v(instance_rule=DENY, override=True).offered is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_the_override_anything_may_be_added_by_hand():
|
||||||
|
assert _v(instance_rule=DENY, override=True).addable is True
|
||||||
|
assert _v(same_group=False, override=True).addable is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Specificity -------------------------------------------------------------------------
|
||||||
|
def test_the_most_specific_rule_wins():
|
||||||
|
rules = [
|
||||||
|
Rule("*", "*", "deny"),
|
||||||
|
Rule("*", "b", "allow"),
|
||||||
|
Rule("a", "*", "deny"),
|
||||||
|
Rule("a", "b", "allow"),
|
||||||
|
]
|
||||||
|
assert match(rules, "a", "b").effect == "allow"
|
||||||
|
assert match(rules, "a", "c") == Rule("a", "*", "deny")
|
||||||
|
assert match(rules, "x", "b") == Rule("*", "b", "allow")
|
||||||
|
assert match(rules, "x", "y") == Rule("*", "*", "deny")
|
||||||
|
assert match([], "a", "b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_verdict_says_why_in_english_for_a_model():
|
||||||
|
assert "forbids" in _v(instance_rule=DENY).reason
|
||||||
|
assert _v().reason == ""
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Talk rules end to end: stored, edited, and obeyed by the roster, a friend and a crowd.
|
||||||
|
|
||||||
|
The table of what `decide` answers is `test_talk_decide.py`. This file is where
|
||||||
|
those answers reach something: the roster a model is told, the friend it may
|
||||||
|
ask, the crowd picker's two lists, a member added by hand that must still speak,
|
||||||
|
and a rule that lets a model from another data group in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import (
|
||||||
|
DEFAULT_GROUP,
|
||||||
|
Chat,
|
||||||
|
Connection,
|
||||||
|
CrowdMember,
|
||||||
|
DataGroup,
|
||||||
|
Model,
|
||||||
|
TalkRule,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import crowd, settings_store, talk
|
||||||
|
from lembas.services import subagent as subagent_service
|
||||||
|
from lembas.services.crypto import encrypt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owner(db, registered) -> User:
|
||||||
|
"""The first account, demoted: an administrator holds `rules.override`."""
|
||||||
|
user = db.scalars(select(User).order_by(User.created_at)).first()
|
||||||
|
user.role = "user"
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup(db, owner):
|
||||||
|
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
|
||||||
|
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()
|
||||||
|
for position, (connection, name) in enumerate(
|
||||||
|
[(local, "gpt-oss"), (local, "qwen38"), (local, "bonsai"), (cloud, "deepseek")]
|
||||||
|
):
|
||||||
|
db.add(Model(connection_id=connection.id, model_id=name, position=position))
|
||||||
|
chat = Chat(
|
||||||
|
user_id=owner.id,
|
||||||
|
model_id="gpt-oss",
|
||||||
|
connection_id=local.id,
|
||||||
|
data_group_id=DEFAULT_GROUP,
|
||||||
|
title="t",
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(models) -> list[str]:
|
||||||
|
return [m.model_id for m in models]
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_override(db):
|
||||||
|
settings_store.update(db, {"default_permissions": {talk.PERMISSION: True}})
|
||||||
|
|
||||||
|
|
||||||
|
# --- The roster and the friend ------------------------------------------------------
|
||||||
|
def test_an_instance_deny_takes_a_model_off_the_roster(db, owner, setup):
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
roster = chat_service.roster_block(db, owner, exclude="gpt-oss", group=DEFAULT_GROUP)
|
||||||
|
assert "qwen38" not in roster and "bonsai" in roster
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_friend_the_rules_forbid_is_refused_with_the_reason(db, owner, setup):
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
friend, refusal = subagent_service._resolve_friend(
|
||||||
|
db, owner, "qwen38", asking="gpt-oss", group=DEFAULT_GROUP
|
||||||
|
)
|
||||||
|
assert friend is None
|
||||||
|
listed = refusal.split("These are the ones you can:")[-1]
|
||||||
|
assert "qwen38" not in listed
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rule_is_read_from_the_main_model_only(db, owner, setup):
|
||||||
|
"""bonsai may not talk to qwen38 -- which says nothing about gpt-oss's chats."""
|
||||||
|
talk.set_rule(db, None, "bonsai", "qwen38", "deny")
|
||||||
|
assert "qwen38" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||||
|
assert "qwen38" not in _ids(talk.offered(db, owner, "bonsai", DEFAULT_GROUP))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_closed_instance_offers_only_what_is_allowed(db, owner, setup):
|
||||||
|
settings_store.update(db, {"mode": "closed"}, key=settings_store.RULES)
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "bonsai", "allow")
|
||||||
|
assert _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Other data groups -----------------------------------------------------------------
|
||||||
|
def test_another_group_is_not_offered_until_a_rule_allows_it(db, owner, setup):
|
||||||
|
assert "deepseek" not in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
|
||||||
|
assert "deepseek" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_member_from_another_group_reads_its_own_groups_data(db, owner, setup):
|
||||||
|
from lembas.services import data_groups
|
||||||
|
|
||||||
|
speaker = chat_service.Speaker("deepseek", None)
|
||||||
|
assert data_groups.for_speaker(db, owner, setup, speaker) == "hosted"
|
||||||
|
|
||||||
|
|
||||||
|
# --- The crowd: offered, and by hand -------------------------------------------------------
|
||||||
|
def test_the_picker_names_what_it_holds_back_and_why(client, db, owner, setup):
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||||
|
page = client.get(f"/chat/{setup.id}").text
|
||||||
|
held = page.split("Not offered to this model")[1]
|
||||||
|
assert "qwen38" in held and "bonsai" in held
|
||||||
|
assert "The instance's rule" in held or "The instance's rule" in held
|
||||||
|
assert "You may still add it yourself." in held
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_persons_own_rule_is_soft_for_them(client, db, owner, setup):
|
||||||
|
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||||
|
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["bonsai"]})
|
||||||
|
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["bonsai"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_instance_rule_is_hard_without_the_override(client, db, owner, setup):
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
|
||||||
|
assert list(db.scalars(select(CrowdMember))) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_the_override_an_instance_rule_can_be_passed_by_hand(client, db, owner, setup):
|
||||||
|
_grant_override(db)
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
|
||||||
|
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["qwen38"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_member_added_by_hand_still_speaks(db, owner, setup):
|
||||||
|
"""Send time asks `addable`, not `offered`, or the choice would be undone."""
|
||||||
|
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||||
|
db.add(CrowdMember(chat_id=setup.id, model_id="bonsai"))
|
||||||
|
db.commit()
|
||||||
|
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss", "bonsai"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_member_the_instance_later_forbids_is_skipped_and_named(db, owner, setup):
|
||||||
|
db.add(CrowdMember(chat_id=setup.id, model_id="qwen38"))
|
||||||
|
db.commit()
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||||
|
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss"]
|
||||||
|
assert crowd.unreachable_members(db, setup, owner) == ["qwen38"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Storing and editing ------------------------------------------------------------------------
|
||||||
|
def test_both_directions_writes_two_rules_and_a_second_write_replaces(db, setup):
|
||||||
|
talk.set_rule(db, None, "a", "b", "deny", both=True)
|
||||||
|
assert {(r.from_model, r.to_model) for r in db.scalars(select(TalkRule))} == {
|
||||||
|
("a", "b"),
|
||||||
|
("b", "a"),
|
||||||
|
}
|
||||||
|
talk.set_rule(db, None, "a", "b", "allow")
|
||||||
|
assert db.scalar(select(TalkRule).where(TalkRule.from_model == "a")).effect == "allow"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_person_cannot_delete_an_instance_rule(db, owner, setup):
|
||||||
|
talk.set_rule(db, None, "a", "b", "deny")
|
||||||
|
rule = db.scalar(select(TalkRule))
|
||||||
|
assert talk.delete_rule(db, owner, rule.id) is False
|
||||||
|
assert talk.delete_rule(db, None, rule.id) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_page_adds_a_rule_and_draws_the_matrix(client, db, registered, setup):
|
||||||
|
# The fixture demoted the first account; the page is an administrator's.
|
||||||
|
user = db.scalars(select(User).order_by(User.created_at)).first()
|
||||||
|
user.role = "admin"
|
||||||
|
db.commit()
|
||||||
|
client.post("/admin/rules", data={"from_model": "gpt-oss", "to_model": "bonsai",
|
||||||
|
"effect": "deny"})
|
||||||
|
page = client.get("/admin/rules").text
|
||||||
|
assert "Who may talk to whom" in page
|
||||||
|
assert page.count("talk-matrix__no") >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_settings_card_sets_a_persons_mode_and_rules(client, db, owner, setup):
|
||||||
|
client.post("/api/preferences/talk-mode", data={"mode": "closed"})
|
||||||
|
client.post("/api/preferences/talk-rules", data={"from_model": "gpt-oss",
|
||||||
|
"to_model": "bonsai", "effect": "allow"})
|
||||||
|
db.expire_all()
|
||||||
|
user = db.get(User, owner.id)
|
||||||
|
assert talk.user_mode(user) == "closed"
|
||||||
|
assert _ids(talk.offered(db, user, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -100,6 +102,59 @@ def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered
|
|||||||
assert 'name="temporary"' in client.get("/chat?temporary=1").text
|
assert 'name="temporary"' in client.get("/chat?temporary=1").text
|
||||||
|
|
||||||
|
|
||||||
|
def _temporary_link(page: str) -> str:
|
||||||
|
match = re.search(r'href="([^"]*)"\s+aria-label="Temporary chat"', page)
|
||||||
|
assert match, "no temporary toggle on the page"
|
||||||
|
return html.unescape(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_url(page: str) -> str:
|
||||||
|
match = re.search(r'data-picker-navigate="([^"]*)"', page)
|
||||||
|
assert match, "no navigating model picker on the page"
|
||||||
|
return html.unescape(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_temporary_toggle_keeps_the_chosen_model(client: TestClient, db, registered):
|
||||||
|
"""Temporary chats could only be started on the default model: the toggle
|
||||||
|
linked to a bare `/chat?temporary=1`, so the model picked a moment before
|
||||||
|
was dropped."""
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="other-model"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
on = _temporary_link(client.get("/chat?model=other-model").text)
|
||||||
|
assert "model=other-model" in on and "temporary=1" in on
|
||||||
|
assert 'name="model_id" value="other-model"' in client.get(on).text
|
||||||
|
|
||||||
|
off = _temporary_link(client.get(on).text)
|
||||||
|
assert "model=other-model" in off and "temporary" not in off
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_a_model_keeps_the_temporary_flag(client: TestClient, db, registered):
|
||||||
|
"""The other half: the picker navigated to a bare `/chat?model=`, so
|
||||||
|
picking a model after the toggle quietly made the chat an ordinary one."""
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="other-model"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
url = _picker_url(client.get("/chat?temporary=1").text)
|
||||||
|
assert url.endswith("model=")
|
||||||
|
page = client.get(url + "other-model").text
|
||||||
|
assert 'name="temporary"' in page
|
||||||
|
assert 'name="model_id" value="other-model"' in page
|
||||||
|
assert _picker_url(client.get("/chat").text) == "/chat?model="
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_links_keep_the_folder(client: TestClient, db, registered):
|
||||||
|
_connection(db)
|
||||||
|
client.post("/api/folders", data={"name": "Quests"})
|
||||||
|
folder = db.scalar(select(Folder))
|
||||||
|
|
||||||
|
page = client.get(f"/chat?folder={folder.id}").text
|
||||||
|
assert f"folder={folder.id}" in _temporary_link(page)
|
||||||
|
assert f"folder={folder.id}" in _picker_url(page)
|
||||||
|
|
||||||
|
|
||||||
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
|
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
|
||||||
_connection(db)
|
_connection(db)
|
||||||
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
|
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
|
||||||
|
|||||||
@@ -177,6 +177,61 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
|||||||
assert "scrollHeight > " in SOURCE
|
assert "scrollHeight > " in SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tab_switch_moves_only_the_container_that_scrolls():
|
||||||
|
"""Switching a tab on /admin/prompts lifted the whole shell 56px, with the
|
||||||
|
topbar gone off the top and a strip of bare background under everything.
|
||||||
|
|
||||||
|
Two halves, and either one alone is enough to bring it back. The prompt
|
||||||
|
cards' `.visually-hidden` labels are `position: absolute`. With no
|
||||||
|
positioned ancestor they were placed against the page and stretched the
|
||||||
|
document to 6771px behind an `overflow: hidden` root. And the handler used
|
||||||
|
`scrollIntoView`, which scrolls every ancestor that can scroll, the root
|
||||||
|
included. Measured in headless Chromium at 1640x930 before and after.
|
||||||
|
"""
|
||||||
|
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||||
|
start = app.index(".scroll-region,")
|
||||||
|
rule = app[start : app.index("}", start)]
|
||||||
|
assert ".admin-scroll" in rule
|
||||||
|
assert "position: relative" in rule
|
||||||
|
|
||||||
|
code = re.sub(r"/\*.*?\*/", "", SOURCE, flags=re.S)
|
||||||
|
start = code.index('closest(".tabs__bar")')
|
||||||
|
handler = code[start : code.index("})();", start)]
|
||||||
|
assert "scrollIntoView" not in handler
|
||||||
|
assert "outer.scrollTop" in handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tab_bar_edge_fade_is_covered_when_nothing_overflows():
|
||||||
|
"""The covers were as wide as the shadows and solid for only 40% of that,
|
||||||
|
so 60% of each shadow showed through with nothing to scroll to. On Shire
|
||||||
|
that was a grey sliver at both ends of every tab bar. A cover has to be
|
||||||
|
solid across the whole shadow. Measured in headless Chromium on
|
||||||
|
/admin/prompts and /settings in both themes before and after.
|
||||||
|
"""
|
||||||
|
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||||
|
start = admin.index("background-attachment: local, local, scroll, scroll")
|
||||||
|
block = admin[admin.rindex(".tabs__bar {", 0, start) : start]
|
||||||
|
cover = re.search(r"linear-gradient\(to right, var\(--bg\) (\d+)%, transparent\)", block)
|
||||||
|
shadow = re.search(r"var\(--scrim\), transparent ([\d.]+)rem", block)
|
||||||
|
sizes = re.search(r"background-size: ([\d.]+)rem 100%, [\d.]+rem 100%, ([\d.]+)rem 100%", block)
|
||||||
|
assert cover and shadow and sizes, "the edge-fade rule changed shape; re-check it by eye"
|
||||||
|
solid = float(sizes.group(1)) * int(cover.group(1)) / 100
|
||||||
|
assert solid >= float(shadow.group(1)) == float(sizes.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_composer_is_as_wide_as_its_column_not_its_hint():
|
||||||
|
"""With `max-width` and auto margins alone, the box was as wide as its
|
||||||
|
widest content inside a flex column, so the vision hint under it decided:
|
||||||
|
768px for GPT-OSS ("has no vision, so images will not be sent") and 538px
|
||||||
|
for a model that sees images. Reported from the live instance with four
|
||||||
|
screenshots."""
|
||||||
|
chat = (ROOT / "web/static/css/chat.css").read_text(encoding="utf-8")
|
||||||
|
start = chat.index(".composer__inner {")
|
||||||
|
rule = chat[start : chat.index("}", start)]
|
||||||
|
assert "width: 100%" in rule
|
||||||
|
assert "max-width: var(--thread-max-width)" in rule
|
||||||
|
|
||||||
|
|
||||||
def test_the_two_ends_of_the_shell_stay_level():
|
def test_the_two_ends_of_the_shell_stay_level():
|
||||||
"""The sidebar footer and the composer sit either side of the same vertical
|
"""The sidebar footer and the composer sit either side of the same vertical
|
||||||
edge and are both content-sized, so without a common floor they end at
|
edge and are both content-sized, so without a common floor they end at
|
||||||
|
|||||||
Reference in New Issue
Block a user