Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9970bb43c6
|
||
|
|
e65ea90fe6
|
||
|
|
cb8a223fa4
|
||
|
|
dfd8418d95
|
||
|
|
db22962164
|
||
|
|
793f9c8cad
|
||
|
|
da43bc1459
|
||
|
|
8503c6c775
|
||
|
|
ab32c68a8f
|
||
|
|
a16510aba8
|
+293
@@ -16,6 +16,299 @@ for 1.0.0 have something to be assembled from.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.10.0
|
||||
|
||||
Data groups: a provider's models read only the data of the group their
|
||||
connection is in. This is the first of three releases. The next two add rules
|
||||
for which model may talk to which, and helpers on a different model.
|
||||
|
||||
- **Every connection is in a data group, and its models read only that group.**
|
||||
This covers memories, notes, skills, knowledge bases and their documents,
|
||||
reports, and the personality and impression a model keeps with you. It applies
|
||||
both to what a model is handed at the start of a turn and to what its tools
|
||||
can find. A tool can no longer open a record from another group by its id.
|
||||
**Admin → Data groups** makes groups, puts connections in them, and shows
|
||||
**where each group's data goes**, flagging a service whose connection sits in
|
||||
a different group. Every instance starts with one group, called Default, with
|
||||
every connection and every existing record in it. Until you make a second
|
||||
group nothing changes, and nothing about groups is shown in the library.
|
||||
- **A chat stays in the group it was started in.**
|
||||
- Inside a chat, the model menu offers only models in the chat's group and
|
||||
names the others underneath, with the reason.
|
||||
- Switching to one is refused, because it would be sent the whole
|
||||
conversation.
|
||||
- The crowd picker, `ask_friend`, the list of other models, attaching a
|
||||
knowledge base and the `@` menu all stay within the chat's group.
|
||||
- If a chat's model is later moved into another group, the next reply is
|
||||
refused with an explanation rather than sent.
|
||||
- When a chat's own connection has gone, the fallback to "any connection
|
||||
offering the same model" now only considers connections in the chat's
|
||||
group. Before, it could silently move a conversation to another provider.
|
||||
- **A group can have its own embedding model and image reviewer.** The embedder
|
||||
is sent the full text of everything it indexes, and the reviewer every picture
|
||||
with its prompt. A group that names neither uses the instance's.
|
||||
- **Schedules run in their model's group.** A schedule's reports land in that
|
||||
group. A schedule whose model is in another group than Messages cannot post
|
||||
into Messages, and says so. The model that works out a schedule's timing from
|
||||
plain words is now picked from the schedule's own group, not simply the first
|
||||
pinned model.
|
||||
- **Your own arrangement, with a new permission.** With *Manage their own data
|
||||
groups* (off by default), a person can:
|
||||
- make personal groups nobody else sees;
|
||||
- choose which group each connection reads for them alone;
|
||||
- move their own notes, skills and knowledge bases between groups.
|
||||
|
||||
Everybody can see which group each connection reads, on the new **Data** tab
|
||||
in Settings, once there is more than one group. New notes, skills, knowledge
|
||||
bases and memories can be put in any group you can use.
|
||||
- **A search no longer mixes two embedding models of the same width.** It
|
||||
checked only a vector's width, so two different 1024-wide models scored against
|
||||
each other and returned confident nonsense. That used to need a model change
|
||||
with a rebuild pending; with an embedder per group it would have been ordinary.
|
||||
A query now carries the model that made it, and only that model's pieces are
|
||||
scored.
|
||||
- The crowd picker on the new-chat screen now leaves out the model the chat is
|
||||
being started on. It was offering the chat's own model as a member.
|
||||
- Four lines on the crowd page and in the crowd picker were still in English on
|
||||
a Slovak instance. They are translated now.
|
||||
- Known limits:
|
||||
- A skill name and a knowledge base name are still unique per person across
|
||||
all groups. The database constraint cannot be changed without rebuilding the
|
||||
table.
|
||||
- Speech to text and text to speech are not grouped: audio is sent to the
|
||||
speech server and not kept.
|
||||
- Moving a whole knowledge base to another group leaves its documents indexed
|
||||
for the old group's embedder until the index is rebuilt.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
- **A temporary chat can be started on any model.** On the new-chat screen,
|
||||
turning on Temporary switched the model back to the default, and choosing a
|
||||
model switched Temporary off, so a temporary chat could only ever be started
|
||||
on the default model. The Temporary button, the model menu and `/temp` now
|
||||
keep each other's choice, and also keep the folder a chat was started in
|
||||
("New chat here") and whether it is an agent chat.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
The model menu says which model is loaded, and a new chat now matches the
|
||||
model it is about to talk to.
|
||||
|
||||
- **A dot on the model that is loaded.** Opening the model menu asks each
|
||||
connection which of its models is in memory. llama-swap says so in its
|
||||
ordinary model list, so the one it is holding gets a green dot, and one being
|
||||
loaded gets a pulsing amber one. Picking a model without a dot means waiting
|
||||
for it to load first. A hosted API such as DeepSeek never unloads anything and
|
||||
does not report it, so its models show no dot, not a false "not loaded". Each
|
||||
connection is asked once per menu opening, at most every five seconds. One
|
||||
that reports nothing is asked again only after ten minutes, and one that is
|
||||
slow or down just leaves the menu without dots.
|
||||
|
||||
- **A new chat offers the model's own effort levels.** The new-chat screen
|
||||
offered low, medium and high whatever the model took. On a model like Bonsai,
|
||||
which takes low, medium and xhigh with xhigh as its default, the menu offered a
|
||||
`high` it rejects. It had no xhigh, so it showed "off" while the chat it
|
||||
created used xhigh. It now shows the same levels, and the same default, as the
|
||||
chat will have.
|
||||
|
||||
- **The message box is the same width for every model.** It was sized by its
|
||||
widest content, so the "has no vision, so images will not be sent" line made
|
||||
it wider for models without vision than for models with it. It is now always
|
||||
the width of the conversation column.
|
||||
|
||||
- **"Speak friend and enter."** The line under an empty chat (and on the "not
|
||||
yours" error page) lost its commas. On the Doors of Durin it is a riddle: the
|
||||
answer is to say *friend*, not to be greeted as one. Only the shipped wording
|
||||
changed. An instance that has overridden the line keeps its own.
|
||||
|
||||
## 1.8.5
|
||||
|
||||
- **No more grey slivers at the ends of the tab bars.** Tab bars fade at an edge
|
||||
to show there are more tabs to scroll to. The fade was only partly hidden
|
||||
when there was nothing to scroll, so a shadow always showed at both ends. It
|
||||
was invisible on the dark theme and a grey sliver on Shire, on Administration
|
||||
→ Prompts, Settings and every other tabbed page. The fade now appears only
|
||||
on the side where tabs are actually hidden.
|
||||
|
||||
## 1.8.4
|
||||
|
||||
Two things that kept showing up after they should have gone away.
|
||||
|
||||
- **"A new version is ready" no longer appears on a page that is already the
|
||||
new version.** After an update the toast showed up on every page, even one just
|
||||
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
|
||||
whenever a new service worker was waiting. But a page loaded after the update
|
||||
already *is* the update: pages always come from the server, and every
|
||||
stylesheet and script they name carries the release in its address. The
|
||||
worker that waits is nearly always the one from the previous release, still
|
||||
holding the tab, because a reload opens the new page before the old one goes
|
||||
away. The toast now compares the waiting worker's release with the page's
|
||||
own, so it only appears in a tab that was opened before the update. For the
|
||||
same reason, pressing Reload in one tab no longer reloads the other tabs that
|
||||
are already up to date. That matters when one of them has a reply streaming
|
||||
into it.
|
||||
|
||||
- **Switching tabs on Administration → Prompts no longer lifts the page.**
|
||||
Choosing any tab but the first pushed the whole window up by the height of
|
||||
the title bar and left a blank strip along the bottom, under the sidebar too.
|
||||
The prompt cards' hidden labels were positioned against the page instead of
|
||||
the panel. That made the page 6,771px tall behind a window that cannot scroll
|
||||
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
|
||||
contains what is inside it, and a tab switch moves only the panel that
|
||||
scrolls. Administration → General on a small phone had the same leak and is
|
||||
fixed with it.
|
||||
|
||||
## 1.8.3
|
||||
|
||||
The model picker on a phone, which could not be read once a chat was open.
|
||||
|
||||
- **The model menu no longer runs off the left of the screen.** Inside a chat
|
||||
the picker sits in the middle of the top bar, with the panel buttons to its
|
||||
right, and its menu opened from the picker's right edge — so on a phone most
|
||||
of it was off the screen and every model's name was cut off. On a narrow
|
||||
screen the menu now hangs from the bar itself, edge to edge, and every name is
|
||||
whole. Wider screens are unchanged.
|
||||
|
||||
- **Opening it on a touchscreen no longer raises the keyboard.** With more than
|
||||
eight models the menu has a filter box, and it took the focus on opening — so
|
||||
the keyboard came up and covered half the list you had opened it to choose
|
||||
from. On a touchscreen the chosen model takes the focus instead; the filter is
|
||||
one tap away. With a mouse, typing straight into the filter works as before.
|
||||
|
||||
## 1.8.2
|
||||
|
||||
The model lists, made readable. Both printed every capability switch as a tag —
|
||||
reasoning, vision, tools and then seventeen `tool_*` names — for every model.
|
||||
|
||||
- **The model picker in a chat is name, context window and an eye.** One line per
|
||||
model: its name, its context window shortened the way it is quoted (`CTX 131K`,
|
||||
`CTX 1M`), and an eye if it can see images — nothing if it cannot. The tags and
|
||||
the description are gone from it; a menu whose one job is choosing does not
|
||||
need twenty badges per row. The context sizes and the eyes line up as columns
|
||||
whatever a name's length, and a model with no context length set shows nothing
|
||||
rather than `CTX 0`.
|
||||
|
||||
- **The tick follows the model you picked.** It stayed on the model the page was
|
||||
loaded with until the next reload, while the highlight moved.
|
||||
|
||||
- **Settings → Models no longer runs the tags over the names.** The tags sat
|
||||
beside the name, squeezed it to a word per line on a phone and drew over it at
|
||||
every width. The name now has the row to itself, with the same context size and
|
||||
eye as the picker, and the capability tags wrap underneath at the card's full
|
||||
width. A long name wraps rather than being cut off.
|
||||
|
||||
## 1.8.1
|
||||
|
||||
Three fixes to how a crowd behaves, found by reading one real round on the live
|
||||
instance rather than by testing: two models, one round, a question that asked for
|
||||
something to be *made*.
|
||||
|
||||
- **A member no longer answers the question again.** Asked to pick a language and
|
||||
write an example, the main model wrote Python; the second model gave a genuinely
|
||||
useful critique of it — and then answered the original question itself, in a
|
||||
different language. Nothing in its instruction said not to. It now says so:
|
||||
*respond to what is above you; do not answer the person's original request again
|
||||
yourself.* A member that produces a rival answer is not a second opinion, it is
|
||||
a second first opinion, and it is what takes a round off the question.
|
||||
|
||||
- **The model that opened the round no longer capitulates.** Told to write the
|
||||
final answer and take what the others got right, it abandoned its own perfectly
|
||||
good answer, wrote *"I agree that Rust is the superior choice"* with no argument
|
||||
anywhere for why, and rewrote everything in the newcomer's language. Both
|
||||
closing instructions now carry: *your own answer is not automatically the worse
|
||||
one for having been written first; change your position where somebody gave you
|
||||
a reason, and say what the reason was.*
|
||||
|
||||
This mattered more than it reads. All three answers were compiled: the original
|
||||
Python was fine, the critic's Rust compiled and ran — and **the merged answer
|
||||
that was actually delivered did not compile at all**. A crowd that ends by
|
||||
agreeing with whoever spoke last can be worse than the model that started it.
|
||||
|
||||
- **The bubble that opens a round now says `1 of 3` like every other one.** It was
|
||||
the single contribution with no chip, because the crowd does not start it — the
|
||||
composer does, and a round only begins when it finishes. So a two-model round
|
||||
read as an ordinary reply followed by one labelled `2 of 2`, with no 1 anywhere.
|
||||
It is stamped when the round begins, and that stamp is deliberately invisible to
|
||||
everything that decides what happens next: fed to the scheduler it would inherit
|
||||
the round's clock, so regenerating the opening an hour later would end the round
|
||||
with "out of time" before anybody spoke.
|
||||
|
||||
- Fixed: **the crowd chip was never translated.** `1 of 3`, `on the way back`,
|
||||
`closing`, `no rounds left` and the rest were English on a Slovak instance.
|
||||
|
||||
**Worth knowing, and not a bug:** with **two** models there is no backward pass at
|
||||
all. The way back would contain only the model that opened the round, whose turn
|
||||
*is* the close — so `crowd.disagree` never fires. You need at least three models
|
||||
before a single "do you disagree" bubble can exist.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
|
||||
model to a chat was the Chat settings panel — behind the ⋯ menu, inside a chat
|
||||
that already existed — and the switch that turns the feature on was a card on
|
||||
the Agents page. Somebody who enabled it went looking and found nothing, which
|
||||
is the correct outcome of that arrangement.
|
||||
|
||||
Now there is a **crowd button in the composer**, beside the attachment and
|
||||
scope buttons, on both the chat screen and Messages. It carries a count when
|
||||
the chat has a crowd, it lists the models you can reach, and it says what the
|
||||
turn will cost before you tick anything. On the new-chat screen the choice
|
||||
**rides along with the first message**, so a chat can start as a crowd rather
|
||||
than having to be converted into one.
|
||||
|
||||
The instance switch and its bounds have moved to their own page, **Admin →
|
||||
Crowd**.
|
||||
|
||||
- Fixed: **the new-chat screen was wider than a phone.** Before the first
|
||||
message, the suggestion cards pushed the conversation 65px past the edge of a
|
||||
390px screen and it could be dragged sideways; after the first message it
|
||||
looked right, because the cards were gone. Reported from a phone.
|
||||
|
||||
Two things were true at once. The cards' grid asked for a minimum column width
|
||||
it could not give up — the ordinary version of this bug — and it was *also* a
|
||||
grid item, which means it carried a min-content floor that beats `width: 100%`
|
||||
outright. Fixing only the first made it 27px worse. Both are fixed, on all four
|
||||
grids in the stylesheets that could have it, and a test now refuses either half
|
||||
of the pair on its own.
|
||||
|
||||
The reason this survived four releases of narrow-width checking is worth
|
||||
recording: the screenshot harness built its client without running the
|
||||
application's startup, so the suggestion cards were **absent from every shot
|
||||
ever taken of that screen**, and its overflow check deliberately ignored
|
||||
anything inside a scrolling box — correct for a wide table in its own scroller,
|
||||
blind to a box that scrolls sideways when nobody asked it to. Both are fixed,
|
||||
and the harness now names the offending element and the child responsible.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- **The interface speaks Slovak.** Pick a language under **Appearance** in your
|
||||
own settings, or set what everybody else gets under Admin → General. Your own
|
||||
choice wins over the instance's, it is saved to your account rather than to one
|
||||
browser, and `<html lang>` finally says what the page is actually in.
|
||||
|
||||
All 969 translatable strings are translated, including the long explanatory
|
||||
paragraphs on the admin pages — there is no half-done corner where a Slovak
|
||||
instance falls back to English. Dates follow too: a month name is a month name
|
||||
in the language you are reading, which `strftime` cannot do without a process
|
||||
locale that this application must not set.
|
||||
|
||||
**What is deliberately still in English**: everything a *model* reads. The
|
||||
prompt fragments under Admin → Prompts are instructions written for models, and
|
||||
translating them would change what the models are told rather than what you
|
||||
see. Models answer in whatever language you write to them in — they already
|
||||
did, and that line is editable where all the others are.
|
||||
|
||||
An English instance is byte-for-byte what shipped in 1.6.0. That is a property
|
||||
of the design rather than a claim: a string with no translation renders the
|
||||
English it was written in, so a language added later cannot leave holes in a
|
||||
page.
|
||||
|
||||
- Fixed: **every page was rendered in the instance's language, whatever anybody
|
||||
had chosen.** Found while building the above and worth naming because it would
|
||||
have been invisible: the language was resolved in a dependency that FastAPI runs
|
||||
in a threadpool, and the context it was set in is discarded on the way out. Now
|
||||
it is resolved in the request's own task.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- **A chat can have a crowd.** Switch it on under Admin → Agents, and each chat's
|
||||
|
||||
@@ -81,6 +81,13 @@ src = ["src", "tests"]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
|
||||
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# A translation catalogue is keyed by the English sentence, and a sentence cannot
|
||||
# be rewrapped without becoming a different key. Wrapping them would mean every
|
||||
# key spelled as an implicit concatenation, which is both unreadable and one
|
||||
# stray space away from a silent miss.
|
||||
"src/lembas/web/i18n/*.py" = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
# Registered so `-m "not slow"` works and an unknown-marker warning does not
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Find every translatable string, and say what the catalogues are missing.
|
||||
|
||||
Run it:
|
||||
|
||||
python scripts/i18n_extract.py # a report
|
||||
python scripts/i18n_extract.py --write sk # fill sk.py with what is missing
|
||||
|
||||
A development instrument, like `shoot.py` and `fetch_vendor.py`: nothing in `src/`
|
||||
imports it. What it knows is the one thing a catalogue keyed on source text cannot
|
||||
know for itself -- that an English sentence has been edited, leaving its
|
||||
translation stranded under the old wording. `tests/test_translations.py` asserts
|
||||
the same property from the other side, so a catalogue cannot rot quietly.
|
||||
|
||||
The scan is deliberately simple: `t("…")` and `t('…')`, in templates and in
|
||||
Python. A string built by concatenation or an f-string is not found, and that is
|
||||
the point -- a sentence assembled from pieces cannot be translated, because the
|
||||
order of the pieces is not the same in every language. Use `t("… %(name)s …",
|
||||
name=…)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src/lembas"
|
||||
TEMPLATES = SRC / "web/templates"
|
||||
CATALOGUES = SRC / "web/i18n"
|
||||
|
||||
# `t("…")` with either quote, allowing escaped quotes inside. Multi-line, because
|
||||
# a paragraph in a template is wrapped for the width of the file.
|
||||
CALL = re.compile(r"""\bt\(\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""", re.S)
|
||||
|
||||
# `i18n.stamp(value, "%d %B %Y")` -- the *format* is translated too, so a language
|
||||
# that puts the day first, or wants a full stop after it, says so in the
|
||||
# catalogue. A second pattern rather than a looser first one: widening `t(` to
|
||||
# "any call with a string in it" would sweep up every `select("…")` in the
|
||||
# codebase.
|
||||
STAMP = re.compile(
|
||||
# One level of nesting allowed in the first argument, because it is usually a
|
||||
# call: `stamp(clock.now_for(user), "…")`.
|
||||
r"""\bstamp\((?:[^()"']|\([^()]*\))*,\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def normalise(text: str) -> str:
|
||||
"""The key: whitespace collapsed, escapes resolved.
|
||||
|
||||
A template wraps one sentence across three lines, and the same sentence in a
|
||||
Python file across two. Keying on the exact bytes would need an entry per
|
||||
wrapping, so the key is the text with its runs of whitespace flattened -- which
|
||||
is exactly what `i18n.translate` looks up.
|
||||
"""
|
||||
text = text.replace('\\"', '"').replace("\\'", "'").replace("\\n", " ")
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def sources() -> list[Path]:
|
||||
files = sorted(TEMPLATES.rglob("*.html"))
|
||||
files += [
|
||||
path
|
||||
for path in sorted(SRC.rglob("*.py"))
|
||||
if "web/i18n" not in str(path) and "__pycache__" not in str(path)
|
||||
]
|
||||
return files
|
||||
|
||||
|
||||
JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.S)
|
||||
PY_COMMENT = re.compile(r"^[ \t]*#.*$", re.M)
|
||||
|
||||
|
||||
def strip_comments(path: Path, text: str) -> str:
|
||||
"""Comments are not strings. This file's own docstrings quote `t("Save")`, and
|
||||
so does `web/templating.py`'s comment explaining the global -- both would
|
||||
otherwise arrive in the catalogue as things to translate."""
|
||||
if path.suffix == ".html":
|
||||
return JINJA_COMMENT.sub("", text)
|
||||
return PY_COMMENT.sub("", text)
|
||||
|
||||
|
||||
def found() -> dict[str, list[str]]:
|
||||
"""Every string, with the files it appears in."""
|
||||
out: dict[str, list[str]] = {}
|
||||
for path in sources():
|
||||
text = strip_comments(path, path.read_text(encoding="utf-8"))
|
||||
for match in list(CALL.finditer(text)) + list(STAMP.finditer(text)):
|
||||
key = normalise(match.group("text"))
|
||||
if not key:
|
||||
continue
|
||||
out.setdefault(key, [])
|
||||
where = str(path.relative_to(REPO))
|
||||
if where not in out[key]:
|
||||
out[key].append(where)
|
||||
return out
|
||||
|
||||
|
||||
def catalogue(code: str) -> dict[str, str]:
|
||||
path = CATALOGUES / f"{code}.py"
|
||||
if not path.exists():
|
||||
return {}
|
||||
namespace: dict[str, object] = {}
|
||||
exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), namespace)
|
||||
return dict(namespace.get("MESSAGES", {})) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def report() -> int:
|
||||
strings = found()
|
||||
print(f"{len(strings)} translatable strings in {len(sources())} files")
|
||||
for code in ("sk",):
|
||||
have = catalogue(code)
|
||||
missing = [key for key in strings if key not in have]
|
||||
orphans = [key for key in have if key not in strings]
|
||||
done = len(strings) - len(missing)
|
||||
print(
|
||||
f" {code}: {done}/{len(strings)} translated"
|
||||
f" ({len(missing)} missing, {len(orphans)} orphaned)"
|
||||
)
|
||||
for key in orphans[:10]:
|
||||
print(f" orphan: {key[:80]!r}")
|
||||
return 0
|
||||
|
||||
|
||||
def write(code: str) -> int:
|
||||
"""Append the missing keys to a catalogue, each mapped to itself.
|
||||
|
||||
Mapped to the English rather than to "" on purpose: an empty translation would
|
||||
render as an empty paragraph, while the English renders as what it already
|
||||
said. A catalogue half-filled is a page half-translated, never a page with
|
||||
holes in it.
|
||||
"""
|
||||
strings = found()
|
||||
have = catalogue(code)
|
||||
missing = [key for key in strings if key not in have]
|
||||
if not missing:
|
||||
print(f"{code}: nothing missing")
|
||||
return 0
|
||||
path = CATALOGUES / f"{code}.py"
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"\n# --- {len(missing)} added by scripts/i18n_extract.py ---\n")
|
||||
for key in missing:
|
||||
handle.write(f"MESSAGES[{key!r}] = {key!r}\n")
|
||||
print(f"{code}: added {len(missing)} keys to {path.relative_to(REPO)}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--write" in sys.argv:
|
||||
return write(sys.argv[sys.argv.index("--write") + 1])
|
||||
return report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+171
-1
@@ -26,6 +26,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -128,8 +129,65 @@ window.__measure = function () {
|
||||
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
|
||||
}
|
||||
|
||||
/* --- A box that scrolls sideways when nobody asked it to -----------------
|
||||
|
||||
The blind spot that hid the suggestions bug through forty measurements.
|
||||
`.suggestions` rendered 455px wide inside a 390px `.thread-scroll`, and
|
||||
every check above looked straight past it: `culprits('x')` skips anything
|
||||
with a scrollable ancestor -- correct for a table inside its own scroller,
|
||||
wrong for the scroller itself -- and `scrollsSideways` stayed false because
|
||||
`.thread-scroll` absorbed the overflow instead of the document.
|
||||
|
||||
"Authored" is the distinction that makes this reportable rather than noise.
|
||||
The tree's rule is that anything wide gets its OWN scroller, so a wrapper
|
||||
carrying `overflow-x: auto` in a stylesheet is right. A box given only
|
||||
`overflow-y: auto` scrolls sideways as well, because the other axis then
|
||||
computes to `auto` -- and that is always a bug. Computed style cannot tell
|
||||
those apart, both being `auto`, so the rules that say it are read off the
|
||||
stylesheets -- in Python, by `authored_sideways()` below, and not from the
|
||||
CSSOM here: a stylesheet loaded over `file://` is a foreign origin for
|
||||
`cssRules` even with `--allow-file-access-from-files`, and every sheet
|
||||
throws. That silently found *nothing authored*, which turns this check into
|
||||
"every vertical scroller is a bug" -- so the list arriving empty is a hard
|
||||
error rather than a clean run. */
|
||||
var sidewaysAuthors = __SIDEWAYS_AUTHORS__;
|
||||
|
||||
function authoredSideways(el) {
|
||||
if (el.style.overflowX || el.style.overflow) return true;
|
||||
for (var i = 0; i < sidewaysAuthors.length; i++) {
|
||||
try { if (el.matches(sidewaysAuthors[i])) return true; } catch (e) { /* :has() etc */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var sideways = [];
|
||||
document.querySelectorAll('body, body *').forEach(function (el) {
|
||||
var ox = getComputedStyle(el).overflowX;
|
||||
if (ox !== 'auto' && ox !== 'scroll') return;
|
||||
if (el.scrollWidth <= el.clientWidth + 1) return;
|
||||
if (authoredSideways(el)) return;
|
||||
/* Which child is doing it. "`.thread-scroll` scrolls sideways" is not
|
||||
actionable; "`.suggestions` is 455px inside its 390px" is. */
|
||||
var worst = null;
|
||||
el.querySelectorAll('*').forEach(function (kid) {
|
||||
var over = kid.getBoundingClientRect().width - el.clientWidth;
|
||||
if (over > 1 && (!worst || over > worst.over)) {
|
||||
worst = {tag: kid.tagName.toLowerCase(),
|
||||
cls: (kid.className && kid.className.toString().slice(0, 50)) || '',
|
||||
w: Math.round(kid.getBoundingClientRect().width),
|
||||
over: Math.round(over)};
|
||||
}
|
||||
});
|
||||
sideways.push({tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className && el.className.toString().slice(0, 50)) || '',
|
||||
scrollW: el.scrollWidth, clientW: el.clientWidth,
|
||||
widest: worst});
|
||||
});
|
||||
|
||||
var shell = document.querySelector('.shell');
|
||||
return {
|
||||
sidewaysScrollers: sideways.slice(0, 8),
|
||||
sidewaysCount: sideways.length,
|
||||
docScrollH: de.scrollHeight,
|
||||
innerH: window.innerHeight,
|
||||
docScrollW: de.scrollWidth,
|
||||
@@ -167,6 +225,46 @@ window.__measure = function () {
|
||||
"""
|
||||
|
||||
|
||||
@cache
|
||||
def authored_sideways() -> tuple[str, ...]:
|
||||
"""Selectors whose rules really do ask for horizontal scrolling.
|
||||
|
||||
The tree's rule is that anything wide gets its own scroller, so these are
|
||||
the correct ones: a table wrapper, a code block, the tab bar. Everything
|
||||
else that scrolls sideways is `overflow-y: auto` dragging the other axis
|
||||
along with it, which is always a bug and is what `.suggestions` did.
|
||||
"""
|
||||
selectors: list[str] = []
|
||||
for path in sorted((STATIC / "css").glob("*.css")):
|
||||
text = re.sub(r"/\*.*?\*/", "", path.read_text(), flags=re.S)
|
||||
# Innermost blocks only: `[^{}]*` cannot cross a brace, so an `@media`
|
||||
# prelude never matches and the rules inside it do.
|
||||
for prelude, body in re.findall(r"([^{}]*)\{([^{}]*)\}", text):
|
||||
wants = False
|
||||
for declaration in body.split(";"):
|
||||
name, _, value = declaration.partition(":")
|
||||
name, value = name.strip().lower(), value.strip().lower()
|
||||
if name not in ("overflow", "overflow-x") or not value:
|
||||
continue
|
||||
# `overflow: hidden auto` is x then y, so the first word is ours;
|
||||
# `overflow: auto` is both.
|
||||
wants = wants or value.split()[0] in ("auto", "scroll")
|
||||
if not wants:
|
||||
continue
|
||||
selectors += [
|
||||
part.strip()
|
||||
for part in prelude.split(",")
|
||||
if part.strip() and not part.strip().startswith("@")
|
||||
]
|
||||
if not selectors:
|
||||
raise SystemExit("read no horizontal-overflow rules -- the sideways check would cry wolf")
|
||||
return tuple(selectors)
|
||||
|
||||
|
||||
# The chat `build_client` seeds, so a run can name `/chat/<SHOOT_CHAT>`.
|
||||
SHOOT_CHAT = "5" * 32
|
||||
|
||||
|
||||
def build_client():
|
||||
import lembas.config as config_mod
|
||||
|
||||
@@ -198,6 +296,56 @@ def build_client():
|
||||
db.flush()
|
||||
for name in ("gemma4-moe", "qwen3-coder"):
|
||||
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
|
||||
|
||||
# A second data group with a hosted model in it, a note in each group and a
|
||||
# chat with a fixed id (`SHOOT_CHAT`). Without them every grouped screen --
|
||||
# the chips, the Data tab, the models named under the picker as being in
|
||||
# another group -- is rendered with nothing in it, which is the "a screen the
|
||||
# test never renders is unchecked" lesson again.
|
||||
from lembas.db.models import Chat, DataGroup, Note, User
|
||||
|
||||
with session_scope() as db:
|
||||
db.add(DataGroup(id="hosted", name="Hosted providers"))
|
||||
hosted = Connection(
|
||||
name="hosted",
|
||||
base_url="http://127.0.0.1:2",
|
||||
api_key_encrypted="",
|
||||
data_group_id="hosted",
|
||||
)
|
||||
db.add(hosted)
|
||||
db.flush()
|
||||
db.add(
|
||||
Model(
|
||||
connection_id=hosted.id,
|
||||
model_id="deepseek-flash",
|
||||
display_name="DeepSeek Flash",
|
||||
)
|
||||
)
|
||||
owner = db.query(User).first()
|
||||
db.add(Note(owner_id=owner.id, title="A note at home", body="x", data_group_id="default"))
|
||||
db.add(Note(owner_id=owner.id, title="A hosted note", body="x", data_group_id="hosted"))
|
||||
local = db.query(Connection).filter_by(name="local").first()
|
||||
db.add(
|
||||
Chat(
|
||||
id=SHOOT_CHAT,
|
||||
user_id=owner.id,
|
||||
model_id="gemma4-moe",
|
||||
connection_id=local.id,
|
||||
data_group_id="default",
|
||||
title="A chat to measure",
|
||||
)
|
||||
)
|
||||
|
||||
# 🚨 The suggestion cards are seeded by the startup hook, and `TestClient(app)`
|
||||
# runs a lifespan only inside a `with` block -- so every shot of the new-chat
|
||||
# screen ever taken by this script was of a page with its cards missing. That
|
||||
# is how a grid 65px wider than a phone survived forty measurements. Seeded
|
||||
# here rather than by entering the lifespan, which would also start the
|
||||
# schedule ticker and rehydrate background jobs inside a screenshot run.
|
||||
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||
|
||||
with session_scope() as db:
|
||||
seed_suggestions(db)
|
||||
return client
|
||||
|
||||
|
||||
@@ -219,6 +367,20 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
html,
|
||||
)
|
||||
|
||||
# Anything else the *application* serves rather than mounts. Model avatars live
|
||||
# under `/uploads/models/…`, which is a route behind auth -- so they cannot be
|
||||
# pointed at a file on disk and have to be fetched through the client like
|
||||
# `/branding.css` above. A real instance has them and a fixture does not, which
|
||||
# is exactly the difference that makes a page measured here unlike the page
|
||||
# somebody is looking at.
|
||||
for url in sorted({*re.findall(r'\bsrc="(/(?:uploads|branding)/[^"?]+)"', html)}):
|
||||
response = client.get(url)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
name = "fetched-" + url.strip("/").replace("/", "-")
|
||||
(assets / name).write_bytes(response.content)
|
||||
html = html.replace(f'src="{url}"', f'src="file://{assets}/{name}"')
|
||||
|
||||
# Fail loudly, and only about things that decide how the page LOOKS: every
|
||||
# `src`, and `href` on a <link>. An `href` on an anchor is a destination,
|
||||
# not an asset -- flagging those makes the guard cry wolf on every page and
|
||||
@@ -256,7 +418,8 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
|
||||
"catch(e){}</script>"
|
||||
)
|
||||
return html.replace("</head>", quiet + MEASURE + "</head>", 1)
|
||||
measure = MEASURE.replace("__SIDEWAYS_AUTHORS__", json.dumps(list(authored_sideways())))
|
||||
return html.replace("</head>", quiet + measure + "</head>", 1)
|
||||
|
||||
|
||||
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
|
||||
@@ -391,6 +554,13 @@ def main() -> None:
|
||||
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
|
||||
if r["scrollsSideways"]:
|
||||
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
|
||||
for s in r.get("sidewaysScrollers", []):
|
||||
widest = s["widest"]
|
||||
blame = f"<{widest['tag']}.{widest['cls']} {widest['w']}px" if widest else ""
|
||||
flags.append(
|
||||
f"SCROLLER-SIDEWAYS({s['tag']}.{s['cls']} "
|
||||
f"{s['scrollW']}>{s['clientW']}{blame})"
|
||||
)
|
||||
if r["overflowCount"]:
|
||||
flags.append(f"overflow:{r['overflowCount']}")
|
||||
if r["smallCount"]:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.10.0"
|
||||
|
||||
+21
-1
@@ -13,9 +13,10 @@ from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Connection, Model, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import data_groups, settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
|
||||
from lembas.web import i18n
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -46,6 +47,7 @@ async def general_page(request: Request, db: Db, user: AdminUser, saved: bool =
|
||||
"admin/general.html",
|
||||
{
|
||||
"values": settings_store.get_group(db),
|
||||
"languages": i18n.LANGUAGES,
|
||||
"saved": saved,
|
||||
"user_count": db.scalar(select(func.count()).select_from(User)),
|
||||
},
|
||||
@@ -57,6 +59,7 @@ async def save_general(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
allow_signup: bool = Form(False),
|
||||
language: str = Form(""),
|
||||
system_prompt: str = Form(""),
|
||||
compact_threshold: int = Form(95),
|
||||
max_chat_rounds: int = Form(5),
|
||||
@@ -70,6 +73,9 @@ async def save_general(
|
||||
db,
|
||||
{
|
||||
"allow_signup": allow_signup,
|
||||
# Validated rather than trusted: a code this release does not have
|
||||
# would leave every page in a language nobody chose.
|
||||
"language": i18n.known(language),
|
||||
"system_prompt": system_prompt.strip()[:8000],
|
||||
# 0 is "never"; anything else is clamped into a band where it can
|
||||
# do some good. 100 is useless -- you cannot compact after
|
||||
@@ -82,6 +88,9 @@ async def save_general(
|
||||
"max_chat_rounds": min(max(max_chat_rounds, 0), 100),
|
||||
},
|
||||
)
|
||||
# The instance default is cached at process level, exactly as branding is, so
|
||||
# the one module that writes it is the one that drops the cache.
|
||||
i18n.forget()
|
||||
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
|
||||
return RedirectResponse("/admin/general?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -100,6 +109,7 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s
|
||||
},
|
||||
"message": message,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"data_group_choices": data_groups.instance_groups(db),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -166,8 +176,17 @@ async def update_connection(
|
||||
unload_url: str = Form(""),
|
||||
unload_method: str = Form("POST"),
|
||||
extra_headers: str = Form(""),
|
||||
data_group_id: str = Form(""),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
# Which of the instance's data groups this provider reads. Empty is "not
|
||||
# submitted" -- an older page -- and leaves it alone; a personal group is
|
||||
# somebody else's arrangement and cannot be chosen here.
|
||||
chosen = data_group_id.strip()
|
||||
if chosen:
|
||||
group = data_groups.get(db, chosen)
|
||||
if group is not None and group.owner_id is None:
|
||||
connection.data_group_id = group.id
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
connection.base_url = base_url.strip().rstrip("/")
|
||||
connection.enabled = enabled
|
||||
@@ -218,6 +237,7 @@ async def test_connection(
|
||||
"message": message,
|
||||
"message_kind": "error" if error else "success",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"data_group_choices": data_groups.instance_groups(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -66,10 +66,6 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
|
||||
# reply is allowed to set going on its own, and a nav entry for one
|
||||
# card would be worse than the near-miss.
|
||||
"subagents": settings_store.subagents(db),
|
||||
# And a third group on the same page, for the same reason: a crowd is
|
||||
# not an agent-chat feature either, but this is where somebody comes to
|
||||
# find out what one turn is allowed to set going.
|
||||
"crowd": settings_store.crowd(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -115,35 +111,6 @@ async def save_subagents(
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/crowd")
|
||||
async def save_crowd(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_models: int = Form(4),
|
||||
max_rounds: int = Form(2),
|
||||
wall_seconds: int = Form(900),
|
||||
collapse_agreement: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Its own route, for the reason `save_subagents` gives above."""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read. Every floor is one: a zero would be
|
||||
# the feature switched off wearing the switch's clothes, and that is a
|
||||
# thing to answer in one place.
|
||||
"max_models": min(max(max_models, 1), 8),
|
||||
"max_rounds": min(max(max_rounds, 1), 5),
|
||||
"wall_seconds": min(max(wall_seconds, 60), 7200),
|
||||
"collapse_agreement": collapse_agreement,
|
||||
},
|
||||
key=settings_store.CROWD,
|
||||
)
|
||||
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
|
||||
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
|
||||
|
||||
# Read out by the speech test. Short, and the one line this project would pick.
|
||||
TEST_PHRASE = "Speak, friend, and enter."
|
||||
TEST_PHRASE = "Speak friend and enter."
|
||||
|
||||
|
||||
def _page_context(db: Db) -> dict:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""The crowd: several models answering one turn, in any chat.
|
||||
|
||||
Its own module because it is its own page, and it is its own page because as a card
|
||||
on `/admin/agents` it read as an agent-chat feature. It is not one: a crowd works in
|
||||
an ordinary conversation, and the owner reasonably concluded otherwise from where
|
||||
the switch was sitting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/crowd", tags=["admin-crowd"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def crowd_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
"""Its own page, for the reason its template records: as a card on the Agents
|
||||
screen it read as an agent-chat feature, which it is not."""
|
||||
return render(
|
||||
request,
|
||||
"admin/crowd.html",
|
||||
{"crowd": settings_store.crowd(db), "saved": saved},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_crowd(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_models: int = Form(4),
|
||||
max_rounds: int = Form(2),
|
||||
wall_seconds: int = Form(900),
|
||||
collapse_agreement: bool = Form(False),
|
||||
) -> Response:
|
||||
"""One group, one form, one route.
|
||||
|
||||
The bounds are clamped here as well as in `settings_store.crowd`, which is the
|
||||
same belt-and-braces `save_subagents` in `admin_agents.py` uses: a value posted
|
||||
past this route -- by an older page, or by hand -- still reads back sane.
|
||||
"""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Every floor is one: a zero would be the feature switched off
|
||||
# wearing the switch's clothes.
|
||||
"max_models": min(max(max_models, 1), 8),
|
||||
"max_rounds": min(max(max_rounds, 1), 5),
|
||||
"wall_seconds": min(max(wall_seconds, 60), 7200),
|
||||
"collapse_agreement": collapse_agreement,
|
||||
},
|
||||
key=settings_store.CROWD,
|
||||
)
|
||||
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/crowd?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
+88
-39
@@ -34,9 +34,9 @@ from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import data_groups, interaction, settings_store, sse
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import interaction, settings_store, sse
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import reports as reports_service
|
||||
@@ -222,6 +222,14 @@ def _new_chat(
|
||||
folder_id=folder.id if folder is not None else None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
# The chat's data group is its model's, fixed now. It is what every
|
||||
# later turn reads memories and notes from, and what decides which models
|
||||
# this chat may be switched to -- see services/data_groups.py.
|
||||
data_group_id=(
|
||||
data_groups.for_pair(db, user, chosen[0], chosen[1])
|
||||
if chosen
|
||||
else data_groups.DEFAULT_GROUP
|
||||
),
|
||||
temporary=temporary,
|
||||
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
||||
ssh_profile_id=profile.id if profile is not None else None,
|
||||
@@ -296,6 +304,11 @@ async def start_chat(
|
||||
scope_on: list[str] = Form(default=[]),
|
||||
scope_skill_all: list[str] = Form(default=[]),
|
||||
scope_skill_on: list[str] = Form(default=[]),
|
||||
# Who else answers, as the crowd menu stood before the first word. There is no
|
||||
# chat row yet to attach members to, so the choice rides along with the message
|
||||
# -- the same mechanism the scope switches above use, and the reason the control
|
||||
# lives inside the composer's form rather than in the topbar.
|
||||
crowd_model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -329,6 +342,8 @@ async def start_chat(
|
||||
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
|
||||
)
|
||||
|
||||
_apply_crowd(db, chat, user, crowd_model_ids)
|
||||
|
||||
_adopt_draft(db, user, draft_id, chat)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
@@ -650,8 +665,12 @@ async def attach_base(
|
||||
if not permissions.has(db, user, "library.use"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
|
||||
|
||||
# Only a base in the chat's own data group: its documents are what this
|
||||
# chat's model would search, and another group's are not its to read.
|
||||
base = db.scalar(
|
||||
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
|
||||
documents_service.visible_bases(db, user, data_groups.for_chat(db, chat)).where(
|
||||
KnowledgeBase.id == base_id
|
||||
)
|
||||
)
|
||||
if base is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||
@@ -1510,6 +1529,50 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> None:
|
||||
"""Replace a chat's crowd with the models named, in the order named.
|
||||
|
||||
One implementation for both the composer (where the choice rides along with
|
||||
the first message) and the settings panel, because two would be two places to
|
||||
forget a rule -- and there are three:
|
||||
|
||||
* **Checked against what this person can reach**, never against what exists.
|
||||
A control checked only in the template is advisory, and a crafted request
|
||||
walks past it. Same reasoning as the model branch in `update_chat`.
|
||||
* **Never the chat's own model**, which would answer twice in a row.
|
||||
* **Capped by `crowd.max_models`**, on the way in as well as on the way out.
|
||||
|
||||
The connection is stored beside the id because `Model` is unique on the pair,
|
||||
and a model offered by two connections is two rows with different capabilities.
|
||||
"""
|
||||
from lembas.db.models import CrowdMember
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
# Only models in the chat's own data group: a member is sent the whole
|
||||
# conversation, so one from another group would carry it to that provider.
|
||||
reachable = {
|
||||
model.model_id: model
|
||||
for model in chat_service.available_models(
|
||||
db, user, data_groups.for_chat(db, chat)
|
||||
)
|
||||
}
|
||||
wanted: list[str] = []
|
||||
for value in values:
|
||||
value = str(value).strip()
|
||||
if value and value in reachable and value != chat.model_id and value not in wanted:
|
||||
wanted.append(value)
|
||||
wanted = wanted[: int(settings["max_models"])]
|
||||
|
||||
chat.crowd = [
|
||||
CrowdMember(
|
||||
model_id=model_id,
|
||||
connection_id=reachable[model_id].connection_id,
|
||||
position=index,
|
||||
)
|
||||
for index, model_id in enumerate(wanted)
|
||||
]
|
||||
|
||||
|
||||
def _messages_after(db: DBSession, message: Message) -> list[Message]:
|
||||
"""Everything later in this chat than one message.
|
||||
|
||||
@@ -2079,11 +2142,28 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
)
|
||||
# Checked against what this user can reach, not merely what exists --
|
||||
# otherwise the picker is advisory and a crafted request bypasses it.
|
||||
# And within the chat's own data group: the new model would be sent the
|
||||
# whole history, which is exactly what a group keeps from its provider.
|
||||
group = data_groups.for_chat(db, chat)
|
||||
match = next(
|
||||
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
|
||||
(
|
||||
m
|
||||
for m in chat_service.available_models(db, user, group)
|
||||
if m.model_id == model_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
elsewhere = any(
|
||||
m.model_id == model_id for m in chat_service.available_models(db, user)
|
||||
)
|
||||
if elsewhere:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"That model is in another data group than this chat "
|
||||
f"({data_groups.name_of(db, group)}), so it cannot be given this "
|
||||
f"chat's history. Start a new chat with it instead.",
|
||||
)
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
|
||||
chat.model_id = model_id
|
||||
chat.connection_id = match.connection_id
|
||||
@@ -2106,9 +2186,9 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
chat.knowledge_bases = (
|
||||
list(
|
||||
db.scalars(
|
||||
documents_service.visible_bases(db, user).where(
|
||||
KnowledgeBase.id.in_(wanted)
|
||||
)
|
||||
documents_service.visible_bases(
|
||||
db, user, data_groups.for_chat(db, chat)
|
||||
).where(KnowledgeBase.id.in_(wanted))
|
||||
)
|
||||
)
|
||||
if wanted
|
||||
@@ -2117,39 +2197,8 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
|
||||
if "crowd_model_ids" in form:
|
||||
# The same shape as the bases above: one field always sent, so clearing
|
||||
# every box clears the crowd. Checked against what this person can reach
|
||||
# rather than against what exists, or the picker is advisory and a crafted
|
||||
# request walks past it -- the reasoning the model branch carries.
|
||||
from lembas.db.models import CrowdMember
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
reachable = {
|
||||
model.model_id for model in chat_service.available_models(db, user)
|
||||
}
|
||||
wanted: list[str] = []
|
||||
for value in form.getlist("crowd_model_ids"):
|
||||
value = str(value).strip()
|
||||
# Never the chat's own model: it would answer twice in a row, which is
|
||||
# nobody's idea of a second opinion.
|
||||
if value and value in reachable and value != chat.model_id and value not in wanted:
|
||||
wanted.append(value)
|
||||
wanted = wanted[: int(settings["max_models"])]
|
||||
|
||||
chat.crowd = [
|
||||
CrowdMember(
|
||||
model_id=model_id,
|
||||
connection_id=next(
|
||||
(
|
||||
model.connection_id
|
||||
for model in chat_service.available_models(db, user)
|
||||
if model.model_id == model_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
position=index,
|
||||
)
|
||||
for index, model_id in enumerate(wanted)
|
||||
]
|
||||
# every box clears the crowd.
|
||||
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
||||
|
||||
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
||||
if submitted_params:
|
||||
|
||||
+19
-1
@@ -13,6 +13,7 @@ from starlette.requests import HTTPConnection
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import get_session_factory
|
||||
from lembas.security.sessions import COOKIE_NAME, resolve_session
|
||||
from lembas.web import i18n
|
||||
|
||||
|
||||
def get_db() -> Iterator[DBSession]:
|
||||
@@ -27,9 +28,21 @@ def get_db() -> Iterator[DBSession]:
|
||||
Db = Annotated[DBSession, Depends(get_db)]
|
||||
|
||||
|
||||
def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
|
||||
async def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
|
||||
"""Resolve the session cookie to a user, or None when signed out.
|
||||
|
||||
⚠ `async def`, and that is load-bearing rather than tidy. FastAPI runs a
|
||||
*sync* dependency in a threadpool, and `i18n.activate` below sets a
|
||||
`ContextVar` -- which anyio copies **into** the thread and discards on the way
|
||||
out, so the language was set in a context nothing else could see and every
|
||||
page rendered in English however anybody's preference was stored. An async
|
||||
dependency is awaited in the request's own task, where the value survives to
|
||||
the render.
|
||||
|
||||
What it costs is one indexed SELECT on the event loop rather than in a
|
||||
thread, which is what every route in this application already does with its
|
||||
session.
|
||||
|
||||
Cached on the connection's state so several dependencies in one request do
|
||||
not each hit the sessions table.
|
||||
|
||||
@@ -44,6 +57,11 @@ def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
|
||||
return cached
|
||||
user = resolve_session(db, conn.cookies.get(COOKIE_NAME))
|
||||
conn.state.user = user
|
||||
# The language this request renders in, set here because this is where the
|
||||
# person is already known -- no second session and no second cookie read. A
|
||||
# request that never resolves a user keeps whatever `LanguageMiddleware` set,
|
||||
# which is the instance default.
|
||||
i18n.activate(i18n.for_user(user))
|
||||
return user
|
||||
|
||||
|
||||
|
||||
+57
-18
@@ -21,8 +21,8 @@ from sqlalchemy import select
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import Attachment, Chat, Document, KnowledgeBase, Note
|
||||
from lembas.security import permissions
|
||||
from lembas.services import data_groups, settings_store
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.fetch import FetchError, fetch
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import notes as notes_service
|
||||
@@ -119,7 +119,7 @@ async def attach_link(
|
||||
@router.post("/from-knowledge", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_knowledge(
|
||||
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
chat_id: str = Form(""), model_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Attach a library document to the message being composed.
|
||||
|
||||
@@ -127,7 +127,8 @@ async def attach_from_knowledge(
|
||||
conversation because a document was later edited or deleted -- the same
|
||||
reason a PDF's text is extracted once at upload rather than per request.
|
||||
"""
|
||||
document = documents_service.get(db, document_id, user)
|
||||
group = data_groups.for_composer(db, user, chat_id, model_id)
|
||||
document = documents_service.get(db, document_id, user, group)
|
||||
if document is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -163,7 +164,12 @@ def _not_available(request: Request, what: str) -> Response:
|
||||
|
||||
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_note(
|
||||
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
note_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
model_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Attach a note the model wrote earlier.
|
||||
|
||||
@@ -171,7 +177,9 @@ async def attach_from_note(
|
||||
document, and a transcript that changes underneath itself because somebody
|
||||
tidied a note later is the thing all of this is arranged to prevent.
|
||||
"""
|
||||
note = notes_service.get(db, note_id, user)
|
||||
note = notes_service.get(
|
||||
db, note_id, user, data_groups.for_composer(db, user, chat_id, model_id)
|
||||
)
|
||||
if note is None:
|
||||
return _not_available(request, "note")
|
||||
|
||||
@@ -226,7 +234,12 @@ async def attach_from_scratch(
|
||||
|
||||
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_skill(
|
||||
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
skill_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
model_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Hand a skill over directly, rather than hoping the model fetches it.
|
||||
|
||||
@@ -234,7 +247,9 @@ async def attach_from_skill(
|
||||
a body on demand -- but only if the model decides to. `@` is the reader
|
||||
saying "use this one", which is a different act and deserves a way to say it.
|
||||
"""
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
skill = skills_service.get(
|
||||
db, skill_id, user, data_groups.for_composer(db, user, chat_id, model_id)
|
||||
)
|
||||
if skill is None:
|
||||
return _not_available(request, "skill")
|
||||
|
||||
@@ -259,6 +274,7 @@ async def attach_from_attachment(
|
||||
user: RequiredUser,
|
||||
attachment_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
model_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Point at something already in this conversation, without uploading again.
|
||||
|
||||
@@ -269,6 +285,12 @@ async def attach_from_attachment(
|
||||
original = db.get(Attachment, attachment_id)
|
||||
if original is None or original.user_id != user.id:
|
||||
return _not_available(request, "attachment")
|
||||
# Another chat's attachment is that chat's data, in that chat's group.
|
||||
source = db.get(Chat, original.chat_id) if original.chat_id else None
|
||||
if source is not None and data_groups.for_chat(db, source) != data_groups.for_composer(
|
||||
db, user, chat_id, model_id
|
||||
):
|
||||
return _not_available(request, "attachment")
|
||||
|
||||
return _chip(
|
||||
request,
|
||||
@@ -280,15 +302,24 @@ async def attach_from_attachment(
|
||||
|
||||
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def knowledge_picker(
|
||||
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
chat_id: str = "",
|
||||
model_id: str = "",
|
||||
) -> Response:
|
||||
"""The list of documents shown by the composer's Knowledge option."""
|
||||
"""The list of documents shown by the composer's Knowledge option.
|
||||
|
||||
Only the composer's own data group: see `data_groups.for_composer`.
|
||||
"""
|
||||
group = data_groups.for_composer(db, user, chat_id, model_id)
|
||||
if q.strip():
|
||||
found = documents_service.search(db, user, q, limit=20)
|
||||
found = documents_service.search(db, user, q, limit=20, group=group)
|
||||
else:
|
||||
found = list(
|
||||
db.scalars(
|
||||
documents_service.visible(db, user)
|
||||
documents_service.visible(db, user, group=group)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
@@ -311,6 +342,7 @@ async def mention_picker(
|
||||
chat_id: str = "",
|
||||
profile_id: str = "",
|
||||
project_dir: str = "",
|
||||
model_id: str = "",
|
||||
) -> Response:
|
||||
"""What `@` offers: files under the project directory, and the library.
|
||||
|
||||
@@ -348,24 +380,29 @@ async def mention_picker(
|
||||
skills: list = []
|
||||
bases: list = []
|
||||
if permissions.has(db, user, "library.use"):
|
||||
# The library half offers only the composer's own data group: whatever is
|
||||
# picked becomes part of the conversation and goes to the chat's model.
|
||||
group = data_groups.for_composer(db, user, chat_id, model_id)
|
||||
if needle:
|
||||
documents = documents_service.search(db, user, q, limit=10)
|
||||
notes = notes_service.search(db, user, q, limit=5)
|
||||
skills = skills_service.search(db, user, q, limit=5)
|
||||
documents = documents_service.search(db, user, q, limit=10, group=group)
|
||||
notes = notes_service.search(db, user, q, limit=5, group=group)
|
||||
skills = skills_service.search(db, user, q, limit=5, group=group)
|
||||
else:
|
||||
documents = list(
|
||||
db.scalars(
|
||||
documents_service.visible(db, user)
|
||||
documents_service.visible(db, user, group=group)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
notes = list(
|
||||
db.scalars(
|
||||
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
|
||||
notes_service.visible(db, user, group)
|
||||
.order_by(Note.updated_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
)
|
||||
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
|
||||
skills = list(db.scalars(skills_service.visible(db, user, group).limit(5)))
|
||||
|
||||
# A whole base is a *reference*, not a copy: attaching one scopes the
|
||||
# chat to it and the model searches inside it. Dumping the contents of
|
||||
@@ -377,7 +414,9 @@ async def mention_picker(
|
||||
bases = [
|
||||
base
|
||||
for base in db.scalars(
|
||||
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
|
||||
documents_service.visible_bases(db, user, group).order_by(
|
||||
KnowledgeBase.name
|
||||
)
|
||||
)
|
||||
if not needle or needle in base.name.lower()
|
||||
][:5]
|
||||
|
||||
+146
-16
@@ -33,8 +33,8 @@ from lembas.db.models import (
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.services import data_groups, settings_store, sharing
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services.fetch import FetchError, fetch
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import memories as memories_service
|
||||
@@ -60,6 +60,46 @@ def _page(db: DBSession, query, page: int):
|
||||
return rows, {"page": page, "pages": pages, "total": total}
|
||||
|
||||
|
||||
def _groups(db: DBSession, user: User) -> dict:
|
||||
"""What `library/_group.html` needs on every library page.
|
||||
|
||||
`several_groups` false is the ordinary instance, and then nothing about
|
||||
groups is rendered anywhere in the library.
|
||||
"""
|
||||
usable = data_groups.usable(db, user)
|
||||
return {
|
||||
"several_groups": len(usable) > 1,
|
||||
"data_groups": usable,
|
||||
"group_names": {group.id: group.name for group in data_groups.all_groups(db)},
|
||||
"may_move_groups": data_groups.may_manage(db, user),
|
||||
}
|
||||
|
||||
|
||||
def _chosen_group(db: DBSession, user: User, value: str) -> str:
|
||||
"""A submitted group, if this person may use it; otherwise the default."""
|
||||
value = (value or "").strip()
|
||||
if value and data_groups.may_use(db, user, value):
|
||||
return value
|
||||
return data_groups.DEFAULT_GROUP
|
||||
|
||||
|
||||
def _move(db: DBSession, user: User, row, value) -> None:
|
||||
"""Move a record into another group, when that was asked and is allowed.
|
||||
|
||||
`None` is a form that did not carry the field -- a single-group instance,
|
||||
or somebody without `data.manage` -- and leaves the record where it is.
|
||||
"""
|
||||
if value is None or not data_groups.may_manage(db, user):
|
||||
return
|
||||
wanted = str(value).strip()
|
||||
if wanted and data_groups.may_use(db, user, wanted):
|
||||
row.data_group_id = wanted
|
||||
|
||||
|
||||
def _group_filter(query, model, group: str):
|
||||
return query.where(data_groups.condition(model, group)) if group else query
|
||||
|
||||
|
||||
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
|
||||
"""What the share placeholder needs, which is now three facts.
|
||||
|
||||
@@ -87,7 +127,12 @@ async def library_home(user: RequiredUser):
|
||||
# FastAPI matches in registration order and this has bitten before.
|
||||
@router.get("/library/knowledge")
|
||||
async def knowledge_list(
|
||||
request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
error: str = "",
|
||||
shared: bool = False,
|
||||
group: str = "",
|
||||
):
|
||||
"""The bases, not the documents. A library is a set of places first.
|
||||
|
||||
@@ -100,6 +145,7 @@ async def knowledge_list(
|
||||
if shared
|
||||
else documents_service.visible_bases(db, user)
|
||||
)
|
||||
query = _group_filter(query, KnowledgeBase, group)
|
||||
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
||||
counts = {
|
||||
base.id: db.scalar(
|
||||
@@ -117,6 +163,8 @@ async def knowledge_list(
|
||||
"counts": counts,
|
||||
"shared": shared,
|
||||
"error": error,
|
||||
"group": group,
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -124,11 +172,19 @@ async def knowledge_list(
|
||||
|
||||
@router.post("/api/library/bases")
|
||||
async def create_base(
|
||||
db: Db, user: RequiredUser, name: str = Form(""), description: str = Form("")
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
data_group_id: str = Form(""),
|
||||
) -> Response:
|
||||
try:
|
||||
base = documents_service.create_base(
|
||||
db, owner=user, name=name, description=description
|
||||
db,
|
||||
owner=user,
|
||||
name=name,
|
||||
description=description,
|
||||
group=_chosen_group(db, user, data_group_id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
from urllib.parse import quote
|
||||
@@ -163,6 +219,7 @@ async def knowledge_detail(request: Request, db: Db, user: RequiredUser, documen
|
||||
.order_by(KnowledgeBase.name)
|
||||
)
|
||||
),
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -202,6 +259,7 @@ async def base_detail(
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**_shared_context(db, user, base, "base"),
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -220,6 +278,8 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
|
||||
if name:
|
||||
base.name = name
|
||||
base.description = str(form.get("description", "")).strip()[:2000]
|
||||
# A base moves with every document in it: they have no group of their own.
|
||||
_move(db, user, base, form.get("data_group_id"))
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
@@ -302,7 +362,17 @@ async def update_document(
|
||||
wanted = str(form.get("base_id", "")).strip()
|
||||
if wanted and wanted != document.base_id:
|
||||
destination = documents_service.get_base(db, wanted, user)
|
||||
if destination is not None and sharing.can_write(destination, user):
|
||||
current = db.get(KnowledgeBase, document.base_id) if document.base_id else None
|
||||
# Into a base in another data group is a move between groups, which
|
||||
# changes which providers may read it -- `data.manage`, like any move.
|
||||
crosses = current is not None and destination is not None and (
|
||||
data_groups.group_of(current) != data_groups.group_of(destination)
|
||||
)
|
||||
if (
|
||||
destination is not None
|
||||
and sharing.can_write(destination, user)
|
||||
and (not crosses or data_groups.may_manage(db, user))
|
||||
):
|
||||
document.base_id = destination.id
|
||||
|
||||
db.commit()
|
||||
@@ -352,6 +422,7 @@ async def notes_list(
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
group: str = "",
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
@@ -363,7 +434,12 @@ async def notes_list(
|
||||
"""
|
||||
if q.strip():
|
||||
rows = notes_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
db,
|
||||
user,
|
||||
q,
|
||||
limit=PAGE_SIZE,
|
||||
vector=await retrieval.embed_query(db, q),
|
||||
group=group or None,
|
||||
)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
@@ -372,6 +448,7 @@ async def notes_list(
|
||||
if shared
|
||||
else notes_service.visible(db, user)
|
||||
)
|
||||
query = _group_filter(query, Note, group)
|
||||
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
||||
return render(
|
||||
request,
|
||||
@@ -382,17 +459,25 @@ async def notes_list(
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
"group": group,
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/notes/new")
|
||||
async def new_note(request: Request, db: Db, user: RequiredUser):
|
||||
async def new_note(request: Request, db: Db, user: RequiredUser, group: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"library/note_detail.html",
|
||||
{"section": "notes", "note": None, **sidebar_context(db, user)},
|
||||
{
|
||||
"section": "notes",
|
||||
"note": None,
|
||||
"group": group,
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -409,6 +494,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
"note": note,
|
||||
"body_html": render_markdown(note.body),
|
||||
**_shared_context(db, user, note, "note"),
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -416,9 +502,20 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
|
||||
@router.post("/api/library/notes")
|
||||
async def create_note(
|
||||
db: Db, user: RequiredUser, title: str = Form(""), body: str = Form("")
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
title: str = Form(""),
|
||||
body: str = Form(""),
|
||||
data_group_id: str = Form(""),
|
||||
) -> Response:
|
||||
note = notes_service.create(db, owner=user, title=title, body=body, author=AUTHOR_USER)
|
||||
note = notes_service.create(
|
||||
db,
|
||||
owner=user,
|
||||
title=title,
|
||||
body=body,
|
||||
author=AUTHOR_USER,
|
||||
group=_chosen_group(db, user, data_group_id),
|
||||
)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -431,6 +528,7 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That note is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
_move(db, user, note, form.get("data_group_id"))
|
||||
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -453,6 +551,7 @@ async def skills_list(
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
group: str = "",
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
@@ -464,7 +563,12 @@ async def skills_list(
|
||||
"""
|
||||
if q.strip():
|
||||
rows = skills_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
db,
|
||||
user,
|
||||
q,
|
||||
limit=PAGE_SIZE,
|
||||
vector=await retrieval.embed_query(db, q),
|
||||
group=group or None,
|
||||
)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
@@ -473,6 +577,7 @@ async def skills_list(
|
||||
if shared
|
||||
else skills_service.visible(db, user)
|
||||
)
|
||||
query = _group_filter(query, Skill, group)
|
||||
rows, pager = _page(db, query.order_by(Skill.name), page)
|
||||
return render(
|
||||
request,
|
||||
@@ -483,17 +588,25 @@ async def skills_list(
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
"group": group,
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/skills/new")
|
||||
async def new_skill(request: Request, db: Db, user: RequiredUser):
|
||||
async def new_skill(request: Request, db: Db, user: RequiredUser, group: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"library/skill_detail.html",
|
||||
{"section": "skills", "skill": None, **sidebar_context(db, user)},
|
||||
{
|
||||
"section": "skills",
|
||||
"skill": None,
|
||||
"group": group,
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -510,6 +623,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
"skill": skill,
|
||||
"revisions": skill.revisions,
|
||||
**_shared_context(db, user, skill, "skill"),
|
||||
**_groups(db, user),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -522,10 +636,17 @@ async def create_skill(
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
body: str = Form(""),
|
||||
data_group_id: str = Form(""),
|
||||
) -> Response:
|
||||
try:
|
||||
skill = skills_service.create(
|
||||
db, owner=user, name=name, description=description, body=body, author=AUTHOR_USER
|
||||
db,
|
||||
owner=user,
|
||||
name=name,
|
||||
description=description,
|
||||
body=body,
|
||||
author=AUTHOR_USER,
|
||||
group=_chosen_group(db, user, data_group_id),
|
||||
)
|
||||
except skills_service.SkillError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
@@ -541,6 +662,7 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That skill is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
_move(db, user, skill, form.get("data_group_id"))
|
||||
skills_service.update(
|
||||
db,
|
||||
skill,
|
||||
@@ -581,9 +703,17 @@ async def delete_skill(db: Db, user: RequiredUser, skill_id: str) -> Response:
|
||||
# Lives in Settings rather than in the library: it is a set of short facts about
|
||||
# the reader, not content they collected.
|
||||
@router.post("/api/library/memories")
|
||||
async def add_memory(db: Db, user: RequiredUser, content: str = Form("")) -> Response:
|
||||
async def add_memory(
|
||||
db: Db, user: RequiredUser, content: str = Form(""), data_group_id: str = Form("")
|
||||
) -> Response:
|
||||
try:
|
||||
memories_service.add(db, owner=user, content=content, author=AUTHOR_USER)
|
||||
memories_service.add(
|
||||
db,
|
||||
owner=user,
|
||||
content=content,
|
||||
author=AUTHOR_USER,
|
||||
group=_chosen_group(db, user, data_group_id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
@@ -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)}
|
||||
+150
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
@@ -17,6 +18,7 @@ from lembas.db.models import (
|
||||
KIND_TASK,
|
||||
KINDS,
|
||||
Chat,
|
||||
Connection,
|
||||
Folder,
|
||||
KnowledgeBase,
|
||||
Message,
|
||||
@@ -28,11 +30,12 @@ from lembas.services import branding as branding_service
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import data_groups, settings_store
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.web import i18n
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
@@ -62,8 +65,18 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""
|
||||
models = chat_service.available_models(db, user)
|
||||
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
||||
# A chat that exists may only be switched to a model in its own data group:
|
||||
# the new model would be sent the whole history. The rest are named below
|
||||
# the picker rather than silently missing from it, so somebody looking for
|
||||
# one learns where it went -- and that a new chat is how to reach it.
|
||||
group = data_groups.for_chat(db, chat) if chat is not None else None
|
||||
in_group = chat_service.available_models(db, user, group) if group is not None else models
|
||||
in_group_ids = {m.id for m in in_group}
|
||||
return {
|
||||
"models": models,
|
||||
"models": in_group,
|
||||
"models_elsewhere": [m for m in models if m.id not in in_group_ids],
|
||||
"chat_group_name": data_groups.name_of(db, group) if group is not None else "",
|
||||
"several_groups": data_groups.several(db, user),
|
||||
"current_model": current,
|
||||
# Assistant bubbles show the avatar of the model that wrote them, which
|
||||
# may not be the model the chat is set to now. Keyed by model_id, the
|
||||
@@ -75,14 +88,18 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"knowledge_bases": (
|
||||
list(
|
||||
db.scalars(
|
||||
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
|
||||
documents_service.visible_bases(db, user, group).order_by(
|
||||
KnowledgeBase.name
|
||||
)
|
||||
)
|
||||
)
|
||||
if permissions.has(db, user, "library.use")
|
||||
else []
|
||||
),
|
||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||
**_crowd_context(db, user, chat, models),
|
||||
**_crowd_context(
|
||||
db, user, chat, in_group, current.model_id if current is not None else ""
|
||||
),
|
||||
# What *this* model takes, not the three every model used to be assumed
|
||||
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
||||
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||
@@ -196,12 +213,55 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -> dict:
|
||||
def _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 _crowd_context(
|
||||
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
||||
) -> dict:
|
||||
"""Who else could answer in this chat, and what that would cost.
|
||||
|
||||
Empty — and the panel then shows nothing rather than an empty control — when
|
||||
the feature is off, when there is nobody else to add, or on the new-chat
|
||||
screen, where there is no chat to attach anybody to yet.
|
||||
Offered on the **new-chat screen as well**, where there is no chat row yet: the
|
||||
choice rides along with the first message, the way the scope switches do. The
|
||||
first version of this was per-chat only and therefore invisible to anybody
|
||||
setting a conversation up — which is how the feature shipped switched on and
|
||||
unreachable. Empty only when the feature is off or there is nobody else to add,
|
||||
and then the control is absent rather than being an empty menu.
|
||||
|
||||
The cost is spelled out because it is the thing somebody will not have thought
|
||||
about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint
|
||||
@@ -210,21 +270,42 @@ def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -
|
||||
from lembas.services import crowd as crowd_service
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
if chat is None or not settings["enabled"]:
|
||||
if not settings["enabled"]:
|
||||
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
|
||||
|
||||
others = [model for model in models if model.model_id != chat.model_id]
|
||||
members = [
|
||||
# On the new-chat screen the "own" model is whichever one the picker is
|
||||
# showing, so the list excludes it for the same reason it does in a chat:
|
||||
# adding it would have it answer twice in a row.
|
||||
own = chat.model_id if chat is not None else default_model_id
|
||||
# Only the main model's data group: a member is sent the whole conversation.
|
||||
# On the new-chat screen that is the group of the model the picker shows,
|
||||
# which is the one the chat will be pinned to.
|
||||
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 group is not None:
|
||||
allowed = {m.id for m in chat_service.available_models(db, user, group)}
|
||||
models = [model for model in models if model.id in allowed]
|
||||
others = [model for model in models if model.model_id != own]
|
||||
members = (
|
||||
[
|
||||
row.model_id
|
||||
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
|
||||
]
|
||||
if chat is not None
|
||||
else []
|
||||
)
|
||||
reachable = {model.model_id for model in others}
|
||||
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
|
||||
rounds = int(settings["max_rounds"])
|
||||
return {
|
||||
"crowd_available": others,
|
||||
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
||||
"crowd_skipped": crowd_service.unreachable_members(db, chat, user),
|
||||
"crowd_skipped": (
|
||||
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
|
||||
),
|
||||
# One round is out and back: everybody answers, everybody but the last is
|
||||
# asked whether they disagree, and the main model closes.
|
||||
"crowd_replies": max(1, speakers * 2 - 1),
|
||||
@@ -709,6 +790,24 @@ async def chat_index(
|
||||
if preselected is None and context["models"]:
|
||||
preselected = context["models"][0]
|
||||
|
||||
# Every preselection lives in the URL, so every link that changes one of
|
||||
# them has to carry the rest. The temporary toggle used to link to a bare
|
||||
# `/chat?temporary=1` and the model picker to a bare `/chat?model=`, so
|
||||
# each undid the other: temporary chats could only ever be started on the
|
||||
# default model. The model goes last in the picker's URL because ui.js
|
||||
# appends the chosen id to it.
|
||||
carried = {
|
||||
"model": model if model and preselected and preselected.model_id == model else "",
|
||||
"temporary": "1" if temporary else "",
|
||||
"kind": kind if kind in KINDS and kind != KIND_CHAT else "",
|
||||
"folder": starting_folder.id if starting_folder is not None else "",
|
||||
}
|
||||
|
||||
def new_chat_url(**changes: str) -> str:
|
||||
query = urlencode({k: v for k, v in {**carried, **changes}.items() if v})
|
||||
return f"/chat?{query}" if query else "/chat"
|
||||
|
||||
without_model = new_chat_url(model="")
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
@@ -718,7 +817,31 @@ async def chat_index(
|
||||
"bodies": {},
|
||||
**context,
|
||||
"current_model": preselected,
|
||||
# `_chat_context` reads the efforts off the *chat's* model, and there
|
||||
# is no chat here -- so every new chat was offered the generic three
|
||||
# whatever it was about to talk to. On Bonsai (low, medium, xhigh)
|
||||
# the configured `xhigh` was not among them, and the picker fell
|
||||
# through to "off". The chat created from this screen then got
|
||||
# `xhigh` anyway, so the control said one thing and the first reply
|
||||
# did another.
|
||||
"efforts": (
|
||||
chat_service.efforts_for(preselected)
|
||||
if preselected
|
||||
else chat_service.DEFAULT_EFFORTS
|
||||
),
|
||||
# `_chat_context` had no chat and so no main model to build the crowd
|
||||
# list from: the list offered here included the model it would be
|
||||
# added to, and ignored which data group the new chat is going into.
|
||||
**_crowd_context(
|
||||
db,
|
||||
user,
|
||||
None,
|
||||
context["models"],
|
||||
preselected.model_id if preselected is not None else "",
|
||||
),
|
||||
"starting_temporary": temporary,
|
||||
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
|
||||
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
|
||||
"starting_kind": kind if kind in KINDS else KIND_CHAT,
|
||||
"starting_folder": starting_folder,
|
||||
"suggestions": suggestions_service.visible(db),
|
||||
@@ -907,12 +1030,26 @@ async def settings_page(
|
||||
# would leave no way to delete them.
|
||||
"personalities": personas_service.personas_of(db, user),
|
||||
"impressions": personas_service.impressions_for(db, user),
|
||||
# A person's key carries the data group after the model id; the
|
||||
# template shows the two apart rather than printing the raw key.
|
||||
"split_key": personas_service.split_key,
|
||||
**_data_group_settings(db, user),
|
||||
# Sorted rather than left in set order, because a list of six
|
||||
# hundred zones that is not alphabetical is one nobody can use.
|
||||
"languages": i18n.LANGUAGES,
|
||||
# Their own choice, and what "follow the instance" currently means --
|
||||
# named rather than left blank, because "follow the instance" is only a
|
||||
# useful option if you can see what you would be following.
|
||||
"chosen_language": str((user.settings_json or {}).get("language") or ""),
|
||||
"instance_language": dict(i18n.LANGUAGES).get(
|
||||
i18n.instance_default(), i18n.instance_default()
|
||||
),
|
||||
"timezones": sorted(available_timezones()),
|
||||
"timezone": clock.name_for(user),
|
||||
"server_timezone": str(clock.server_zone()),
|
||||
"local_now": clock.now_for(user).strftime("%H:%M on %A %-d %B"),
|
||||
# Through `i18n.stamp`, not `strftime`: `%A` and `%B` are C-locale
|
||||
# English whatever the page is in, and this one is read by a person.
|
||||
"local_now": i18n.stamp(clock.now_for(user), "%H:%M on %A %-d %B"),
|
||||
**context,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ from lembas.config import settings
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.web import i18n
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +67,29 @@ async def set_timezone(db: Db, user: RequiredUser, timezone: str = Form("")) ->
|
||||
return RedirectResponse("/settings?saved=timezone", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/language")
|
||||
async def set_language(db: Db, user: RequiredUser, language: str = Form("")) -> Response:
|
||||
"""Which language this person sees the interface in.
|
||||
|
||||
Empty is a real answer -- "whatever the instance is set to" -- rather than an
|
||||
unset field, which is why it is stored as "" rather than removed. The same
|
||||
shape the timezone above uses, and for the same reason: absent and "follow the
|
||||
default" are different states, and a form cannot tell them apart otherwise.
|
||||
|
||||
Unlike the theme and the layout this needs no `localStorage` tier. Those two
|
||||
exist there because a paint that starts in the wrong theme flashes; text is
|
||||
rendered on the server and cannot.
|
||||
"""
|
||||
chosen = (language or "").strip().lower()
|
||||
if chosen and chosen not in i18n.LANGUAGE_IDS:
|
||||
return RedirectResponse(
|
||||
"/settings?error=language", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
user.settings_json = {**(user.settings_json or {}), "language": chosen}
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=language", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# Which CSS variables a browser is allowed to set from here, and how far. An
|
||||
# open dict would let a page store anything under somebody's account and have
|
||||
# it read back on every load; a width outside these bounds would hand them a
|
||||
@@ -269,3 +293,81 @@ async def change_password(
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# --- Data groups -------------------------------------------------------------
|
||||
# A person's own arrangement of which provider may read which of their data.
|
||||
# Everything here needs `data.manage`: it changes what a provider can see, and an
|
||||
# instance that never granted it keeps the arrangement its administrator made.
|
||||
def _refuse_without_manage(db, user) -> Response | None:
|
||||
from lembas.services import data_groups
|
||||
|
||||
if data_groups.may_manage(db, user):
|
||||
return None
|
||||
return RedirectResponse(
|
||||
"/settings?error=You+may+not+manage+your+own+data+groups.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/data-groups")
|
||||
async def set_data_groups(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
"""Which group each connection reads, for this person.
|
||||
|
||||
One select per connection, named `group__<connection id>`; empty means
|
||||
"follow the instance", which removes the entry rather than storing a copy of
|
||||
the administrator's choice -- a copy would stop following it the day it
|
||||
changed.
|
||||
"""
|
||||
from lembas.services import data_groups
|
||||
|
||||
refused = _refuse_without_manage(db, user)
|
||||
if refused is not None:
|
||||
return refused
|
||||
form = await request.form()
|
||||
chosen: dict[str, str] = {}
|
||||
for key, value in form.items():
|
||||
if not key.startswith("group__"):
|
||||
continue
|
||||
connection_id, group_id = key.removeprefix("group__"), str(value).strip()
|
||||
if group_id and data_groups.may_use(db, user, group_id):
|
||||
chosen[connection_id] = group_id
|
||||
user.settings_json = {**(user.settings_json or {}), data_groups.SETTING_KEY: chosen}
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=Data+groups+updated.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/data-groups/new")
|
||||
async def create_personal_group(
|
||||
db: Db, user: RequiredUser, name: str = Form("")
|
||||
) -> Response:
|
||||
from lembas.db.models import DataGroup
|
||||
|
||||
refused = _refuse_without_manage(db, user)
|
||||
if refused is not None:
|
||||
return refused
|
||||
name = " ".join(name.split())[:120]
|
||||
if not name:
|
||||
return RedirectResponse("/settings?error=A+data+group+needs+a+name.", status_code=303)
|
||||
db.add(DataGroup(name=name, owner_id=user.id))
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=Data+group+created.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/data-groups/{group_id}/delete")
|
||||
async def delete_personal_group(db: Db, user: RequiredUser, group_id: str) -> Response:
|
||||
"""Remove one of this person's own groups -- only once nothing is in it."""
|
||||
from urllib.parse import quote
|
||||
|
||||
from lembas.services import data_groups
|
||||
|
||||
refused = _refuse_without_manage(db, user)
|
||||
if refused is not None:
|
||||
return refused
|
||||
group = data_groups.get(db, group_id)
|
||||
if group is None or group.owner_id != user.id:
|
||||
return RedirectResponse("/settings?error=No+such+data+group.", status_code=303)
|
||||
try:
|
||||
data_groups.delete(db, group)
|
||||
except ValueError as exc:
|
||||
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
|
||||
return RedirectResponse("/settings?saved=Data+group+deleted.", status_code=303)
|
||||
|
||||
@@ -237,7 +237,7 @@ async def describe_schedule(request: Request, db: Db, user: RequiredUser):
|
||||
described = str(form.get("request") or "").strip()
|
||||
|
||||
template = prompts_service.resolve(db, "task.schedule_compile")
|
||||
resolved = compile_service.endpoint_for(db, user)
|
||||
resolved = compile_service.endpoint_for(db, user, str(form.get("model_id") or "").strip())
|
||||
if resolved is None:
|
||||
compiled = compile_service.Compiled(
|
||||
instruction=described,
|
||||
|
||||
@@ -36,6 +36,7 @@ from lembas.db.models.chat import (
|
||||
Message,
|
||||
)
|
||||
from lembas.db.models.connection import Connection, Model, model_groups
|
||||
from lembas.db.models.data_group import DEFAULT_GROUP, DataGroup, InDataGroup
|
||||
from lembas.db.models.image import ImageWorkflow
|
||||
from lembas.db.models.library import (
|
||||
AUTHOR_MODEL,
|
||||
@@ -167,6 +168,9 @@ __all__ = [
|
||||
"CrowdMember",
|
||||
"Job",
|
||||
"Connection",
|
||||
"DEFAULT_GROUP",
|
||||
"DataGroup",
|
||||
"InDataGroup",
|
||||
"CustomTool",
|
||||
"CHUNK_DOCUMENT",
|
||||
"CHUNK_KINDS",
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.data_group import InDataGroup
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -173,7 +174,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
return f"<Folder {self.name}>"
|
||||
|
||||
|
||||
class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Chat(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
__tablename__ = "chats"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.data_group import InDataGroup
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -36,7 +37,7 @@ model_groups = Table(
|
||||
)
|
||||
|
||||
|
||||
class Connection(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Connection(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""A configured upstream endpoint speaking the OpenAI HTTP API.
|
||||
|
||||
Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's
|
||||
|
||||
@@ -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)
|
||||
@@ -38,6 +38,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.data_group import InDataGroup
|
||||
|
||||
# Who wrote a record. Not decoration: a skill the model wrote itself is the one
|
||||
# worth looking at twice when its behaviour changes unexpectedly.
|
||||
@@ -81,7 +82,7 @@ chat_knowledge_bases = Table(
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeBase(UUIDPrimaryKey, Timestamps, Base):
|
||||
class KnowledgeBase(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""A named collection of documents.
|
||||
|
||||
Sharing lives here rather than on the individual document: "this folder is
|
||||
@@ -167,7 +168,7 @@ class Document(UUIDPrimaryKey, Timestamps, Base):
|
||||
return f"<Document {self.title!r}>"
|
||||
|
||||
|
||||
class Note(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Note(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""Something the model wrote down, or a person did.
|
||||
|
||||
Longer and more specific than a memory. Not injected: a handful of notes
|
||||
@@ -188,7 +189,7 @@ class Note(UUIDPrimaryKey, Timestamps, Base):
|
||||
return f"<Note {self.title!r}>"
|
||||
|
||||
|
||||
class Memory(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Memory(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""One short fact, in front of the model on every turn.
|
||||
|
||||
Deliberately not shareable and deliberately small. The length cap is
|
||||
@@ -208,7 +209,7 @@ class Memory(UUIDPrimaryKey, Timestamps, Base):
|
||||
return f"<Memory {self.content[:40]!r}>"
|
||||
|
||||
|
||||
class Skill(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Skill(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""A named set of instructions the model can choose to follow.
|
||||
|
||||
`description` is the load-bearing field: it is what gets injected, and it is
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.data_group import InDataGroup
|
||||
|
||||
# Where a report came from. Not a foreign key to anything -- see `source_id`.
|
||||
SOURCE_SCHEDULE = "schedule"
|
||||
@@ -14,7 +15,7 @@ SOURCE_MANUAL = "manual"
|
||||
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
|
||||
|
||||
|
||||
class Report(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Report(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""A finished piece of work, filed.
|
||||
|
||||
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.models.data_group import InDataGroup
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
|
||||
@@ -26,7 +27,7 @@ ORIGIN_MODEL = "model"
|
||||
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
|
||||
|
||||
|
||||
class Schedule(UUIDPrimaryKey, Timestamps, Base):
|
||||
class Schedule(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
||||
"""One standing instruction and when it comes due.
|
||||
|
||||
The row carries no recurrence logic at all: `rule_json` is read by
|
||||
|
||||
@@ -17,6 +17,8 @@ from lembas.api import (
|
||||
admin_agents,
|
||||
admin_audio,
|
||||
admin_branding,
|
||||
admin_crowd,
|
||||
admin_data_groups,
|
||||
admin_extraction,
|
||||
admin_images,
|
||||
admin_models,
|
||||
@@ -37,6 +39,7 @@ from lembas.api import (
|
||||
folders,
|
||||
library,
|
||||
messages,
|
||||
models,
|
||||
pages,
|
||||
preferences,
|
||||
push,
|
||||
@@ -49,6 +52,7 @@ from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
from lembas.config import settings
|
||||
from lembas.db.session import init_db
|
||||
from lembas.services.library import indexing
|
||||
from lembas.web import i18n
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
log = logging.getLogger("lembas")
|
||||
@@ -81,6 +85,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services.chat import sweep_temporary
|
||||
from lembas.services.data_groups import sweep_unassigned
|
||||
from lembas.services.files import sweep_orphans
|
||||
from lembas.services.library.documents import sweep_unfiled
|
||||
from lembas.services.library.indexing import sweep_orphans as sweep_chunks
|
||||
@@ -91,6 +96,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# Documents that predate knowledge bases have nowhere to live until
|
||||
# this runs; see services/library/documents.py.
|
||||
sweep_unfiled(db)
|
||||
# Rows written before data groups existed, into the group they were
|
||||
# read in -- see services/data_groups.py.
|
||||
sweep_unassigned(db)
|
||||
# Temporary chats older than a day. Startup only, like the sweeps
|
||||
# above it -- see services/chat.py:sweep_temporary.
|
||||
sweep_temporary(db)
|
||||
@@ -177,6 +185,15 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# The language every request starts in. A `ContextVar` is per task, and a task
|
||||
# is reused between requests -- so without resetting it here, a signed-out page
|
||||
# would inherit whichever person was served last on that worker. The user's own
|
||||
# choice is applied later, by `get_current_user`, where they are already known.
|
||||
@app.middleware("http")
|
||||
async def _language(request, call_next):
|
||||
i18n.activate(i18n.instance_default())
|
||||
return await call_next(request)
|
||||
|
||||
# One place that notices a library record changing, rather than a call in
|
||||
# each of the ten writers that touch those tables. Idempotent, because the
|
||||
# factory is called per test. See services/library/indexing.py:install.
|
||||
@@ -193,6 +210,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(folders.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(models.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
@@ -211,6 +229,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
app.include_router(admin_agents.router)
|
||||
app.include_router(admin_crowd.router)
|
||||
app.include_router(admin_data_groups.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(branding.router)
|
||||
|
||||
|
||||
@@ -328,6 +328,20 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
# Data groups keep one provider's models away from the data another's have
|
||||
# been handed. The administrator's arrangement applies to everybody; this
|
||||
# lets a person make groups of their own, put a connection into one for
|
||||
# themselves, and move their own records between groups. Off by default,
|
||||
# because it moves what a provider can read -- and an instance that never
|
||||
# looks should keep the arrangement its administrator made.
|
||||
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,
|
||||
|
||||
@@ -71,7 +71,11 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
|
||||
"chat_empty": (
|
||||
"Empty chat",
|
||||
"Above the composer on a chat with nothing in it yet.",
|
||||
"Speak, friend, and enter.",
|
||||
# No commas, on purpose. It is the riddle on the Doors of Durin, and
|
||||
# its answer is to *say* "friend" -- the password is the word itself.
|
||||
# With commas it is an invitation to a friend, which is the misreading
|
||||
# that kept the Fellowship outside the door.
|
||||
"Speak friend and enter.",
|
||||
),
|
||||
"offline_title": (
|
||||
"Offline heading",
|
||||
@@ -87,7 +91,7 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
|
||||
"error_403": (
|
||||
"403 — not yours",
|
||||
"Shown on a page somebody is not allowed to see.",
|
||||
"Speak, friend, and enter. This door is not yours to open.",
|
||||
"Speak friend and enter. This door is not yours to open.",
|
||||
),
|
||||
"error_404": (
|
||||
"404 — not found",
|
||||
|
||||
@@ -20,6 +20,7 @@ from lembas.db.models import (
|
||||
Connection,
|
||||
Message,
|
||||
Model,
|
||||
User,
|
||||
)
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
@@ -115,8 +116,14 @@ def resolve_endpoint(
|
||||
|
||||
if connection is None or not connection.enabled:
|
||||
# The original connection is gone or disabled. Any enabled connection
|
||||
# still offering this model id will do.
|
||||
model = db.scalar(
|
||||
# still offering this model id will do -- **in the chat's own data
|
||||
# group**, for the chat's own model. Any connection at all would repoint
|
||||
# the conversation onto whichever provider happened to serve the same
|
||||
# id, and hand it the whole history on the way.
|
||||
from lembas.services import data_groups
|
||||
|
||||
candidates = list(
|
||||
db.scalars(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(
|
||||
@@ -126,6 +133,17 @@ def resolve_endpoint(
|
||||
)
|
||||
.order_by(Connection.position)
|
||||
)
|
||||
)
|
||||
if speaks_for_chat and candidates:
|
||||
owner = db.get(User, chat.user_id) if chat.user_id else None
|
||||
groups = data_groups.connection_groups(db, owner)
|
||||
wanted = data_groups.for_chat(db, chat)
|
||||
candidates = [
|
||||
m
|
||||
for m in candidates
|
||||
if groups.get(m.connection_id, data_groups.DEFAULT_GROUP) == wanted
|
||||
]
|
||||
model = candidates[0] if candidates else None
|
||||
if model is None:
|
||||
raise LLMError(
|
||||
f"No enabled connection currently offers the model "
|
||||
@@ -588,7 +606,10 @@ def build_request(
|
||||
if crowd_turn is None and upto is not None:
|
||||
from lembas.services import crowd as crowd_service
|
||||
|
||||
crowd_turn = crowd_service.state_of(upto)
|
||||
# `scheduling_state`: the opening reply carries a stamp for the chip's
|
||||
# sake, and regenerating it must still build an ordinary first answer --
|
||||
# not one told that "the answers above are quoted, yours comes next".
|
||||
crowd_turn = crowd_service.scheduling_state(upto)
|
||||
# Images are only sent to a model an administrator has marked as having
|
||||
# vision. Sending them to one that has not is not a graceful degradation:
|
||||
# most endpoints reject the whole request.
|
||||
@@ -838,16 +859,28 @@ def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
||||
return chosen.model_id, chosen.connection_id
|
||||
|
||||
|
||||
def available_models(db: DBSession, user=None) -> list[Model]:
|
||||
def available_models(db: DBSession, user=None, group: str | None = None) -> list[Model]:
|
||||
"""Models this user may start a chat with, in the administrator's order.
|
||||
|
||||
Pinning does NOT hoist a model up this list: pinned models get their own
|
||||
shortcuts in the sidebar, and a picker whose order silently differs from
|
||||
the one configured in the admin screen is just confusing.
|
||||
|
||||
`group` narrows to the models whose connection is in one data group for this
|
||||
person -- what a chat that already exists may switch to, and who may be
|
||||
asked or added to it. `None` is the new-chat screen, where any model can
|
||||
start a chat and the chat then takes that model's group.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
reachable = permissions.models_visible_to(db, user)
|
||||
if group is not None:
|
||||
from lembas.services import data_groups
|
||||
|
||||
groups = data_groups.connection_groups(db, user)
|
||||
reachable = [
|
||||
m for m in reachable if groups.get(m.connection_id, data_groups.DEFAULT_GROUP) == group
|
||||
]
|
||||
return sorted(reachable, key=lambda m: (m.position, m.model_id))
|
||||
|
||||
|
||||
@@ -862,7 +895,9 @@ MAX_ROSTER_CHARS = 2400
|
||||
MAX_ROSTER_ENTRY = 300
|
||||
|
||||
|
||||
def roster_models(db: DBSession, user=None, *, exclude: str = "") -> list[Model]:
|
||||
def roster_models(
|
||||
db: DBSession, user=None, *, exclude: str = "", group: str | None = None
|
||||
) -> list[Model]:
|
||||
"""The other models this person could reach, in the administrator's order.
|
||||
|
||||
`exclude` is a `model_id` and is normally the chat's own: a model does not
|
||||
@@ -871,10 +906,12 @@ def roster_models(db: DBSession, user=None, *, exclude: str = "") -> list[Model]
|
||||
would be both a leak and a dead end, since asking it anything is refused by
|
||||
the same check.
|
||||
"""
|
||||
return [model for model in available_models(db, user) if model.model_id != exclude]
|
||||
return [model for model in available_models(db, user, group) if model.model_id != exclude]
|
||||
|
||||
|
||||
def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
|
||||
def roster_block(
|
||||
db: DBSession, user=None, *, exclude: str = "", group: str | None = None
|
||||
) -> str:
|
||||
"""The roster as the models read it: one line each, name, id, what it is for.
|
||||
|
||||
The id is in brackets because it is what has to be typed back into
|
||||
@@ -885,7 +922,7 @@ def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
|
||||
"""
|
||||
lines: list[str] = []
|
||||
budget = MAX_ROSTER_CHARS
|
||||
for model in roster_models(db, user, exclude=exclude)[:MAX_ROSTER_MODELS]:
|
||||
for model in roster_models(db, user, exclude=exclude, group=group)[:MAX_ROSTER_MODELS]:
|
||||
parts = ((model.description or "").strip(), (model.notes or "").strip())
|
||||
about = " ".join(part for part in parts if part)
|
||||
about = " ".join(about.split())[:MAX_ROSTER_ENTRY]
|
||||
|
||||
@@ -134,6 +134,41 @@ def state_of(message: Message | None) -> Turn | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_opening(state: Turn | None) -> bool:
|
||||
"""Whether this state is the main model's opening reply.
|
||||
|
||||
`phase=out, index=0` is **display state and never scheduling state**. The
|
||||
opening reply is not started by the crowd -- the composer starts it, exactly
|
||||
as it starts every other reply, and a round only begins when it *finishes*.
|
||||
Stamping it afterwards is what lets the transcript say `1 of 3` on the bubble
|
||||
that opened the round; before that it was the one contribution with no chip,
|
||||
so a two-model round read as an ordinary reply followed by a crowd.
|
||||
|
||||
Everything that asks "is a round already in progress?" has to skip it, or the
|
||||
stamp changes behaviour it was never meant to touch -- see `scheduling_state`.
|
||||
"""
|
||||
return state is not None and state.phase == PHASE_OUT and state.index == 0
|
||||
|
||||
|
||||
def scheduling_state(message: Message | None) -> Turn | None:
|
||||
"""The round state the scheduler should act on: `state_of`, minus the opening.
|
||||
|
||||
Two things would break if the opening stamp were fed to `next_turn` as real
|
||||
state, and both are silent:
|
||||
|
||||
* **`started_at` would be inherited on a regenerate.** Regenerating the
|
||||
opening reply an hour later would hand `next_turn` an hour-old clock and the
|
||||
round would stop with "out of time" before anybody spoke.
|
||||
* **The once-per-turn gates key off "no state at all"** -- compaction, the
|
||||
title, the unread push. A stamped opening reads as a later speaker, and each
|
||||
of them would be skipped for the turn that is supposed to have them.
|
||||
|
||||
So the stamp is written where the transcript reads it and nowhere else.
|
||||
"""
|
||||
state = state_of(message)
|
||||
return None if is_opening(state) else state
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
@@ -237,11 +272,18 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
|
||||
|
||||
Deduplicated against the main model: adding the chat's own model to the crowd
|
||||
would have it answer twice in a row, which is not what anybody meant by it.
|
||||
|
||||
And narrowed to the chat's data group: a member from another group would be
|
||||
handed this conversation, which is exactly what groups exist to prevent.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
|
||||
reachable = {
|
||||
model.model_id: model for model in chat_service.roster_models(db, user, exclude="")
|
||||
model.model_id: model
|
||||
for model in chat_service.roster_models(
|
||||
db, user, exclude="", group=data_groups.for_chat(db, chat)
|
||||
)
|
||||
}
|
||||
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
|
||||
seen = {chat.model_id}
|
||||
@@ -256,9 +298,13 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
|
||||
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
|
||||
"""Members that will be skipped, so a screen can say so rather than lie."""
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
|
||||
reachable = {
|
||||
model.model_id for model in chat_service.roster_models(db, user, exclude="")
|
||||
model.model_id
|
||||
for model in chat_service.roster_models(
|
||||
db, user, exclude="", group=data_groups.for_chat(db, chat)
|
||||
)
|
||||
}
|
||||
return [
|
||||
member.model_id
|
||||
@@ -374,9 +420,11 @@ __all__ = [
|
||||
"Turn",
|
||||
"elapsed",
|
||||
"is_newest",
|
||||
"is_opening",
|
||||
"member_speakers",
|
||||
"next_turn",
|
||||
"now_stamp",
|
||||
"scheduling_state",
|
||||
"state_of",
|
||||
"tool_defs",
|
||||
"unreachable_members",
|
||||
|
||||
@@ -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 compaction as compaction_service
|
||||
from lembas.services import crowd as crowd_service
|
||||
from lembas.services import interaction, settings_store, tokens, tool_labels
|
||||
from lembas.services import data_groups, interaction, settings_store, tokens, tool_labels
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import push as push_service
|
||||
@@ -640,8 +640,15 @@ async def _run(generation: Generation) -> None:
|
||||
# on has to survive that. It is also the only thing that can make the
|
||||
# bubble's avatar and the model actually asked agree.
|
||||
speaker = chat_service.speaker_for(db, chat, message)
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker)
|
||||
owner = db.get(User, chat.user_id)
|
||||
# Before the endpoint is even resolved: a chat whose model has since
|
||||
# been moved into another data group must not be sent to it, or that
|
||||
# provider is handed the whole history the group was keeping from it.
|
||||
moved = data_groups.refusal(db, owner, chat, speaker)
|
||||
if moved:
|
||||
generation.error = moved
|
||||
return
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker)
|
||||
|
||||
# Before the request is built, not while it streams. Every other
|
||||
# budget here can only be noticed part way through and so ends with
|
||||
@@ -661,7 +668,10 @@ async def _run(generation: Generation) -> None:
|
||||
# once, here, and used for three decisions: which tools it may have,
|
||||
# which instruction closes its request, and whether it may ask for
|
||||
# another round.
|
||||
crowd_state = crowd_service.state_of(message)
|
||||
# `scheduling_state` for the reason `build_request` gives: the
|
||||
# opening reply's stamp is for the transcript, and regenerating it
|
||||
# must not hand it a member's tools or a member's instruction.
|
||||
crowd_state = crowd_service.scheduling_state(message)
|
||||
crowd_settings = settings_store.crowd(db)
|
||||
may_ask_again = bool(
|
||||
crowd_state is not None
|
||||
@@ -2232,7 +2242,11 @@ def _advance_crowd(generation: Generation) -> bool:
|
||||
speakers = crowd_service.member_speakers(db, chat, owner_user)
|
||||
speakers = speakers[: int(settings["max_models"]) + 1]
|
||||
|
||||
state = crowd_service.state_of(message)
|
||||
# `scheduling_state` and not `state_of`: the opening reply carries a
|
||||
# stamp for the transcript's sake (so it can say `1 of 3`), and that
|
||||
# stamp must not read as "a round is already running" -- it would
|
||||
# inherit the old clock on a regenerate. See `crowd.is_opening`.
|
||||
state = crowd_service.scheduling_state(message)
|
||||
# The turn a round belongs to: the user message this all answers.
|
||||
turn_id = state.turn if state is not None else _turn_anchor(db, message)
|
||||
following = crowd_service.next_turn(
|
||||
@@ -2254,6 +2268,22 @@ def _advance_crowd(generation: Generation) -> bool:
|
||||
db.commit()
|
||||
return False
|
||||
|
||||
if state is None:
|
||||
# The round begins here, so stamp the reply that opened it. It is
|
||||
# the only contribution that is not started by the crowd, and
|
||||
# before this it was the only one with no chip -- which made a
|
||||
# two-model round read as an ordinary reply followed by a crowd,
|
||||
# and left the reader counting "2 of 2" with no 1 in sight. Same
|
||||
# turn and same `started_at`, so the bubbles group.
|
||||
message.crowd_json = crowd_service.Turn(
|
||||
turn=following.turn,
|
||||
round=following.round,
|
||||
phase=crowd_service.PHASE_OUT,
|
||||
index=0,
|
||||
of=following.of,
|
||||
started_at=following.started_at,
|
||||
).as_json()
|
||||
|
||||
speaker = speakers[following.index]
|
||||
placeholder = chat_service.create_message(
|
||||
db,
|
||||
@@ -2281,10 +2311,14 @@ def _opens_the_turn(message: Message) -> bool:
|
||||
"""Whether this reply is the first one answering a question.
|
||||
|
||||
True for every ordinary reply, and for a crowd only for the main model's
|
||||
opening turn -- which is the one with no crowd state on it at all, because a
|
||||
round begins when that reply *finishes*.
|
||||
opening turn. That reply has no crowd state while it is being written -- a
|
||||
round begins when it *finishes* -- and once the round has begun it carries the
|
||||
opening stamp, which `is_opening` reads as "still the one that opens the
|
||||
turn". Both are the same answer to this question, and missing the second means
|
||||
a reply that has already been compacted-for and titled gets it again on the
|
||||
next look.
|
||||
"""
|
||||
return crowd_service.state_of(message) is None
|
||||
return crowd_service.scheduling_state(message) is None
|
||||
|
||||
|
||||
def _opens_the_turn_id(generation: Generation) -> bool:
|
||||
|
||||
@@ -186,6 +186,20 @@ def context_variables(
|
||||
# set one behaves exactly as it always did.
|
||||
stamp = clock.now_for(user)
|
||||
|
||||
# Whose data this request may carry. The data group of the *answering*
|
||||
# model -- the chat's own for the chat's model, the member's own for a crowd
|
||||
# member -- so a model is handed exactly one group's memories, skills and
|
||||
# personality and never the chat's merely for being in it. No chat is the
|
||||
# admin preview, which has no speaker and shows every group.
|
||||
group: str | None = None
|
||||
if chat is not None:
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
|
||||
group = data_groups.for_speaker(
|
||||
db, user, chat, speaker or chat_service.speaker_for(db, chat)
|
||||
)
|
||||
|
||||
values: dict[str, str] = {
|
||||
"today": stamp.strftime("%A %-d %B %Y"),
|
||||
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
|
||||
@@ -227,9 +241,11 @@ def context_variables(
|
||||
"unbounded": "" if settings_store.chat_rounds(db) else "yes",
|
||||
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
|
||||
"tool_names": _tool_names(offered),
|
||||
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||
"memories": memories_service.block(db, user, group) if "memory" in families else "",
|
||||
"skills": (
|
||||
skills_service.index_block(db, user, exclude=tools_service.scoped_skills_off(chat))
|
||||
skills_service.index_block(
|
||||
db, user, exclude=tools_service.scoped_skills_off(chat), group=group
|
||||
)
|
||||
if "skills" in families
|
||||
else ""
|
||||
),
|
||||
@@ -304,8 +320,15 @@ def context_variables(
|
||||
# Naming the bases a chat is scoped to matters: without it the model
|
||||
# cannot tell "there is nothing about this" from "I am only allowed to
|
||||
# see the contracts folder", and phrases a miss as the former.
|
||||
if "knowledge" in families and chat.knowledge_bases:
|
||||
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
|
||||
# Only the attached bases in this speaker's group: a base from another
|
||||
# group is not searchable here, and naming it would leak its name.
|
||||
in_group = [
|
||||
base
|
||||
for base in chat.knowledge_bases
|
||||
if (base.data_group_id or data_groups.DEFAULT_GROUP) == group
|
||||
]
|
||||
if "knowledge" in families and in_group:
|
||||
values["knowledge_bases"] = ", ".join(base.name for base in in_group)
|
||||
values["document_names"] = _document_names(db, chat)
|
||||
|
||||
# The one thing a tool description cannot carry, because a description
|
||||
@@ -338,7 +361,7 @@ def context_variables(
|
||||
# reason the roster and the tool are one checkbox rather than two.
|
||||
if "friend" in families:
|
||||
values["model_roster"] = chat_service.roster_block(
|
||||
db, user, exclude=speaking.model_id
|
||||
db, user, exclude=speaking.model_id, group=group
|
||||
)
|
||||
|
||||
if "persona" in families:
|
||||
@@ -346,7 +369,7 @@ def context_variables(
|
||||
# administrator's default until the model has written one with them;
|
||||
# and this model's impression of them, which has no default and never
|
||||
# could.
|
||||
key = speaking.model_id
|
||||
key = personas_service.key_for(speaking.model_id, group)
|
||||
values["persona"] = personas_service.block(db, key, user)
|
||||
values["person_view"] = personas_service.view_block(db, key, user)
|
||||
|
||||
|
||||
@@ -330,7 +330,23 @@ def _reviewer(context: ToolContext) -> tuple[Endpoint, str] | None:
|
||||
try:
|
||||
with session_scope() as db:
|
||||
model = None
|
||||
if wanted:
|
||||
# The chat's data group may name its own reviewer, because the
|
||||
# reviewer is sent the picture and the prompt that described it --
|
||||
# this group's data, going to whichever provider reviews. A group
|
||||
# that names none uses the instance's choice below.
|
||||
from lembas.services import data_groups
|
||||
|
||||
group = data_groups.get(db, context.data_group)
|
||||
if group is not None and group.review_model_id:
|
||||
model = db.scalar(
|
||||
select(Model)
|
||||
.where(Model.model_id == group.review_model_id)
|
||||
.order_by(
|
||||
Model.connection_id != (group.review_connection_id or ""),
|
||||
Model.position,
|
||||
)
|
||||
)
|
||||
if model is None and wanted:
|
||||
# By the model's own id, and by primary key for anything stored
|
||||
# before that was the rule -- a value written by an older release
|
||||
# is a primary key and must keep working.
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session as DBSession
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import (
|
||||
CHUNK_DOCUMENT,
|
||||
DEFAULT_GROUP,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
Document,
|
||||
@@ -69,19 +70,27 @@ def stored_path(stored_name: str) -> Path | None:
|
||||
|
||||
|
||||
# --- Bases -------------------------------------------------------------------
|
||||
def visible_bases(db: DBSession, user: User | None):
|
||||
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
|
||||
def visible_bases(db: DBSession, user: User | None, group: str | None = None):
|
||||
"""Bases this user may see; `group` narrows to one data group, for a model."""
|
||||
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user, group))
|
||||
|
||||
|
||||
def get_base(db: DBSession, base_id: str, user: User | None) -> KnowledgeBase | None:
|
||||
def get_base(
|
||||
db: DBSession, base_id: str, user: User | None, group: str | None = None
|
||||
) -> KnowledgeBase | None:
|
||||
base = db.get(KnowledgeBase, base_id)
|
||||
if base is None or not sharing.can_read(db, base, user):
|
||||
if base is None or not sharing.can_read(db, base, user, group):
|
||||
return None
|
||||
return base
|
||||
|
||||
|
||||
def create_base(
|
||||
db: DBSession, *, owner: User, name: str, description: str = ""
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
name: str,
|
||||
description: str = "",
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> KnowledgeBase:
|
||||
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
|
||||
existing = db.scalar(
|
||||
@@ -90,28 +99,58 @@ def create_base(
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
# Unique per person across every data group: the constraint is
|
||||
# `(owner_id, name)` and cannot be changed without rebuilding the table.
|
||||
if (existing.data_group_id or DEFAULT_GROUP) != (group or DEFAULT_GROUP):
|
||||
raise ValueError(
|
||||
f"You already have a knowledge base called {name!r} in another "
|
||||
f"data group. Names are unique across your groups."
|
||||
)
|
||||
raise ValueError(f"You already have a knowledge base called {name!r}.")
|
||||
|
||||
base = KnowledgeBase(owner_id=owner.id, name=name, description=description.strip()[:2000])
|
||||
base = KnowledgeBase(
|
||||
owner_id=owner.id,
|
||||
data_group_id=group or DEFAULT_GROUP,
|
||||
name=name,
|
||||
description=description.strip()[:2000],
|
||||
)
|
||||
db.add(base)
|
||||
db.commit()
|
||||
return base
|
||||
|
||||
|
||||
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
|
||||
"""The base a document goes into when none was chosen.
|
||||
def default_base(db: DBSession, owner: User, group: str = DEFAULT_GROUP) -> KnowledgeBase:
|
||||
"""The base a document goes into when none was chosen, in one data group.
|
||||
|
||||
Made on demand rather than at registration, so an account that never uses
|
||||
the library never grows an empty one.
|
||||
the library never grows an empty one. Outside the default group it is named
|
||||
after the group, because a base name is unique per person across every group
|
||||
and two called "My documents" cannot both exist.
|
||||
"""
|
||||
from lembas.services import data_groups
|
||||
|
||||
group = group or DEFAULT_GROUP
|
||||
base = db.scalar(
|
||||
select(KnowledgeBase)
|
||||
.where(KnowledgeBase.owner_id == owner.id)
|
||||
.where(
|
||||
KnowledgeBase.owner_id == owner.id,
|
||||
data_groups.condition(KnowledgeBase, group),
|
||||
)
|
||||
.order_by(KnowledgeBase.created_at)
|
||||
)
|
||||
if base is not None:
|
||||
return base
|
||||
base = KnowledgeBase(owner_id=owner.id, name=DEFAULT_BASE_NAME)
|
||||
name = DEFAULT_BASE_NAME
|
||||
if group != DEFAULT_GROUP:
|
||||
name = f"{DEFAULT_BASE_NAME} — {data_groups.name_of(db, group)}"[:190]
|
||||
taken = set(
|
||||
db.scalars(select(KnowledgeBase.name).where(KnowledgeBase.owner_id == owner.id))
|
||||
)
|
||||
wanted, counter = name, 2
|
||||
while wanted in taken:
|
||||
wanted = f"{name} ({counter})"
|
||||
counter += 1
|
||||
base = KnowledgeBase(owner_id=owner.id, name=wanted, data_group_id=group)
|
||||
db.add(base)
|
||||
db.commit()
|
||||
return base
|
||||
@@ -172,10 +211,15 @@ def store_upload(
|
||||
filename: str,
|
||||
title: str = "",
|
||||
base: KnowledgeBase | None = None,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Document:
|
||||
"""Add an uploaded file to the library. Raises files.FileError if unusable."""
|
||||
"""Add an uploaded file to the library. Raises files.FileError if unusable.
|
||||
|
||||
A document is in the data group of its base. `group` only chooses which
|
||||
default base it lands in when no base was given.
|
||||
"""
|
||||
prepared = files_service.prepare(payload, filename)
|
||||
base = base or default_base(db, owner)
|
||||
base = base or default_base(db, owner, group)
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
|
||||
(library_dir() / stored_name).write_bytes(prepared.payload)
|
||||
@@ -205,14 +249,19 @@ def store_upload(
|
||||
|
||||
|
||||
def store_page(
|
||||
db: DBSession, *, owner: User, page: Fetched, base: KnowledgeBase | None = None
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
page: Fetched,
|
||||
base: KnowledgeBase | None = None,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Document:
|
||||
"""Add a fetched web page to the library.
|
||||
|
||||
Saved as text rather than as the original HTML: the point of keeping it is
|
||||
what it said, and the markup would have to be reduced again on every read.
|
||||
"""
|
||||
base = base or default_base(db, owner)
|
||||
base = base or default_base(db, owner, group)
|
||||
document = Document(
|
||||
owner_id=owner.id,
|
||||
base_id=base.id,
|
||||
@@ -233,15 +282,22 @@ def store_page(
|
||||
|
||||
|
||||
# --- Reading -----------------------------------------------------------------
|
||||
def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = None):
|
||||
def visible(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
*,
|
||||
base_ids: list[str] | None = None,
|
||||
group: str | None = None,
|
||||
):
|
||||
"""Documents this user may see, optionally narrowed to some bases.
|
||||
|
||||
Visibility comes from the base, not the document: a document is readable by
|
||||
whoever can read the base it lives in. That is the whole reason bases are
|
||||
shareable and documents are not.
|
||||
shareable and documents are not -- and it is also why a document has no
|
||||
data group of its own: it is in its base's.
|
||||
"""
|
||||
condition = Document.base_id.in_(
|
||||
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user))
|
||||
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user, group))
|
||||
)
|
||||
query = select(Document).where(condition)
|
||||
if base_ids:
|
||||
@@ -251,12 +307,14 @@ def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = No
|
||||
return query
|
||||
|
||||
|
||||
def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
|
||||
def get(
|
||||
db: DBSession, document_id: str, user: User | None, group: str | None = None
|
||||
) -> Document | None:
|
||||
document = db.get(Document, document_id)
|
||||
if document is None:
|
||||
return None
|
||||
base = db.get(KnowledgeBase, document.base_id) if document.base_id else None
|
||||
if base is None or not sharing.can_read(db, base, user):
|
||||
if base is None or not sharing.can_read(db, base, user, group):
|
||||
return None
|
||||
return document
|
||||
|
||||
@@ -310,6 +368,7 @@ def search(
|
||||
limit: int = 10,
|
||||
base_ids: list[str] | None = None,
|
||||
vector: list[float] | None = None,
|
||||
group: str | None = None,
|
||||
) -> list[Document]:
|
||||
"""Documents matching `needle` that this user may see, best match first.
|
||||
|
||||
@@ -331,7 +390,9 @@ def search(
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(
|
||||
db.scalars(
|
||||
visible(db, user, base_ids=base_ids).where(Document.id.in_(list(order)))
|
||||
visible(db, user, base_ids=base_ids, group=group).where(
|
||||
Document.id.in_(list(order))
|
||||
)
|
||||
)
|
||||
)
|
||||
rows.sort(key=lambda document: order.get(document.id, len(order)))
|
||||
|
||||
@@ -55,6 +55,7 @@ from lembas.db.models import (
|
||||
Chunk,
|
||||
Connection,
|
||||
Document,
|
||||
KnowledgeBase,
|
||||
Model,
|
||||
Note,
|
||||
Report,
|
||||
@@ -92,19 +93,40 @@ class Embedder:
|
||||
batch: int = 16
|
||||
|
||||
|
||||
def embedder(db: DBSession) -> Embedder | None:
|
||||
"""The configured embedding model, or None.
|
||||
def embedder(db: DBSession, group: str | None = None) -> Embedder | None:
|
||||
"""The embedding model for one data group, or the instance's, or None.
|
||||
|
||||
None is the answer to every "no" -- none chosen, the model row deleted, its
|
||||
connection disabled -- and every caller reads it the same way: do nothing,
|
||||
and let the keyword search stand. That is deliberately not an error. An
|
||||
instance that never configured this is the common case, not a broken one.
|
||||
|
||||
**A group may name its own.** The embedder is sent the full text of every
|
||||
record it indexes, so a group that keeps its data away from a provider has
|
||||
to be able to keep it away from that provider's embedder too. A group that
|
||||
names none uses the instance's, which is what every group starts with --
|
||||
and the Data groups page says so, per group, beside the providers.
|
||||
|
||||
Mixing is impossible by construction rather than by care: a `Chunk` carries
|
||||
the model that made its vector and `retrieval.semantic_ids` skips any other,
|
||||
so a query embedded by one group's model never meets another's vectors.
|
||||
"""
|
||||
values = settings_store.extraction(db)
|
||||
batch = int(values.get("embed_batch") or 16)
|
||||
if group:
|
||||
from lembas.services import data_groups
|
||||
|
||||
row = data_groups.get(db, group)
|
||||
if row is not None and row.embedding_model_id:
|
||||
return _resolve(db, row.embedding_model_id, row.embedding_connection_id, batch)
|
||||
wanted = str(values.get("embedding_model_id") or "").strip()
|
||||
if not wanted:
|
||||
return None
|
||||
model = db.scalar(
|
||||
return _resolve(db, wanted, "", batch)
|
||||
|
||||
|
||||
def _resolve(db: DBSession, wanted: str, connection_id: str, batch: int) -> Embedder | None:
|
||||
query = (
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(
|
||||
@@ -112,8 +134,9 @@ def embedder(db: DBSession) -> Embedder | None:
|
||||
Model.enabled.is_(True),
|
||||
Connection.enabled.is_(True),
|
||||
)
|
||||
.order_by(Connection.position)
|
||||
.order_by(Connection.id != (connection_id or ""), Connection.position)
|
||||
)
|
||||
model = db.scalar(query)
|
||||
if model is None:
|
||||
log.info("embedding model %r is configured but not available", wanted)
|
||||
return None
|
||||
@@ -123,7 +146,30 @@ def embedder(db: DBSession) -> Embedder | None:
|
||||
return Embedder(
|
||||
endpoint=Endpoint.from_connection(connection),
|
||||
model_id=model.model_id,
|
||||
batch=int(values.get("embed_batch") or 16),
|
||||
batch=batch,
|
||||
)
|
||||
|
||||
|
||||
def group_of_row(db: DBSession, row) -> str:
|
||||
"""The data group a record is in. A document is in its base's."""
|
||||
from lembas.services import data_groups
|
||||
|
||||
if isinstance(row, Document):
|
||||
base = db.get(KnowledgeBase, row.base_id) if row.base_id else None
|
||||
return data_groups.group_of(base) if base is not None else data_groups.DEFAULT_GROUP
|
||||
return data_groups.group_of(row)
|
||||
|
||||
|
||||
def any_configured(db: DBSession) -> bool:
|
||||
"""Whether any group -- or the instance -- has an embedder to index with."""
|
||||
from lembas.services import data_groups
|
||||
|
||||
if embedder(db) is not None:
|
||||
return True
|
||||
return any(
|
||||
embedder(db, group.id) is not None
|
||||
for group in data_groups.all_groups(db)
|
||||
if group.embedding_model_id
|
||||
)
|
||||
|
||||
|
||||
@@ -199,7 +245,7 @@ async def index_resource(kind: str, resource_id: str, *, force: bool = False) ->
|
||||
if row is None:
|
||||
forget_resource(db, kind, resource_id)
|
||||
return 0
|
||||
worker = embedder(db)
|
||||
worker = embedder(db, group_of_row(db, row))
|
||||
if worker is None:
|
||||
return 0
|
||||
body = text_of(row)
|
||||
@@ -438,7 +484,7 @@ async def rebuild_all(*, force: bool = True) -> None:
|
||||
_PROGRESS = Progress(running=True)
|
||||
try:
|
||||
with session_scope() as db:
|
||||
if embedder(db) is None:
|
||||
if not any_configured(db):
|
||||
_PROGRESS.error = "No embedding model is configured."
|
||||
return
|
||||
work: list[tuple[str, str]] = []
|
||||
|
||||
@@ -22,7 +22,7 @@ import logging
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Memory, User
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, DEFAULT_GROUP, Memory, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,14 +39,19 @@ MAX_TOTAL_CHARS = 4000
|
||||
MAX_RECORDS = 200
|
||||
|
||||
|
||||
def all_for(db: DBSession, user: User | None) -> list[Memory]:
|
||||
def all_for(db: DBSession, user: User | None, group: str | None = None) -> list[Memory]:
|
||||
"""This person's memories; `group` narrows to one data group, for a model.
|
||||
|
||||
`None` is the person's own list in their settings, which shows every group.
|
||||
"""
|
||||
if user is None:
|
||||
return []
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Memory).where(Memory.owner_id == user.id).order_by(Memory.created_at)
|
||||
)
|
||||
)
|
||||
query = select(Memory).where(Memory.owner_id == user.id)
|
||||
if group is not None:
|
||||
from lembas.services import data_groups
|
||||
|
||||
query = query.where(data_groups.condition(Memory, group))
|
||||
return list(db.scalars(query.order_by(Memory.created_at)))
|
||||
|
||||
|
||||
def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
|
||||
@@ -56,7 +61,14 @@ def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
|
||||
return memory
|
||||
|
||||
|
||||
def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL) -> Memory:
|
||||
def add(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
content: str,
|
||||
author: str = AUTHOR_MODEL,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Memory:
|
||||
"""Record a fact. Raises ValueError when there is no room or nothing to say.
|
||||
|
||||
An exact repeat returns the record that already exists rather than making a
|
||||
@@ -71,15 +83,24 @@ def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL)
|
||||
if not content:
|
||||
raise ValueError("A memory cannot be empty.")
|
||||
content = content[:MAX_MEMORY_CHARS]
|
||||
group = group or DEFAULT_GROUP
|
||||
|
||||
# Both checks are per data group. A repeat of a fact another group already
|
||||
# holds is a new memory *here* -- returning the other group's row would be
|
||||
# telling this group's model that it had saved something it cannot see.
|
||||
from lembas.services import data_groups
|
||||
|
||||
in_group = data_groups.condition(Memory, group)
|
||||
existing = db.scalars(
|
||||
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content)
|
||||
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content, in_group)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
count = db.scalar(
|
||||
select(func.count()).select_from(Memory).where(Memory.owner_id == owner.id)
|
||||
select(func.count())
|
||||
.select_from(Memory)
|
||||
.where(Memory.owner_id == owner.id, in_group)
|
||||
)
|
||||
if (count or 0) >= MAX_RECORDS:
|
||||
# Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the
|
||||
@@ -95,6 +116,7 @@ def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL)
|
||||
|
||||
memory = Memory(
|
||||
owner_id=owner.id,
|
||||
data_group_id=group,
|
||||
content=content,
|
||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL,
|
||||
)
|
||||
@@ -117,14 +139,14 @@ def delete(db: DBSession, memory: Memory) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def block(db: DBSession, user: User | None) -> str:
|
||||
def block(db: DBSession, user: User | None, group: str | None = None) -> str:
|
||||
"""The memories as they appear in the prompt, within the budget.
|
||||
|
||||
Oldest first, and truncation drops the *newest* -- a fact that has survived
|
||||
a long time is more likely to be a standing preference than something said
|
||||
once this morning.
|
||||
"""
|
||||
records = all_for(db, user)
|
||||
records = all_for(db, user, group)
|
||||
if not records:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, Note, User
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, DEFAULT_GROUP, Note, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
@@ -26,20 +26,25 @@ MAX_BODY_CHARS = 40_000
|
||||
SNIPPET_CHARS = 800
|
||||
|
||||
|
||||
def visible(db: DBSession, user: User | None):
|
||||
return select(Note).where(sharing.visible_to(Note, user))
|
||||
def visible(db: DBSession, user: User | None, group: str | None = None):
|
||||
"""Notes this user may see; `group` narrows to one data group, for a model."""
|
||||
return select(Note).where(sharing.visible_to(Note, user, group))
|
||||
|
||||
|
||||
def get(db: DBSession, note_id: str, user: User | None) -> Note | None:
|
||||
def get(
|
||||
db: DBSession, note_id: str, user: User | None, group: str | None = None
|
||||
) -> Note | None:
|
||||
note = db.get(Note, note_id)
|
||||
if note is None or not sharing.can_read(db, note, user):
|
||||
if note is None or not sharing.can_read(db, note, user, group):
|
||||
return None
|
||||
return note
|
||||
|
||||
|
||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
|
||||
def recent(
|
||||
db: DBSession, user: User | None, *, limit: int = 20, group: str | None = None
|
||||
) -> list[Note]:
|
||||
return list(
|
||||
db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit))
|
||||
db.scalars(visible(db, user, group).order_by(Note.updated_at.desc()).limit(limit))
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +55,7 @@ def search(
|
||||
*,
|
||||
limit: int = 10,
|
||||
vector: list[float] | None = None,
|
||||
group: str | None = None,
|
||||
) -> list[Note]:
|
||||
"""Notes matching `needle` that this user may see, best match first.
|
||||
|
||||
@@ -62,16 +68,23 @@ def search(
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(db, user).where(Note.id.in_(list(order)))))
|
||||
rows = list(db.scalars(visible(db, user, group).where(Note.id.in_(list(order)))))
|
||||
rows.sort(key=lambda note: order.get(note.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
title: str,
|
||||
body: str,
|
||||
author: str = AUTHOR_USER,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Note:
|
||||
note = Note(
|
||||
owner_id=owner.id,
|
||||
data_group_id=group or DEFAULT_GROUP,
|
||||
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
|
||||
body=body.strip()[:MAX_BODY_CHARS],
|
||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
|
||||
|
||||
@@ -67,7 +67,7 @@ def embeddable(db: DBSession) -> bool:
|
||||
return indexing.enabled(db)
|
||||
|
||||
|
||||
def worker_for(db: DBSession):
|
||||
def worker_for(db: DBSession, group: str | None = None):
|
||||
"""The configured embedder, resolved while a session is open.
|
||||
|
||||
Split from the awaiting half deliberately. A caller that must not hold a
|
||||
@@ -78,7 +78,21 @@ def worker_for(db: DBSession):
|
||||
"""
|
||||
from lembas.services.library import indexing
|
||||
|
||||
return indexing.embedder(db)
|
||||
return indexing.embedder(db, group)
|
||||
|
||||
|
||||
class QueryVector(list):
|
||||
"""A query embedding that knows which model made it.
|
||||
|
||||
A plain list everywhere it is used as one. The extra attribute is what lets
|
||||
`semantic_ids` skip chunks another model embedded: two models of the same
|
||||
width produce vectors that score against each other perfectly happily and
|
||||
mean nothing, and since 1.10.0 a data group may have its own embedder, so
|
||||
two such models on one instance is an ordinary arrangement rather than a
|
||||
rebuild left half done.
|
||||
"""
|
||||
|
||||
model_id: str = ""
|
||||
|
||||
|
||||
async def embed_with(worker, needle: str) -> list[float] | None:
|
||||
@@ -100,7 +114,11 @@ async def embed_with(worker, needle: str) -> list[float] | None:
|
||||
except LLMError as exc:
|
||||
log.info("could not embed a query: %s", exc)
|
||||
return None
|
||||
return vectors[0] if vectors else None
|
||||
if not vectors:
|
||||
return None
|
||||
vector = QueryVector(vectors[0])
|
||||
vector.model_id = worker.model_id
|
||||
return vector
|
||||
|
||||
|
||||
async def embed_query(db: DBSession, needle: str) -> list[float] | None:
|
||||
@@ -126,13 +144,22 @@ def semantic_ids(
|
||||
Chunks whose width does not match the query's are skipped. That is a change
|
||||
of embedding model with a rebuild still pending, and scoring across two
|
||||
spaces produces a confident wrong answer rather than a missing one.
|
||||
|
||||
**And chunks another model made are skipped when the query says which model
|
||||
it came from.** Width alone cannot tell two 1024-wide models apart, and with
|
||||
an embedder per data group two of them on one instance is ordinary. A plain
|
||||
list -- a caller that built its own vector -- keeps the width check alone.
|
||||
"""
|
||||
if not vector:
|
||||
return []
|
||||
width = len(vector)
|
||||
rows = db.execute(
|
||||
select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(Chunk.resource_type == kind)
|
||||
).all()
|
||||
query = select(Chunk.resource_id, Chunk.vector, Chunk.dims).where(
|
||||
Chunk.resource_type == kind
|
||||
)
|
||||
made_by = getattr(vector, "model_id", "")
|
||||
if made_by:
|
||||
query = query.where(Chunk.model_id == made_by)
|
||||
rows = db.execute(query).all()
|
||||
|
||||
best: dict[str, float] = {}
|
||||
for resource_id, blob, dims in rows:
|
||||
|
||||
@@ -28,7 +28,15 @@ from collections.abc import Iterable
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_SKILL, Skill, SkillRevision, User
|
||||
from lembas.db.models import (
|
||||
AUTHOR_MODEL,
|
||||
AUTHOR_USER,
|
||||
CHUNK_SKILL,
|
||||
DEFAULT_GROUP,
|
||||
Skill,
|
||||
SkillRevision,
|
||||
User,
|
||||
)
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
@@ -55,18 +63,23 @@ def slugify(name: str) -> str:
|
||||
return cleaned[:60]
|
||||
|
||||
|
||||
def visible(db: DBSession, user: User | None):
|
||||
return select(Skill).where(sharing.visible_to(Skill, user))
|
||||
def visible(db: DBSession, user: User | None, group: str | None = None):
|
||||
"""Skills this user may see; `group` narrows to one data group, for a model."""
|
||||
return select(Skill).where(sharing.visible_to(Skill, user, group))
|
||||
|
||||
|
||||
def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
|
||||
def get(
|
||||
db: DBSession, skill_id: str, user: User | None, group: str | None = None
|
||||
) -> Skill | None:
|
||||
skill = db.get(Skill, skill_id)
|
||||
if skill is None or not sharing.can_read(db, skill, user):
|
||||
if skill is None or not sharing.can_read(db, skill, user, group):
|
||||
return None
|
||||
return skill
|
||||
|
||||
|
||||
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
|
||||
def by_name(
|
||||
db: DBSession, name: str, user: User | None, group: str | None = None
|
||||
) -> Skill | None:
|
||||
"""Look one up the way the model refers to it.
|
||||
|
||||
Scoped to what this person can **see**, which is theirs plus anything
|
||||
@@ -77,7 +90,7 @@ def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
|
||||
"""
|
||||
if user is None:
|
||||
return None
|
||||
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
|
||||
return db.scalar(visible(db, user, group).where(Skill.name == slugify(name)))
|
||||
|
||||
|
||||
def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
|
||||
@@ -101,7 +114,11 @@ def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
|
||||
|
||||
|
||||
def enabled_for(
|
||||
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
*,
|
||||
exclude: Iterable[str] = (),
|
||||
group: str | None = None,
|
||||
) -> list[Skill]:
|
||||
"""Skills that should appear in the index, oldest first for a stable order.
|
||||
|
||||
@@ -112,7 +129,7 @@ def enabled_for(
|
||||
return []
|
||||
hidden = {slugify(name) for name in exclude}
|
||||
rows = db.scalars(
|
||||
visible(db, user)
|
||||
visible(db, user, group)
|
||||
.where(Skill.enabled.is_(True))
|
||||
.order_by(Skill.name)
|
||||
.limit(MAX_INDEX_SKILLS + len(hidden))
|
||||
@@ -120,14 +137,20 @@ def enabled_for(
|
||||
return [skill for skill in rows if skill.name not in hidden][:MAX_INDEX_SKILLS]
|
||||
|
||||
|
||||
def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> int:
|
||||
def count_enabled(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
*,
|
||||
exclude: Iterable[str] = (),
|
||||
group: str | None = None,
|
||||
) -> int:
|
||||
"""How many skills are available here at all.
|
||||
|
||||
Zero is what withdraws `skill_get` and `skill_edit`: reading and improving
|
||||
are meaningless with nothing to read, and a model told to "read one with
|
||||
skill_get" above a list that is not there spends a round finding out.
|
||||
"""
|
||||
return len(enabled_for(db, user, exclude=exclude))
|
||||
return len(enabled_for(db, user, exclude=exclude, group=group))
|
||||
|
||||
|
||||
def search(
|
||||
@@ -137,6 +160,7 @@ def search(
|
||||
*,
|
||||
limit: int = 10,
|
||||
vector: list[float] | None = None,
|
||||
group: str | None = None,
|
||||
) -> list[Skill]:
|
||||
"""Skills matching `needle` that this user may see, best match first.
|
||||
|
||||
@@ -149,7 +173,7 @@ def search(
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(db, user).where(Skill.id.in_(list(order)))))
|
||||
rows = list(db.scalars(visible(db, user, group).where(Skill.id.in_(list(order)))))
|
||||
rows.sort(key=lambda skill: order.get(skill.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
@@ -175,6 +199,7 @@ def create(
|
||||
description: str,
|
||||
body: str,
|
||||
author: str = AUTHOR_USER,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Skill:
|
||||
slug = slugify(name)
|
||||
if not SKILL_NAME_PATTERN.match(slug):
|
||||
@@ -182,7 +207,17 @@ def create(
|
||||
"A skill name must be two or more letters, numbers or hyphens, "
|
||||
"such as 'weekly-report'."
|
||||
)
|
||||
if owned_by_name(db, slug, owner) is not None:
|
||||
taken = owned_by_name(db, slug, owner)
|
||||
if taken is not None:
|
||||
# A name is unique per person across every data group -- the table's
|
||||
# constraint is `(owner_id, name)` and cannot be changed. "Edit it
|
||||
# instead" would send a model in another group to a skill it cannot
|
||||
# see, so that case gets its own sentence.
|
||||
if (taken.data_group_id or DEFAULT_GROUP) != (group or DEFAULT_GROUP):
|
||||
raise SkillError(
|
||||
f"The name {slug!r} is already used by a skill in another data "
|
||||
f"group. Choose a different name."
|
||||
)
|
||||
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
|
||||
if not description.strip():
|
||||
raise SkillError(
|
||||
@@ -192,6 +227,7 @@ def create(
|
||||
|
||||
skill = Skill(
|
||||
owner_id=owner.id,
|
||||
data_group_id=group or DEFAULT_GROUP,
|
||||
name=slug,
|
||||
description=description.strip()[:MAX_DESCRIPTION_CHARS],
|
||||
body=body.strip()[:MAX_BODY_CHARS],
|
||||
@@ -257,9 +293,15 @@ def delete(db: DBSession, skill: Skill) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def index_block(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> str:
|
||||
def index_block(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
*,
|
||||
exclude: Iterable[str] = (),
|
||||
group: str | None = None,
|
||||
) -> str:
|
||||
"""The one-line-per-skill listing that goes into the prompt."""
|
||||
skills = enabled_for(db, user, exclude=exclude)
|
||||
skills = enabled_for(db, user, exclude=exclude, group=group)
|
||||
if not skills:
|
||||
return ""
|
||||
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
|
||||
|
||||
@@ -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 (
|
||||
AUTHOR_MODEL,
|
||||
AUTHOR_USER,
|
||||
DEFAULT_GROUP,
|
||||
Impression,
|
||||
Persona,
|
||||
PersonaRevision,
|
||||
@@ -54,8 +55,39 @@ MAX_VIEW_CHARS = 800
|
||||
# a model editing itself every turn cannot grow the table without limit.
|
||||
MAX_REVISIONS = 20
|
||||
|
||||
# Between a model id and a data group in a person's key. Two characters, because
|
||||
# one `@` is a character a model id could plausibly contain and this must never
|
||||
# split one.
|
||||
KEY_SEPARATOR = "@@"
|
||||
|
||||
|
||||
def key_for(model_id: str, group: str | None) -> str:
|
||||
"""The key a person's personality and impression are stored under.
|
||||
|
||||
**Namespaced by data group, and the reason is a constraint.** Both tables
|
||||
are `UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint, and
|
||||
this project's schema changes are additive only -- so a `data_group_id`
|
||||
column could not let one person hold a personality for the same model id in
|
||||
two groups, which is exactly what one model id served by two providers in
|
||||
different groups needs.
|
||||
|
||||
The default group keeps the bare model id, which is what every row written
|
||||
before groups existed already holds, so an upgrade moves nothing. The
|
||||
administrator's default (`owner_id NULL`) is always bare: it is their text,
|
||||
not a person's data, and every group falls back to it.
|
||||
"""
|
||||
if not model_id or not group or group == DEFAULT_GROUP:
|
||||
return model_id
|
||||
return f"{model_id}{KEY_SEPARATOR}{group}"
|
||||
|
||||
|
||||
def split_key(model_key: str) -> tuple[str, str]:
|
||||
"""(model id, data group) out of a stored key."""
|
||||
model_id, separator, group = (model_key or "").rpartition(KEY_SEPARATOR)
|
||||
if not separator:
|
||||
return model_key or "", DEFAULT_GROUP
|
||||
return model_id, group or DEFAULT_GROUP
|
||||
|
||||
|
||||
def get(db: DBSession, model_key: str, owner: User | None) -> Persona | None:
|
||||
"""One personality row, exactly as asked for and with no fallback.
|
||||
@@ -86,7 +118,8 @@ def effective(db: DBSession, model_key: str, owner: User | None) -> Persona | No
|
||||
own = get(db, model_key, owner)
|
||||
if own is not None:
|
||||
return own
|
||||
return get(db, model_key, None) if owner is not None else None
|
||||
# The default is keyed on the bare model id whatever group asked.
|
||||
return get(db, split_key(model_key)[0], None) if owner is not None else None
|
||||
|
||||
|
||||
def personas_of(db: DBSession, owner: User | None) -> list[Persona]:
|
||||
@@ -302,6 +335,7 @@ def view_block(db: DBSession, model_key: str, owner: User | None) -> str:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KEY_SEPARATOR",
|
||||
"MAX_PERSONA_CHARS",
|
||||
"MAX_REVISIONS",
|
||||
"MAX_VIEW_CHARS",
|
||||
@@ -312,8 +346,10 @@ __all__ = [
|
||||
"get",
|
||||
"impression",
|
||||
"impressions_for",
|
||||
"key_for",
|
||||
"personas_for",
|
||||
"personas_of",
|
||||
"split_key",
|
||||
"view_block",
|
||||
"revert",
|
||||
"write",
|
||||
|
||||
@@ -2023,17 +2023,28 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
group=GROUP_TASKS,
|
||||
order=451,
|
||||
hint="Added as the last turn when a member speaks on the forward pass. "
|
||||
"The failure to word against is a member that repeats what has already "
|
||||
"been said in different words, which is what makes a crowd feel like an "
|
||||
"echo rather than a second opinion.",
|
||||
"Two failures to word against. One is a member that repeats what has "
|
||||
"already been said in different words, which makes a crowd an echo "
|
||||
"rather than a second opinion. The other only shows up on a request that "
|
||||
"asks for something to be *made* -- write this, pick one, draft that -- "
|
||||
"where a member reads the original instruction as addressed to it too "
|
||||
"and produces a rival answer beside its critique. That is not a second "
|
||||
"opinion either; it is two first opinions, and it is what sends a round "
|
||||
"off the question.",
|
||||
default=(
|
||||
"You are one of several models answering this. The answers above are "
|
||||
"quoted with the name of whoever wrote them; yours comes next.\n"
|
||||
"\n"
|
||||
"Respond to what is above you. Do not answer the person's original "
|
||||
"request again yourself — that has been done, and your turn is about "
|
||||
"what was done with it.\n"
|
||||
"\n"
|
||||
"Add what is missing, correct what is wrong, and say what you would "
|
||||
"have done differently. Do not restate what has already been said to "
|
||||
"show that you agree with it — if you have nothing to add, say so in "
|
||||
"one line and stop. Be brief: somebody is reading all of these."
|
||||
"have done differently and why. Where you would have made a different "
|
||||
"choice, say what it would buy — naming an alternative is not the same "
|
||||
"as giving a reason to prefer it. Do not restate what has already been "
|
||||
"said to show that you agree with it — if you have nothing to add, say "
|
||||
"so in one line and stop. Be brief: somebody is reading all of these."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -2066,11 +2077,23 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"round. Its own fragment rather than a sentence inside the one below, "
|
||||
"because inviting a choice a model cannot express is worse than not "
|
||||
"offering it: on a model without the tools capability there is no "
|
||||
"crowd_again to call, and that is the case the next fragment covers.",
|
||||
"crowd_again to call, and that is the case the next fragment covers.\n"
|
||||
"\n"
|
||||
"The failure to word against is capitulation: the model that opened the "
|
||||
"round abandoning its own answer because somebody spoke after it. A "
|
||||
"closing turn told only to synthesise will follow the last speaker, "
|
||||
"which is how a crowd ends up less accurate than the model that started "
|
||||
"it.",
|
||||
default=(
|
||||
"You opened this and you are closing it. The others have answered and "
|
||||
"have had the chance to disagree.\n"
|
||||
"\n"
|
||||
"Your own answer is not automatically the worse one for having been "
|
||||
"written first. Change your position where somebody gave you a reason, "
|
||||
"and say what the reason was; agreement with no argument behind it is "
|
||||
"not a reason, and neither is a member having moved on to something "
|
||||
"else.\n"
|
||||
"\n"
|
||||
"Write the answer the person actually asked for. Take what the others "
|
||||
"got right, say where you disagree with them and why, and name "
|
||||
"anything still unresolved rather than papering over it. Attribute "
|
||||
@@ -2091,11 +2114,18 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"is reached, or this model has no tools and so cannot ask. It says the "
|
||||
"answer has to be final rather than inviting a choice that would be "
|
||||
"ignored, which is the difference between a feature and a feature that "
|
||||
"looks like one.",
|
||||
"looks like one. It carries the same guard against capitulation as the "
|
||||
"fragment above, and for the same reason.",
|
||||
default=(
|
||||
"You opened this and you are closing it, and this is the last turn: "
|
||||
"there will be no further round.\n"
|
||||
"\n"
|
||||
"Your own answer is not automatically the worse one for having been "
|
||||
"written first. Change your position where somebody gave you a reason, "
|
||||
"and say what the reason was; agreement with no argument behind it is "
|
||||
"not a reason, and neither is a member having moved on to something "
|
||||
"else.\n"
|
||||
"\n"
|
||||
"Write the answer the person actually asked for. Take what the others "
|
||||
"got right, say where you disagree with them and why, and attribute "
|
||||
"what you took from whom. Where the disagreement is unresolved, say so "
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.db.models import CHUNK_REPORT, DEFAULT_GROUP, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
@@ -33,7 +33,7 @@ MAX_BODY_CHARS = 60_000
|
||||
SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
def visible(user: User | None, group: str | None = None):
|
||||
"""Every report this person owns or has been shared.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
@@ -43,13 +43,17 @@ def visible(user: User | None):
|
||||
It said "a later move to shared reports is a change of one line here", and
|
||||
it was: `sharing.visible_to` is that line. Every listing, search and detail
|
||||
page went through this already, which is what made the move safe.
|
||||
|
||||
`group` narrows to one data group, and is what a model's tools pass.
|
||||
"""
|
||||
return select(Report).where(sharing.visible_to(Report, user))
|
||||
return select(Report).where(sharing.visible_to(Report, user, group))
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
def get(
|
||||
db: DBSession, report_id: str, user: User | None, group: str | None = None
|
||||
) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_read(db, report, user):
|
||||
if report is None or not sharing.can_read(db, report, user, group):
|
||||
return None
|
||||
return report
|
||||
|
||||
@@ -67,8 +71,12 @@ def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
return report
|
||||
|
||||
|
||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Report]:
|
||||
return list(db.scalars(visible(user).order_by(Report.created_at.desc()).limit(limit)))
|
||||
def recent(
|
||||
db: DBSession, user: User | None, *, limit: int = 20, group: str | None = None
|
||||
) -> list[Report]:
|
||||
return list(
|
||||
db.scalars(visible(user, group).order_by(Report.created_at.desc()).limit(limit))
|
||||
)
|
||||
|
||||
|
||||
def search(
|
||||
@@ -78,6 +86,7 @@ def search(
|
||||
*,
|
||||
limit: int = 20,
|
||||
vector: list[float] | None = None,
|
||||
group: str | None = None,
|
||||
) -> list[Report]:
|
||||
"""Reports matching `needle`, best match first.
|
||||
|
||||
@@ -94,7 +103,7 @@ def search(
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(user).where(Report.id.in_(list(order)))))
|
||||
rows = list(db.scalars(visible(user, group).where(Report.id.in_(list(order)))))
|
||||
rows.sort(key=lambda report: order.get(report.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
@@ -165,6 +174,7 @@ def create(
|
||||
model_id: str = "",
|
||||
error: str = "",
|
||||
unread: bool = True,
|
||||
group: str = DEFAULT_GROUP,
|
||||
) -> Report:
|
||||
"""File a report.
|
||||
|
||||
@@ -178,6 +188,7 @@ def create(
|
||||
"""
|
||||
report = Report(
|
||||
owner_id=owner.id,
|
||||
data_group_id=group or DEFAULT_GROUP,
|
||||
title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS],
|
||||
summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS],
|
||||
body=(body or "").strip()[:MAX_BODY_CHARS],
|
||||
|
||||
@@ -214,17 +214,29 @@ async def compile_request(
|
||||
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
|
||||
|
||||
|
||||
def endpoint_for(db, user: User) -> tuple[Endpoint, str] | None:
|
||||
def endpoint_for(db, user: User, model_id: str = "") -> tuple[Endpoint, str] | None:
|
||||
"""A connection and model to compile with, or None if there is none.
|
||||
|
||||
Built on a throwaway `Chat` that is never added to a session, exactly as
|
||||
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
||||
`connection_id` and nothing else, so it works unchanged and did not have to
|
||||
learn what a compile is.
|
||||
|
||||
**From the schedule's own data group.** The request being compiled is the
|
||||
person's words about their own work, and it goes to whichever model does the
|
||||
compiling -- so that model is chosen among the ones that will run the
|
||||
schedule, never merely the first one pinned. `model_id` is the model the
|
||||
form has chosen; without one, the person's default model decides the group.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
|
||||
models = chat_service.available_models(db, user)
|
||||
if model_id:
|
||||
group = data_groups.for_pair(db, user, model_id)
|
||||
else:
|
||||
chosen = chat_service.default_model(db, user)
|
||||
group = data_groups.for_pair(db, user, *chosen) if chosen else data_groups.DEFAULT_GROUP
|
||||
models = chat_service.available_models(db, user, group)
|
||||
if not models:
|
||||
return None
|
||||
chosen = next((m for m in models if m.pinned), models[0])
|
||||
|
||||
@@ -30,6 +30,7 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import (
|
||||
DEFAULT_GROUP,
|
||||
ROLE_ASSISTANT,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
@@ -187,6 +188,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
error="The run did not produce a reply.",
|
||||
group=schedule.data_group_id or DEFAULT_GROUP,
|
||||
)
|
||||
return
|
||||
reports_service.create(
|
||||
@@ -198,6 +200,7 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
model_id=message.model_id or "",
|
||||
group=schedule.data_group_id or DEFAULT_GROUP,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -213,7 +216,18 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
|
||||
# not write it, and the bubble should not imply they did.
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import messages as messages_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
# Checked when the schedule was saved, and again here: the person may
|
||||
# have moved a connection or remapped a group since, and a turn in
|
||||
# Messages is read by Messages' model on every later reply.
|
||||
refused = schedules_service.messages_refusal(
|
||||
db, owner, schedule.data_group_id or ""
|
||||
)
|
||||
if refused:
|
||||
schedule.last_error = refused
|
||||
db.commit()
|
||||
return
|
||||
conversation = messages_service.for_user(db, owner)
|
||||
chat_service.create_message(
|
||||
db,
|
||||
|
||||
@@ -14,10 +14,12 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
KIND_MESSAGES,
|
||||
KIND_TASK,
|
||||
ORIGIN_USER,
|
||||
ORIGINS,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
TARGETS,
|
||||
Chat,
|
||||
Schedule,
|
||||
@@ -55,6 +57,51 @@ def for_chat(db: DBSession, chat: Chat) -> Schedule | None:
|
||||
return db.scalars(select(Schedule).where(Schedule.chat_id == chat.id)).first()
|
||||
|
||||
|
||||
def group_for(db: DBSession, owner: User, model_id: str) -> str:
|
||||
"""The data group a schedule runs in: its model's, or the person's default's.
|
||||
|
||||
Stamped on the schedule and on its task chat when it is made. A run reads
|
||||
that group's memories and notes, and what it produces -- a report, a turn
|
||||
in a chat -- lands in it.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
|
||||
if model_id:
|
||||
return data_groups.for_pair(db, owner, model_id)
|
||||
chosen = chat_service.default_model(db, owner)
|
||||
return data_groups.for_pair(db, owner, *chosen) if chosen else data_groups.DEFAULT_GROUP
|
||||
|
||||
|
||||
def messages_refusal(db: DBSession, owner: User, group: str) -> str:
|
||||
"""Why a schedule in `group` may not post into Messages, or "".
|
||||
|
||||
Messages is one long conversation, pinned to one data group like any chat.
|
||||
A run from another group posting its answer there would put that group's
|
||||
output in front of this group's model on the next turn -- data crossing
|
||||
between providers through the one channel nobody would think to check.
|
||||
"""
|
||||
from lembas.services import data_groups
|
||||
|
||||
conversation = db.scalars(
|
||||
select(Chat)
|
||||
.where(Chat.user_id == owner.id, Chat.kind == KIND_MESSAGES)
|
||||
.order_by(Chat.created_at)
|
||||
).first()
|
||||
if conversation is not None:
|
||||
home = data_groups.for_chat(db, conversation)
|
||||
else:
|
||||
home = group_for(db, owner, "")
|
||||
if (group or data_groups.DEFAULT_GROUP) == home:
|
||||
return ""
|
||||
return (
|
||||
f"This schedule's model is in the data group "
|
||||
f"{data_groups.name_of(db, group)!r} and Messages is in "
|
||||
f"{data_groups.name_of(db, home)!r}, so it cannot post there. File it as a "
|
||||
f"report, or keep it in its own chat."
|
||||
)
|
||||
|
||||
|
||||
def count_for(db: DBSession, user: User) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
@@ -103,6 +150,13 @@ def create(
|
||||
# on, so it is refused at the only moment somebody is present to be told.
|
||||
raise ScheduleError("That schedule has no next run — its time has already passed.")
|
||||
|
||||
group = group_for(db, owner, model_id)
|
||||
target = target if target in TARGETS else TARGET_CHAT
|
||||
if target == TARGET_MESSAGES:
|
||||
refused = messages_refusal(db, owner, group)
|
||||
if refused:
|
||||
raise ScheduleError(refused)
|
||||
|
||||
limit = int(settings_store.schedules(db).get("max_per_user") or 20)
|
||||
if count_for(db, owner) >= limit:
|
||||
raise ScheduleError(
|
||||
@@ -115,6 +169,7 @@ def create(
|
||||
kind=KIND_TASK,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
model_id=model_id or "",
|
||||
data_group_id=group,
|
||||
# Said on the row as well as implied by the kind. `tools.unattended`
|
||||
# reads both, because the column was added to a table that already held
|
||||
# task chats and a backfill cannot know which they were -- but every one
|
||||
@@ -134,6 +189,7 @@ def create(
|
||||
target=target if target in TARGETS else TARGET_CHAT,
|
||||
chat_id=chat.id,
|
||||
model_id=model_id or "",
|
||||
data_group_id=group,
|
||||
origin=origin if origin in ORIGINS else ORIGIN_USER,
|
||||
enabled=True,
|
||||
next_fire_at=rule_service.next_after(
|
||||
@@ -162,6 +218,10 @@ def update(
|
||||
if instruction is not None:
|
||||
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
|
||||
if target is not None and target in TARGETS:
|
||||
if target == TARGET_MESSAGES and schedule.target != TARGET_MESSAGES:
|
||||
refused = messages_refusal(db, owner, schedule.data_group_id or "")
|
||||
if refused:
|
||||
raise ScheduleError(refused)
|
||||
schedule.target = target
|
||||
if rule is not None:
|
||||
clean = rule_service.validate(rule)
|
||||
|
||||
@@ -40,6 +40,11 @@ EXTRACTION = "extraction"
|
||||
def _general_defaults() -> dict[str, Any]:
|
||||
return {
|
||||
"allow_signup": env_settings.allow_signup,
|
||||
# What the interface is rendered in when a person has not chosen. Empty
|
||||
# and "en" mean the same thing; `web/i18n.known` is what decides, so a
|
||||
# value from a release that offered more languages than this one cannot
|
||||
# leave somebody with a page nobody can read.
|
||||
"language": "",
|
||||
# When on, new accounts land in the `pending` role and cannot sign in
|
||||
# until an administrator approves them. Reserved for the users pass.
|
||||
"require_approval": False,
|
||||
|
||||
@@ -74,12 +74,19 @@ def principal_ids(user: User | None) -> tuple[list[str], list[str]]:
|
||||
return [user.id], [group.id for group in user.groups]
|
||||
|
||||
|
||||
def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
def visible_to(
|
||||
model: Any, user: User | None, group: str | None = None
|
||||
) -> ColumnElement[bool]:
|
||||
"""A WHERE clause selecting the rows of `model` this user may see.
|
||||
|
||||
Returned as a condition rather than a query so callers can add their own
|
||||
filtering, ordering and pagination without this module knowing about any of
|
||||
it.
|
||||
|
||||
`group` narrows to one data group, and is what every path that hands rows to
|
||||
a *model* passes -- a model reads only its own group's data. `None` is the
|
||||
person's own view of their library, which shows every group they have: the
|
||||
isolation is between providers, not between a person and their records.
|
||||
"""
|
||||
if user is None:
|
||||
# Signed out sees nothing. Not an empty library -- no library.
|
||||
@@ -93,7 +100,12 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
(Share.principal_type == PRINCIPAL_GROUP) & Share.principal_id.in_(groups or [""]),
|
||||
),
|
||||
)
|
||||
return or_(model.owner_id == user.id, model.id.in_(shared))
|
||||
seen = or_(model.owner_id == user.id, model.id.in_(shared))
|
||||
if group is None:
|
||||
return seen
|
||||
from lembas.services import data_groups
|
||||
|
||||
return and_(seen, data_groups.condition(model, group))
|
||||
|
||||
|
||||
def only_shared(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
@@ -120,9 +132,22 @@ def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
return model.owner_id == user.id
|
||||
|
||||
|
||||
def can_read(db: DBSession, resource: Any, user: User | None) -> bool:
|
||||
def can_read(
|
||||
db: DBSession, resource: Any, user: User | None, group: str | None = None
|
||||
) -> bool:
|
||||
"""Whether this user may read one row -- and, given `group`, whether it is in it.
|
||||
|
||||
The group half is what makes fetching a record *by id* obey the same
|
||||
boundary as searching for it. Without it a model that learned an id from
|
||||
another group's transcript could read the record straight past the filter.
|
||||
"""
|
||||
if user is None or resource is None:
|
||||
return False
|
||||
if group is not None:
|
||||
from lembas.services import data_groups
|
||||
|
||||
if data_groups.group_of(resource) != group:
|
||||
return False
|
||||
if resource.owner_id == user.id:
|
||||
return True
|
||||
users, groups = principal_ids(user)
|
||||
|
||||
@@ -77,7 +77,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from lembas.db.models import KIND_AGENT, KIND_CHAT, Chat, Model, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import data_groups, settings_store
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
@@ -224,6 +224,7 @@ def _create_child(
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
peer = friend is not None
|
||||
owner = db.get(User, parent.user_id) if parent.user_id else None
|
||||
child = Chat(
|
||||
user_id=parent.user_id,
|
||||
# An ordinary chat for a friend even when the asking one is an agent
|
||||
@@ -233,6 +234,14 @@ def _create_child(
|
||||
title=title[:200] or ("Question" if peer else "Helper"),
|
||||
model_id=friend.model_id if peer else parent.model_id,
|
||||
connection_id=friend.connection_id if peer else parent.connection_id,
|
||||
# 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, friend.model_id, friend.connection_id)
|
||||
if peer
|
||||
else data_groups.for_chat(db, parent)
|
||||
),
|
||||
# Never in a listing, and swept a day later even if it is kept.
|
||||
temporary=True,
|
||||
parent_chat_id=parent.id,
|
||||
@@ -559,7 +568,9 @@ def _friend_error(message: str, *, question: str = "") -> ToolOutcome:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model | None, str]:
|
||||
def _resolve_friend(
|
||||
db, owner: User, wanted: str, *, asking: str, group: str | None = None
|
||||
) -> tuple[Model | None, str]:
|
||||
"""The model a call named, or a refusal that says what it could have named.
|
||||
|
||||
The name arrives in a tool call, which is to say it was written by a model
|
||||
@@ -570,11 +581,15 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
|
||||
|
||||
Matched on `model_id` first and on the label second, because the roster
|
||||
prints both and a model will sometimes type back the pretty one.
|
||||
|
||||
`group` is the asking chat's data group. A friend is handed the question
|
||||
and whatever context the asker wrote into it, so one in another group would
|
||||
be carrying this group's data to another provider.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
question_for = wanted.strip()
|
||||
candidates = chat_service.roster_models(db, owner, exclude=asking)
|
||||
candidates = chat_service.roster_models(db, owner, exclude=asking, group=group)
|
||||
if not candidates:
|
||||
return None, (
|
||||
"There is no other model here to ask. Answer from what you know."
|
||||
@@ -583,7 +598,7 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
|
||||
return None, (
|
||||
"Name the model to ask, exactly as it is written in brackets in the "
|
||||
"list you were given:\n"
|
||||
+ chat_service.roster_block(db, owner, exclude=asking)
|
||||
+ chat_service.roster_block(db, owner, exclude=asking, group=group)
|
||||
)
|
||||
|
||||
lowered = question_for.lower()
|
||||
@@ -603,7 +618,7 @@ def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model
|
||||
return None, (
|
||||
f"There is no model called {question_for!r} that you can reach. "
|
||||
"These are the ones you can:\n"
|
||||
+ chat_service.roster_block(db, owner, exclude=asking)
|
||||
+ chat_service.roster_block(db, owner, exclude=asking, group=group)
|
||||
)
|
||||
|
||||
|
||||
@@ -670,7 +685,13 @@ async def _run_ask_friend(context: ToolContext, args: dict[str, Any]) -> ToolOut
|
||||
if owner is None: # pragma: no cover - a chat outliving its owner
|
||||
return _friend_error("That account no longer exists.", question=question)
|
||||
|
||||
friend, refusal = _resolve_friend(db, owner, wanted, asking=parent.model_id)
|
||||
friend, refusal = _resolve_friend(
|
||||
db,
|
||||
owner,
|
||||
wanted,
|
||||
asking=parent.model_id,
|
||||
group=data_groups.for_chat(db, parent),
|
||||
)
|
||||
if friend is None:
|
||||
return _friend_error(refusal, question=question)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, KIND_TASK, SOURCE_CHAT, Chat, User
|
||||
from lembas.db.models import AUTHOR_MODEL, DEFAULT_GROUP, KIND_TASK, SOURCE_CHAT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import personas as personas_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
@@ -289,6 +289,12 @@ class ToolContext:
|
||||
image_checkpoint: str = ""
|
||||
model_id: str = ""
|
||||
connection_id: str = ""
|
||||
# Which data group this call reads and writes, resolved from the answering
|
||||
# model's connection. Every library runner passes it to its store and every
|
||||
# write stamps it, so a model reaches exactly one group's records -- by
|
||||
# search *and* by id, since a model that learned an id from somewhere else
|
||||
# must not be able to fetch the record past the filter.
|
||||
data_group: str = DEFAULT_GROUP
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -465,12 +471,18 @@ async def _run_knowledge_search(context: ToolContext, args: dict[str, Any]) -> T
|
||||
# session held across one is the trade `_maybe_compact` already refuses.
|
||||
# None for every "no" -- no model configured, endpoint down -- and the
|
||||
# search is then exactly the keyword one it has always been.
|
||||
vector = await _query_vector(query)
|
||||
vector = await _query_vector(query, context.data_group)
|
||||
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
found = documents_service.search(
|
||||
db, user, query, limit=6, base_ids=context.base_ids, vector=vector
|
||||
db,
|
||||
user,
|
||||
query,
|
||||
limit=6,
|
||||
base_ids=context.base_ids,
|
||||
vector=vector,
|
||||
group=context.data_group,
|
||||
)
|
||||
event = {
|
||||
"name": "knowledge_search",
|
||||
@@ -502,7 +514,7 @@ async def _run_knowledge_get(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
document_id = str(args.get("id") or "").strip()
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
document = documents_service.get(db, document_id, user)
|
||||
document = documents_service.get(db, document_id, user, context.data_group)
|
||||
if document is None:
|
||||
return ToolOutcome(
|
||||
"There is no such document, or it is not available to you.",
|
||||
@@ -526,7 +538,7 @@ async def _run_knowledge_get(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
return ToolOutcome(f"{document.title}\n\n{body}", event)
|
||||
|
||||
|
||||
async def _query_vector(query: str) -> list[float] | None:
|
||||
async def _query_vector(query: str, group: str | None = None) -> list[float] | None:
|
||||
"""The query as a vector, for the stores that can use one.
|
||||
|
||||
Its own session, opened and closed before the caller opens theirs: this is
|
||||
@@ -538,20 +550,22 @@ async def _query_vector(query: str) -> list[float] | None:
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
with session_scope() as db:
|
||||
worker = retrieval.worker_for(db)
|
||||
worker = retrieval.worker_for(db, group)
|
||||
return await retrieval.embed_with(worker, query)
|
||||
|
||||
|
||||
# --- Notes -------------------------------------------------------------------
|
||||
async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
query = str(args.get("query") or "").strip()
|
||||
vector = await _query_vector(query)
|
||||
vector = await _query_vector(query, context.data_group)
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
found = (
|
||||
notes_service.search(db, user, query, limit=8, vector=vector)
|
||||
notes_service.search(
|
||||
db, user, query, limit=8, vector=vector, group=context.data_group
|
||||
)
|
||||
if query
|
||||
else notes_service.recent(db, user, limit=8)
|
||||
else notes_service.recent(db, user, limit=8, group=context.data_group)
|
||||
)
|
||||
event = {
|
||||
"name": "notes_search",
|
||||
@@ -571,7 +585,7 @@ async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
async def _run_notes_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
|
||||
if note is None:
|
||||
return ToolOutcome(
|
||||
"There is no such note, or it is not available to you.",
|
||||
@@ -599,7 +613,12 @@ async def _run_notes_create(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
note = notes_service.create(
|
||||
db, owner=user, title=title, body=body, author=AUTHOR_MODEL
|
||||
db,
|
||||
owner=user,
|
||||
title=title,
|
||||
body=body,
|
||||
author=AUTHOR_MODEL,
|
||||
group=context.data_group,
|
||||
)
|
||||
return ToolOutcome(
|
||||
f"Saved note {note.id} — {note.title!r}.",
|
||||
@@ -615,7 +634,7 @@ async def _run_notes_create(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
|
||||
if note is None or note.owner_id != context.owner_id:
|
||||
return ToolOutcome(
|
||||
"There is no such note, or it belongs to someone else. A note "
|
||||
@@ -642,7 +661,7 @@ async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOut
|
||||
async def _run_notes_delete(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user)
|
||||
note = notes_service.get(db, str(args.get("id") or ""), user, context.data_group)
|
||||
if note is None or note.owner_id != context.owner_id:
|
||||
return ToolOutcome(
|
||||
"There is no such note, or it belongs to someone else.",
|
||||
@@ -735,7 +754,7 @@ async def _run_persona_write(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
return _persona_error("persona_write", "There is nobody here to be this with.")
|
||||
row = personas_service.write(
|
||||
db,
|
||||
model_key=context.model_id,
|
||||
model_key=personas_service.key_for(context.model_id, context.data_group),
|
||||
owner=user,
|
||||
content=content,
|
||||
author=AUTHOR_MODEL,
|
||||
@@ -782,7 +801,9 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
|
||||
if user is None:
|
||||
return _persona_error("impression_write", "There is nobody here to describe.")
|
||||
if not content:
|
||||
row = personas_service.impression(db, context.model_id, user)
|
||||
row = personas_service.impression(
|
||||
db, personas_service.key_for(context.model_id, context.data_group), user
|
||||
)
|
||||
if row is not None:
|
||||
personas_service.clear_impression(db, row)
|
||||
return ToolOutcome(
|
||||
@@ -791,7 +812,7 @@ async def _run_impression_write(context: ToolContext, args: dict[str, Any]) -> T
|
||||
)
|
||||
row = personas_service.write_impression(
|
||||
db,
|
||||
model_key=context.model_id,
|
||||
model_key=personas_service.key_for(context.model_id, context.data_group),
|
||||
owner=user,
|
||||
content=content,
|
||||
author=AUTHOR_MODEL,
|
||||
@@ -819,7 +840,7 @@ async def _run_memory_add(context: ToolContext, args: dict[str, Any]) -> ToolOut
|
||||
user = db.get(User, context.owner_id)
|
||||
try:
|
||||
memory = memories_service.add(
|
||||
db, owner=user, content=content, author=AUTHOR_MODEL
|
||||
db, owner=user, content=content, author=AUTHOR_MODEL, group=context.data_group
|
||||
)
|
||||
except ValueError as exc:
|
||||
return ToolOutcome(
|
||||
@@ -861,7 +882,7 @@ async def _run_memory_forget(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
wanted = str(args.get("content") or "").strip().lower()
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
records = memories_service.all_for(db, user)
|
||||
records = memories_service.all_for(db, user, context.data_group)
|
||||
if not wanted:
|
||||
return ToolOutcome(
|
||||
"Say which memory to remove, quoting its text.",
|
||||
@@ -918,6 +939,7 @@ async def _run_report_write(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
source=SOURCE_CHAT,
|
||||
source_id=context.chat_id or "",
|
||||
model_id=context.model_id or "",
|
||||
group=context.data_group,
|
||||
)
|
||||
return ToolOutcome(
|
||||
f"Filed report {report.id} — {report.title!r}. "
|
||||
@@ -937,9 +959,11 @@ async def _run_report_search(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
found = (
|
||||
reports_service.search(db, user, query, limit=8, vector=vector)
|
||||
reports_service.search(
|
||||
db, user, query, limit=8, vector=vector, group=context.data_group
|
||||
)
|
||||
if query
|
||||
else reports_service.recent(db, user, limit=8)
|
||||
else reports_service.recent(db, user, limit=8, group=context.data_group)
|
||||
)
|
||||
event = {
|
||||
"name": "report_search",
|
||||
@@ -962,7 +986,7 @@ async def _run_report_search(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
async def _run_report_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
report = reports_service.get(db, str(args.get("id") or ""), user)
|
||||
report = reports_service.get(db, str(args.get("id") or ""), user, context.data_group)
|
||||
if report is None:
|
||||
return ToolOutcome(
|
||||
"There is no such report.",
|
||||
@@ -985,7 +1009,7 @@ async def _run_skill_get(context: ToolContext, args: dict[str, Any]) -> ToolOutc
|
||||
name = str(args.get("name") or "").strip()
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
skill = skills_service.by_name(db, name, user)
|
||||
skill = skills_service.by_name(db, name, user, context.data_group)
|
||||
# Enforced here and not only in the listing. Without this the per-chat
|
||||
# narrowing is advisory: a model can name a skill it was never shown --
|
||||
# from an earlier turn, from a note -- and the runner would fetch it.
|
||||
@@ -1020,6 +1044,7 @@ async def _run_skill_create(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
description=str(args.get("description") or ""),
|
||||
body=str(args.get("body") or ""),
|
||||
author=AUTHOR_MODEL,
|
||||
group=context.data_group,
|
||||
)
|
||||
except skills_service.SkillError as exc:
|
||||
return ToolOutcome(
|
||||
@@ -1039,7 +1064,9 @@ async def _run_skill_create(context: ToolContext, args: dict[str, Any]) -> ToolO
|
||||
async def _run_skill_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
skill = skills_service.by_name(db, str(args.get("name") or ""), user)
|
||||
skill = skills_service.by_name(
|
||||
db, str(args.get("name") or ""), user, context.data_group
|
||||
)
|
||||
if skill is None or skill.owner_id != context.owner_id:
|
||||
return ToolOutcome(
|
||||
"There is no such skill, or it belongs to someone else.",
|
||||
@@ -1878,7 +1905,16 @@ def resolve_tools(
|
||||
# something on here would still be reaching for a tool the gates had
|
||||
# already removed.
|
||||
off = scoped_off(chat)
|
||||
empty_library = not skills_service.count_enabled(db, user, exclude=scoped_skills_off(chat))
|
||||
# Counted in the answering model's own data group: skills in another group
|
||||
# are not readable here, so they must not keep `skill_get` on offer.
|
||||
from lembas.services import data_groups
|
||||
|
||||
empty_library = not skills_service.count_enabled(
|
||||
db,
|
||||
user,
|
||||
exclude=scoped_skills_off(chat),
|
||||
group=data_groups.for_speaker(db, user, chat, speaker),
|
||||
)
|
||||
|
||||
# What a crowd speaker may do, which is narrower than what the chat may.
|
||||
if crowd_turn is not None:
|
||||
@@ -2095,6 +2131,7 @@ def context_for(
|
||||
model.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import data_groups
|
||||
from lembas.services.agent import session as agent_session
|
||||
|
||||
if chat is not None and speaker is None:
|
||||
@@ -2110,6 +2147,11 @@ def context_for(
|
||||
image_checkpoint=(chat.image_checkpoint or "") if chat is not None else "",
|
||||
model_id=(speaker.model_id or "") if speaker is not None else "",
|
||||
connection_id=(speaker.connection_id or "") if speaker is not None else "",
|
||||
data_group=(
|
||||
data_groups.for_speaker(db, user, chat, speaker)
|
||||
if chat is not None
|
||||
else DEFAULT_GROUP
|
||||
),
|
||||
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
|
||||
skills_off=scoped_skills_off(chat),
|
||||
tools=tools.by_name if tools is not None else None,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Translating the interface, without a build step.
|
||||
|
||||
## Why not gettext
|
||||
|
||||
`.po` files compiled to `.mo` are a build step, and this project does not have
|
||||
one (hard rule 1). So a catalogue is a committed Python module: a dict, keyed by
|
||||
**the English source text**, loaded at import.
|
||||
|
||||
Keying on the source has one large advantage and one cost, and the advantage is
|
||||
what decides it: **a missing entry renders the key**, which is the English. An
|
||||
untranslated string therefore looks exactly as it did before, an instance running
|
||||
in English is byte-for-byte what shipped, and a half-finished catalogue degrades
|
||||
into a half-translated page rather than into `settings.appearance.theme.label`
|
||||
written across somebody's screen. The cost is that editing an English sentence
|
||||
orphans its translation silently -- which is what
|
||||
`tests/test_translations.py` exists to catch, in both directions.
|
||||
|
||||
This is the shape `branding.FLAVOUR` and `services/prompts.py` already use:
|
||||
defaults in code, overrides beside them, and a test that the two agree.
|
||||
|
||||
## Why a ContextVar
|
||||
|
||||
`t()` has to be reachable from a Jinja **global**, not from the template context.
|
||||
`web/templating.render` is bypassed by 25 direct `TemplateResponse` calls and 8
|
||||
`get_template().render()` calls, and the second group is the SSE frame path, which
|
||||
has no `Request` object at all -- so threading a language through the context
|
||||
would leave a third of the application untranslated, and `brand`'s docstring
|
||||
records that lesson already.
|
||||
|
||||
But a global is bound once at import and the language is **per person**, so the
|
||||
active language cannot live in the global. It lives in a `ContextVar` that
|
||||
`LanguageMiddleware` sets per request. That is the one piece of genuinely new
|
||||
machinery here; `brand` avoids needing it only because instance branding is the
|
||||
same for everybody.
|
||||
|
||||
⚠ A `ContextVar` is per *task*, and a background reply is a task of its own. It
|
||||
therefore does not inherit a request's language, which is correct rather than
|
||||
unfortunate: nothing a generation writes is interface text, and the one place it
|
||||
matters -- a fragment telling a model which language to answer in -- is a prompt
|
||||
variable, not a `t()` call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from contextvars import ContextVar
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The language every string is written in, and the key every catalogue uses.
|
||||
SOURCE = "en"
|
||||
|
||||
# What an instance may be set to. Ordered, because it is also the order the
|
||||
# settings screens offer.
|
||||
LANGUAGES: tuple[tuple[str, str], ...] = (
|
||||
("en", "English"),
|
||||
("sk", "Slovenčina"),
|
||||
)
|
||||
|
||||
LANGUAGE_IDS = tuple(code for code, _name in LANGUAGES)
|
||||
|
||||
# Text direction, so `<html dir>` is answered from one place when a
|
||||
# right-to-left language is added rather than being forgotten.
|
||||
DIRECTIONS: Mapping[str, str] = {"en": "ltr", "sk": "ltr"}
|
||||
|
||||
_active: ContextVar[str] = ContextVar("lembas_language", default=SOURCE)
|
||||
|
||||
# Loaded lazily and cached: a catalogue is a module, and importing every language
|
||||
# at start would read files an instance in English never needs.
|
||||
_catalogues: dict[str, Mapping[str, str]] = {}
|
||||
|
||||
|
||||
# The instance default, cached at process level exactly as `branding.snapshot()`
|
||||
# is and invalidated the same way -- by the one module that writes it calling
|
||||
# `forget()`. Without the cache, every request would need a settings read before
|
||||
# it could decide what language to render in, including the ones that never touch
|
||||
# the database otherwise.
|
||||
_DEFAULT: str | None = None
|
||||
|
||||
|
||||
def instance_default() -> str:
|
||||
"""What this instance renders in when nobody has said otherwise.
|
||||
|
||||
Never raises: a language that cannot be read is not a reason to fail a page,
|
||||
and English is a usable answer. The same argument `branding.snapshot` makes.
|
||||
"""
|
||||
global _DEFAULT
|
||||
if _DEFAULT is not None:
|
||||
return _DEFAULT
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
|
||||
with session_scope() as db:
|
||||
_DEFAULT = known(settings_store.get(db, "language"))
|
||||
except Exception: # noqa: BLE001 - defaults are a usable answer
|
||||
log.debug("could not read the instance language; using %s", SOURCE, exc_info=True)
|
||||
return SOURCE
|
||||
return _DEFAULT
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Drop the cached instance default. Called by whoever saves it."""
|
||||
global _DEFAULT
|
||||
_DEFAULT = None
|
||||
|
||||
|
||||
def for_user(user) -> str:
|
||||
"""The language one person sees: their own choice, else the instance's.
|
||||
|
||||
A `User` or None, so a signed-out page -- the sign-in screen, an error page --
|
||||
is rendered in the instance's language rather than in English by accident.
|
||||
"""
|
||||
chosen = ""
|
||||
if user is not None:
|
||||
chosen = str((getattr(user, "settings_json", None) or {}).get("language") or "")
|
||||
return known(chosen) if chosen else instance_default()
|
||||
|
||||
|
||||
def known(code: str | None) -> str:
|
||||
"""A language this application has, from whatever was stored or requested."""
|
||||
value = (code or "").strip().lower()
|
||||
if value in LANGUAGE_IDS:
|
||||
return value
|
||||
# A stored value from a release that offered more languages than this one, or
|
||||
# a hand-edited row. English rather than an error: a preference nobody can
|
||||
# satisfy is not a reason to refuse somebody their settings page.
|
||||
return SOURCE
|
||||
|
||||
|
||||
def catalogue(code: str) -> Mapping[str, str]:
|
||||
"""Every translation for one language, keyed by its English source."""
|
||||
code = known(code)
|
||||
if code == SOURCE:
|
||||
return {}
|
||||
if code not in _catalogues:
|
||||
try:
|
||||
module = __import__(f"lembas.web.i18n.{code}", fromlist=["MESSAGES"])
|
||||
_catalogues[code] = dict(getattr(module, "MESSAGES", {}))
|
||||
except Exception: # noqa: BLE001 - a broken catalogue must not break the page
|
||||
log.exception("could not load the %s catalogue", code)
|
||||
_catalogues[code] = {}
|
||||
return _catalogues[code]
|
||||
|
||||
|
||||
def active() -> str:
|
||||
return _active.get()
|
||||
|
||||
|
||||
def activate(code: str | None) -> str:
|
||||
"""Set the language for this request. Returns what was actually set."""
|
||||
code = known(code)
|
||||
_active.set(code)
|
||||
return code
|
||||
|
||||
|
||||
def direction(code: str | None = None) -> str:
|
||||
return DIRECTIONS.get(known(code) if code else active(), "ltr")
|
||||
|
||||
|
||||
def translate(text: str, code: str | None = None) -> str:
|
||||
"""One string in the active language, or the English it was written in.
|
||||
|
||||
Whitespace is collapsed for the *lookup* and not for the output. A template
|
||||
wraps its prose across lines for the width of the file, so the same sentence
|
||||
reaches here with different newlines in it depending on where it sits -- and a
|
||||
catalogue keyed on the exact bytes would need an entry per wrapping. What is
|
||||
returned is the translation as written in the catalogue, or the original text
|
||||
untouched.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
entries = catalogue(code or active())
|
||||
if not entries:
|
||||
return text
|
||||
return entries.get(" ".join(text.split()), text)
|
||||
|
||||
|
||||
def t(text: str, **fields: object) -> str:
|
||||
"""The function templates and routes call.
|
||||
|
||||
`t("Saved %(name)s", name=x)` rather than an f-string, because a translator
|
||||
needs the whole sentence and because the order of the parts is not the same in
|
||||
every language. Percent-named rather than `str.format`, so a stray brace in a
|
||||
translation cannot raise.
|
||||
"""
|
||||
out = translate(text)
|
||||
if not fields:
|
||||
return out
|
||||
try:
|
||||
return out % fields
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# A catalogue whose placeholders do not match the source is a bug in the
|
||||
# catalogue, and the English sentence is a better answer than a traceback
|
||||
# in the middle of somebody's page.
|
||||
log.warning("placeholder mismatch translating %r", text[:60])
|
||||
try:
|
||||
return text % fields
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return text
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIRECTIONS",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_IDS",
|
||||
"SOURCE",
|
||||
"activate",
|
||||
"active",
|
||||
"catalogue",
|
||||
"direction",
|
||||
"forget",
|
||||
"for_user",
|
||||
"instance_default",
|
||||
"known",
|
||||
"stamp",
|
||||
"t",
|
||||
"translate",
|
||||
]
|
||||
|
||||
|
||||
# --- Dates ---------------------------------------------------------------------
|
||||
#
|
||||
# `strftime("%A")` and `%B` emit C-locale English whatever the page is in, which is
|
||||
# invisible until the day a second language ships and then wrong on every screen
|
||||
# showing a date. Setting a process locale is not an option: it is global, it is
|
||||
# not thread-safe, and this application renders two people's pages at once.
|
||||
#
|
||||
# So the names are a table and the *format* is itself translatable -- Slovak wants
|
||||
# "26. septembra 2026", not "26 September 2026", and that is a different pattern
|
||||
# rather than different words in the same one.
|
||||
#
|
||||
# ⚠ Only for what a **person** reads. `harness.py`, `schedule/runner.py` and
|
||||
# `schedule/compile.py` all put dates in front of a *model*, and those stay
|
||||
# English: the prompts are written in English, a model is not the reader, and a
|
||||
# background task has no request language anyway.
|
||||
MONTHS: Mapping[str, Mapping[str, str]] = {
|
||||
"sk": {
|
||||
"January": "januára",
|
||||
"February": "februára",
|
||||
"March": "marca",
|
||||
"April": "apríla",
|
||||
"May": "mája",
|
||||
"June": "júna",
|
||||
"July": "júla",
|
||||
"August": "augusta",
|
||||
"September": "septembra",
|
||||
"October": "októbra",
|
||||
"November": "novembra",
|
||||
"December": "decembra",
|
||||
}
|
||||
}
|
||||
|
||||
DAYS: Mapping[str, Mapping[str, str]] = {
|
||||
"sk": {
|
||||
"Monday": "pondelok",
|
||||
"Tuesday": "utorok",
|
||||
"Wednesday": "streda",
|
||||
"Thursday": "štvrtok",
|
||||
"Friday": "piatok",
|
||||
"Saturday": "sobota",
|
||||
"Sunday": "nedeľa",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def stamp(value, fmt: str) -> str:
|
||||
"""One date, in the reader's language.
|
||||
|
||||
`fmt` is an English `strftime` pattern and is translated like any other
|
||||
string, so a language that puts the day before the month -- or wants a full
|
||||
stop after it -- says so in the catalogue rather than here.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
code = active()
|
||||
text = value.strftime(translate(fmt, code))
|
||||
for table in (MONTHS.get(code, {}), DAYS.get(code, {})):
|
||||
for english, local in table.items():
|
||||
text = text.replace(english, local)
|
||||
return text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
|
||||
pinned to the scrollport with `background-attachment: local`, which is the old
|
||||
trick and works everywhere -- the `local` layers scroll with the content and
|
||||
cover the `scroll` ones exactly when there is nothing more to see.
|
||||
|
||||
⚠ "Cover" has to mean all of it. The covers used to be as wide as the
|
||||
shadows and solid for only 40% of that width, so the other 60% of every
|
||||
shadow always showed through, with nothing to scroll to. On Moria that is
|
||||
near-black on near-black and nobody saw it. On Shire it was a grey sliver at
|
||||
both ends of every tab bar. Each cover is now twice the shadow's width and
|
||||
solid across the first half, which is the whole shadow. It fades only past
|
||||
the shadow's end, so once content is scrolled the shadow shows as before.
|
||||
*/
|
||||
.tabs__bar {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--bg) 40%, transparent),
|
||||
linear-gradient(to left, var(--bg) 40%, transparent),
|
||||
linear-gradient(to right, var(--bg) 50%, transparent),
|
||||
linear-gradient(to left, var(--bg) 50%, transparent),
|
||||
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
||||
linear-gradient(to left, var(--scrim), transparent 1.5rem);
|
||||
background-position: left center, right center, left center, right center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5rem 100%;
|
||||
background-size: 3rem 100%, 3rem 100%, 1.5rem 100%, 1.5rem 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
/* A tab is a destination, so a flick should land on one rather than between
|
||||
two. */
|
||||
@@ -260,7 +268,9 @@ a.tabs__tab { text-decoration: none; }
|
||||
*/
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
/* Both halves of the pair -- see `.grid--2` in app.css. */
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.field-row > .field { margin-bottom: var(--sp-4); }
|
||||
@@ -449,6 +459,35 @@ a.tabs__tab { text-decoration: none; }
|
||||
.model-list__item:first-child { padding-top: 0; }
|
||||
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
|
||||
/* The models card in /settings. Every row shares the list's column tracks, so
|
||||
the context sizes and the eyes line up; the description and the capability
|
||||
tags take the rest of the row underneath, never the space beside the name. */
|
||||
.model-list--models {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
.model-list--models .model-list__item {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
row-gap: var(--sp-1);
|
||||
}
|
||||
/* Wraps rather than truncates: there is room below, and a name cut to
|
||||
"Gemma 4 E…" on a phone is a different model's name. */
|
||||
.model-list__name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.model-list__more { grid-column: 2 / -1; min-width: 0; }
|
||||
.model-list__tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.model-list__tags:empty { display: none; }
|
||||
|
||||
/* --- Permission grids ------------------------------------------------------ */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
|
||||
@@ -384,8 +384,16 @@ input.visually-hidden[type="checkbox"] {
|
||||
|
||||
/* Multi-column form layout, one definition. */
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
||||
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
|
||||
/* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
|
||||
reason `.suggestions` in chat.css sets out at length. The pair is not
|
||||
optional: `min(100%, …)` stops the track demanding more than the box, and
|
||||
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
|
||||
flex item carries `min-width: auto`, which is a min-content floor, and a
|
||||
floor beats `width`. A stylesheet cannot tell whether one of these grids has
|
||||
been dropped into a flex parent today, so both go on every one of them.
|
||||
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
|
||||
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
|
||||
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
|
||||
|
||||
/* --- Alerts --------------------------------------------------------------- */
|
||||
.alert {
|
||||
@@ -444,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
|
||||
child will not shrink below its content without it, so a scroller missing it
|
||||
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
|
||||
scroll container's automatic minimum size to get away with omitting it, which
|
||||
is true and is not something the next person should have to know. */
|
||||
is true and is not something the next person should have to know.
|
||||
|
||||
`position: relative` makes each scroller the containing block for what is in
|
||||
it, and without it a `.visually-hidden` label is not in it at all. That class
|
||||
is `position: absolute`, so with no positioned ancestor it is placed against
|
||||
the *page*, at its static position -- six thousand pixels down the Tools
|
||||
panel on /admin/prompts -- and the document grew to 6771px behind a root
|
||||
that is `overflow: hidden`. Nobody can scroll that by hand, but
|
||||
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
|
||||
and the root can. Switching a tab there lifted the whole shell 56px: the
|
||||
topbar gone off the top and a strip of bare background under everything. */
|
||||
.scroll-region,
|
||||
.sidebar__scroll,
|
||||
.inspector__body,
|
||||
@@ -452,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
|
||||
.thread-scroll,
|
||||
.admin-scroll,
|
||||
.main > .tabs > .tabs__body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1369,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
which nothing else on the screen tells you -- gets the room back. */
|
||||
.topbar__actions .picker__label { display: none; }
|
||||
|
||||
/* And the menu is the bar's, not the picker's. Anchored to the picker it
|
||||
opens `right: 0` of a button that sits mid-bar with the panel buttons to
|
||||
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
|
||||
every name in half. Taking `position` off the picker makes the bar the
|
||||
containing block: the menu spans the bar under it, whatever sits where --
|
||||
up to its usual 24rem, held at the bar's right edge by the auto margin. */
|
||||
.topbar { position: relative; }
|
||||
.topbar__actions .picker { position: static; }
|
||||
.topbar__actions .picker__menu {
|
||||
left: max(var(--sp-2), var(--safe-left));
|
||||
right: max(var(--sp-2), var(--safe-right));
|
||||
width: auto;
|
||||
max-width: 24rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -1722,6 +1758,79 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
color: var(--leaf);
|
||||
}
|
||||
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
|
||||
|
||||
/* The model picker: one row per model on the list's own column tracks, so the
|
||||
context sizes and the eyes form columns whatever a name's length. Scoped to
|
||||
the modifier because `.picker__list` is also the @-mention menu's list. */
|
||||
.picker__list--models {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
.picker__list--models .picker__option {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
gap: inherit;
|
||||
}
|
||||
.picker__list--models .picker__option .picker__avatar { margin-top: 0; }
|
||||
|
||||
/* Whether a model is loaded, where its endpoint says so (llama-swap does; a
|
||||
hosted API does not, and gets nothing). A dot on the avatar's corner, ringed
|
||||
in the menu's own surface so it reads against any avatar colour. Nothing is
|
||||
drawn until ui.js has an answer -- an empty `data-model-state` is "unknown",
|
||||
which is not the same claim as "unloaded". */
|
||||
.model-slot { position: relative; display: flex; flex: none; }
|
||||
.model-state {
|
||||
position: absolute;
|
||||
right: calc(var(--model-state-size) / -3);
|
||||
bottom: calc(var(--model-state-size) / -3);
|
||||
width: var(--model-state-size);
|
||||
height: var(--model-state-size);
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 0 0 var(--outline-w) var(--surface);
|
||||
display: none;
|
||||
}
|
||||
.model-slot[data-model-state="loaded"] .model-state { display: block; background: var(--model-state-loaded); }
|
||||
.model-slot[data-model-state="loading"] .model-state {
|
||||
display: block;
|
||||
background: var(--model-state-loading);
|
||||
animation: model-state-pulse var(--dur-slow) var(--ease-in-out) infinite;
|
||||
}
|
||||
@keyframes model-state-pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.model-slot[data-model-state="loading"] .model-state { animation: none; }
|
||||
}
|
||||
.picker__list--models .picker__option-name { min-width: 0; }
|
||||
.model-ctx {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-vision { display: flex; color: var(--ink-faint); min-width: 1rem; }
|
||||
.picker__list--models .picker__tick { display: flex; margin-top: 0; min-width: 1rem; visibility: hidden; }
|
||||
.picker__list--models .picker__option.is-selected .picker__tick { visibility: visible; }
|
||||
/* Models in another data group, named under the list inside a chat. Text, not
|
||||
options: switching this chat to one would hand it the conversation. */
|
||||
.picker__elsewhere {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.picker__elsewhere-head,
|
||||
.picker__elsewhere-names {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picker__elsewhere-names {
|
||||
margin-top: var(--sp-1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.picker__empty {
|
||||
padding: var(--sp-4);
|
||||
margin: 0;
|
||||
|
||||
@@ -266,7 +266,27 @@
|
||||
*/
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
/* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%`
|
||||
alone did not: it is a grid item of `.thread__intro`, so it carries
|
||||
`min-width: auto`, which for a grid item means *a min-content floor* -- and
|
||||
min-width beats width. Its min-content size is two cards side by side, so it
|
||||
rendered 428px wide inside a 390px phone with `width: 100%` set and ignored.
|
||||
|
||||
That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)`
|
||||
-- the tree's standing rule, and right -- made it *worse* on its own, 428px
|
||||
to 455px: a percentage is indefinite while the floor is being measured, so
|
||||
the track fell back to a card's max-content and raised the very number that
|
||||
was overflowing. The two go together. With the floor removed, `width: 100%`
|
||||
finally resolves against the 366px column, `min(100%, …)` hands the track
|
||||
366px to clamp against, and `auto-fit` places one column.
|
||||
|
||||
It scrolled `.thread-scroll` rather than the page, which is why a pass
|
||||
looking for a document that scrolls sideways never saw it: `overflow-y: auto`
|
||||
makes the other axis scrollable too. Reported on a phone, found by asking
|
||||
which *element* could scroll and then reading its computed `width` against
|
||||
its parent's. */
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
@@ -983,9 +1003,17 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* position: relative anchors the `@` and `/` menu to the box. */
|
||||
/* position: relative anchors the `@` and `/` menu to the box.
|
||||
|
||||
`width: 100%` is the width; `max-width` only caps it. Without it the box was
|
||||
as wide as its widest content: `.composer` is a flex column, and auto margins
|
||||
on a flex item switch off the stretch it would otherwise get. So the hint
|
||||
under it decided. "GPT-OSS has no vision, so images will not be sent" made
|
||||
the box 768px, and the same screen with a model that sees images made it
|
||||
538px. */
|
||||
.composer__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--thread-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
--radius-xl: 18px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* The load-state dot on a model's avatar in the model menu. Its colours are
|
||||
tokens of their own, defaulting to the theme's success and warning, so
|
||||
an instance whose success colour is not green can still say "loaded" in
|
||||
green -- that is what people read a dot beside a name as. */
|
||||
--model-state-size: 0.625rem;
|
||||
--model-state-loaded: var(--success);
|
||||
--model-state-loading: var(--warning);
|
||||
|
||||
/*
|
||||
--- Controls ----------------------------------------------------------
|
||||
Every button, input and select resolves its height from these. That is the
|
||||
|
||||
@@ -279,6 +279,13 @@
|
||||
return document.getElementById("attachments");
|
||||
}
|
||||
|
||||
/* The composer's model, which on the new-chat screen decides which data
|
||||
group the library picker may offer. See data_groups.for_composer. */
|
||||
function modelId() {
|
||||
var field = document.querySelector('.composer input[name="model_id"], [data-picker-input]');
|
||||
return field ? field.value : "";
|
||||
}
|
||||
|
||||
function chatId() {
|
||||
var input = document.getElementById("file-input");
|
||||
var url = (input && input.dataset.uploadUrl) || "";
|
||||
@@ -335,7 +342,9 @@
|
||||
var close = dialog.querySelector("button");
|
||||
|
||||
function load(query) {
|
||||
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || ""), {
|
||||
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || "") +
|
||||
"&chat_id=" + encodeURIComponent(chatId()) +
|
||||
"&model_id=" + encodeURIComponent(modelId()), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (response) { return response.text(); })
|
||||
@@ -355,6 +364,7 @@
|
||||
var body = new FormData();
|
||||
body.append("document_id", option.dataset.attachKnowledge);
|
||||
body.append("chat_id", chatId());
|
||||
body.append("model_id", modelId());
|
||||
postForChip("/api/files/from-knowledge", body);
|
||||
finish();
|
||||
});
|
||||
@@ -1099,27 +1109,59 @@
|
||||
The worker no longer takes over open pages on its own -- see sw.js -- so
|
||||
something has to say that one is waiting, and the reader decides. A toast
|
||||
rather than a reload: an application with a reply streaming into it must
|
||||
not be navigated out from under somebody. */
|
||||
function watchForUpdate(registration) {
|
||||
function offer(worker) {
|
||||
if (!worker || !navigator.serviceWorker.controller) return;
|
||||
worker.addEventListener("statechange", function () {
|
||||
if (worker.state !== "installed") return;
|
||||
not be navigated out from under somebody.
|
||||
|
||||
🚨 "A worker is waiting" is not the same as "this page is out of date",
|
||||
and the toast used to treat them as one. After a release it offered a
|
||||
reload on every page, including one just fetched with Ctrl+Shift+R, and
|
||||
reloading could not make it stop. A page is always fetched from the network
|
||||
and every asset it names carries `?v=<release>`, so a page loaded after
|
||||
the update IS the update, whichever worker happens to control it. And the
|
||||
worker that controls it is nearly always the previous one: a reload
|
||||
creates the new page before the old one goes away, so the old worker
|
||||
never runs out of pages and the new one never stops waiting.
|
||||
|
||||
So the question is asked of the page. The worker's release is in its own
|
||||
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
|
||||
base.html. When the two match there is nothing newer to reload into, and
|
||||
the worker is left to take over once the old tabs are closed. */
|
||||
var PAGE_RELEASE = window.lembasRelease || "";
|
||||
|
||||
function releaseOf(worker) {
|
||||
try {
|
||||
return new URL(worker.scriptURL).searchParams.get("v") || "";
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* Unknown on either side counts as newer: better an extra offer than a
|
||||
release nobody is told about. */
|
||||
function isNewerThanThisPage(worker) {
|
||||
var release = releaseOf(worker);
|
||||
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
|
||||
}
|
||||
|
||||
function offerReload(worker) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
}
|
||||
|
||||
function watchForUpdate(registration) {
|
||||
function offer(worker) {
|
||||
if (!worker || !navigator.serviceWorker.controller) return;
|
||||
worker.addEventListener("statechange", function () {
|
||||
if (worker.state !== "installed") return;
|
||||
if (isNewerThanThisPage(worker)) offerReload(worker);
|
||||
});
|
||||
}
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
if (registration.waiting && navigator.serviceWorker.controller &&
|
||||
isNewerThanThisPage(registration.waiting)) {
|
||||
offerReload(registration.waiting);
|
||||
}
|
||||
registration.addEventListener("updatefound", function () {
|
||||
offer(registration.installing);
|
||||
@@ -1130,17 +1172,24 @@
|
||||
the right answer to it -- the page is now being served by a worker whose
|
||||
cache it did not start from.
|
||||
|
||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
||||
`controllerchange` can fire more than once. And `hadController`, because on
|
||||
a *first* visit there is no worker at all: the one that installs then calls
|
||||
`clients.claim()`, which fires this event for the first time -- so without
|
||||
it, the very first page anybody loads reloads itself in front of them for
|
||||
no reason they could possibly work out. */
|
||||
Three guards, and the second is the one that is easy to miss. A flag,
|
||||
because `controllerchange` can fire more than once. And `hadController`,
|
||||
because on a *first* visit there is no worker at all: the one that
|
||||
installs then calls `clients.claim()`, which fires this event for the
|
||||
first time -- so without it, the very first page anybody loads reloads
|
||||
itself in front of them for no reason they could possibly work out.
|
||||
|
||||
The third is the same question as the toast's. Somebody pressing Reload in
|
||||
one tab activates the worker for all of them, and a tab that was already
|
||||
rendered by that release has nothing to gain from a reload -- and may have
|
||||
a reply streaming into it. */
|
||||
var reloading = false;
|
||||
if ("serviceWorker" in navigator) {
|
||||
var hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||
if (reloading || !hadController) return;
|
||||
var controller = navigator.serviceWorker.controller;
|
||||
if (controller && !isNewerThanThisPage(controller)) return;
|
||||
reloading = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
@@ -222,7 +222,16 @@
|
||||
{
|
||||
name: "temp",
|
||||
summary: "Start a temporary chat, gone after a day",
|
||||
run: function () { window.location = "/chat?temporary=1"; }
|
||||
run: function () {
|
||||
/* On the new-chat screen the model, folder and kind already chosen are
|
||||
in the query string. Add the flag to them rather than starting over,
|
||||
or the chat is made on the default model. */
|
||||
var query = new URLSearchParams(
|
||||
window.location.pathname === "/chat" ? window.location.search : ""
|
||||
);
|
||||
query.set("temporary", "1");
|
||||
window.location = "/chat?" + query.toString();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
|
||||
@@ -62,7 +62,14 @@
|
||||
/* A chat under way carries its connection on the composer; a new one is
|
||||
still choosing it, so the select and the hidden field are the truth. */
|
||||
profileId: picker ? picker.value : (box && box.dataset.profileId) || "",
|
||||
projectDir: dir ? dir.value : (box && box.dataset.projectDir) || ""
|
||||
projectDir: dir ? dir.value : (box && box.dataset.projectDir) || "",
|
||||
/* The model the composer is writing to. Only the new-chat screen needs
|
||||
it -- a chat that exists answers "which data group" by itself -- but
|
||||
there it decides which library items may be offered at all. */
|
||||
modelId: (function () {
|
||||
var field = document.querySelector('.composer input[name="model_id"], [data-picker-input]');
|
||||
return field ? field.value : "";
|
||||
})()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,7 +178,8 @@
|
||||
"/api/files/mention-picker?q=" + encodeURIComponent(query) +
|
||||
"&chat_id=" + encodeURIComponent(where.chatId) +
|
||||
"&profile_id=" + encodeURIComponent(where.profileId) +
|
||||
"&project_dir=" + encodeURIComponent(where.projectDir);
|
||||
"&project_dir=" + encodeURIComponent(where.projectDir) +
|
||||
"&model_id=" + encodeURIComponent(where.modelId);
|
||||
|
||||
fetch(url, { credentials: "same-origin" })
|
||||
.then(function (response) { return response.text(); })
|
||||
@@ -266,6 +274,7 @@
|
||||
|
||||
var body = new FormData();
|
||||
body.append("chat_id", where.chatId);
|
||||
body.append("model_id", where.modelId);
|
||||
if (option.dataset.mentionFile) {
|
||||
body.append("profile_id", where.profileId);
|
||||
body.append("path", option.dataset.mentionFile);
|
||||
|
||||
@@ -320,6 +320,11 @@
|
||||
if (filter) {
|
||||
filter.value = "";
|
||||
applyFilter(menu, "");
|
||||
}
|
||||
// Not on a touchscreen: focusing a text field there raises the keyboard,
|
||||
// which covers half the list the finger came to choose from. The filter
|
||||
// is one tap away for whoever wants it.
|
||||
if (filter && !window.matchMedia("(hover: none)").matches) {
|
||||
filter.focus();
|
||||
} else {
|
||||
var selected = menu.querySelector(".picker__option.is-selected") ||
|
||||
@@ -329,6 +334,45 @@
|
||||
// Keep the chosen model in view when the list is long.
|
||||
var current = menu.querySelector(".picker__option.is-selected");
|
||||
if (current) current.scrollIntoView({ block: "nearest" });
|
||||
refreshStates(menu);
|
||||
}
|
||||
|
||||
/* Which models are loaded, asked for each time the model menu opens --
|
||||
llama-swap holds one at a time and it changes by the minute, so a value
|
||||
rendered with the page would be stale by the time anybody looked. Only
|
||||
models whose endpoint reports a state come back; everything else keeps an
|
||||
empty `data-model-state`, which draws nothing. While one is loading the
|
||||
menu asks again every two seconds, and stops when it closes. */
|
||||
function refreshStates(menu) {
|
||||
var list = menu.querySelector(".picker__list--models");
|
||||
if (!list || !window.fetch) return;
|
||||
clearTimeout(menu._stateTimer);
|
||||
fetch("/api/models/state", {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" }
|
||||
}).then(function (response) {
|
||||
return response.ok ? response.json() : null;
|
||||
}).then(function (data) {
|
||||
var states = (data && data.states) || {};
|
||||
var loading = false;
|
||||
list.querySelectorAll(".picker__option[data-model-id]").forEach(function (option) {
|
||||
var slot = option.querySelector("[data-model-state]");
|
||||
if (!slot) return;
|
||||
var state = states[option.dataset.modelId] || "";
|
||||
slot.dataset.modelState = state;
|
||||
if (state === "loading") loading = true;
|
||||
var label = slot.querySelector("[data-model-state-label]");
|
||||
if (label) {
|
||||
label.textContent = state === "loaded" ? list.dataset.labelLoaded
|
||||
: state === "loading" ? list.dataset.labelLoading : "";
|
||||
}
|
||||
});
|
||||
if (loading && !menu.hidden) {
|
||||
menu._stateTimer = setTimeout(function () {
|
||||
if (!menu.hidden) refreshStates(menu);
|
||||
}, 2000);
|
||||
}
|
||||
}).catch(function () { /* No state is a menu without dots, not an error. */ });
|
||||
}
|
||||
|
||||
function applyFilter(menu, needle) {
|
||||
@@ -1128,7 +1172,19 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
shrinks the document and scrollTop is clamped to the new maximum, which
|
||||
for a short panel is somewhere below everything. */
|
||||
var outer = scroller(bar);
|
||||
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
|
||||
if (!outer || outer === body) return;
|
||||
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
|
||||
ancestor that can scroll, the document included -- and the document
|
||||
could, by the height of whatever leaked out of the scroller, so a tab
|
||||
switch lifted the whole shell and left a strip of background under it.
|
||||
The containing block in app.css stops the leak; this stops a leak
|
||||
anyone adds later from being turned into a visible one.
|
||||
|
||||
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
|
||||
is scrolled past the lede it reports the scroller's own top and the
|
||||
sum below would come out as nothing to do. */
|
||||
var tabs = bar.parentElement;
|
||||
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if not connection.enabled %}
|
||||
<span class="badge">disabled</span>
|
||||
<span class="badge">{{ t("disabled") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
formnovalidate>
|
||||
{{ icon("refresh", "icon--sm") }} Test & refresh
|
||||
</button>
|
||||
<button class="btn btn--sm btn--primary" type="submit">Save</button>
|
||||
<button class="btn btn--sm btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,23 +45,23 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name-{{ connection.id }}">Name</label>
|
||||
<label class="field__label" for="name-{{ connection.id }}">{{ t("Name") }}</label>
|
||||
<input class="input" id="name-{{ connection.id }}" name="name"
|
||||
value="{{ connection.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
|
||||
<label class="field__label" for="url-{{ connection.id }}">{{ t("Base URL") }}</label>
|
||||
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
|
||||
value="{{ connection.base_url }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="key-{{ connection.id }}">API key</label>
|
||||
<label class="field__label" for="key-{{ connection.id }}">{{ t("API key") }}</label>
|
||||
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
|
||||
type="password" autocomplete="off"
|
||||
value="{{ unchanged if connection.api_key_encrypted else '' }}"
|
||||
placeholder="No key set">
|
||||
placeholder="{{ t('No key set') }}">
|
||||
<p class="field__hint">
|
||||
{% if connection.api_key_encrypted %}
|
||||
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
|
||||
@@ -72,15 +72,31 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Which data group this provider reads. Only worth a control once there is a
|
||||
second group to choose; until then every connection is in the default one
|
||||
and the field would be a select with one option. #}
|
||||
{% if data_group_choices is defined and data_group_choices|length > 1 %}
|
||||
<div class="field">
|
||||
<label class="field__label" for="unload-{{ connection.id }}">Unload URL</label>
|
||||
<label class="field__label" for="group-{{ connection.id }}">{{ t("Data group") }}</label>
|
||||
<select class="select" id="group-{{ connection.id }}" name="data_group_id">
|
||||
{% for group in data_group_choices %}
|
||||
<option value="{{ group.id }}"
|
||||
{{ 'selected' if (connection.data_group_id or 'default') == group.id }}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">{{ t("Its models read only this group's memories, notes, skills, knowledge and reports, and a chat started on one of them stays in it. Moving a connection does not move any data: it starts reading the other group.") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="unload-{{ connection.id }}">{{ t("Unload URL") }}</label>
|
||||
<div class="btn-row">
|
||||
<input class="input input--mono" id="unload-{{ connection.id }}" name="unload_url"
|
||||
value="{{ connection.unload_url }}" placeholder="No unload call"
|
||||
value="{{ connection.unload_url }}" placeholder="{{ t('No unload call') }}"
|
||||
style="flex: 1; min-width: 0">
|
||||
<select class="select" name="unload_method" aria-label="How to ask it to unload" style="flex: none">
|
||||
<option value="POST" {{ 'selected' if connection.unload_method != 'GET' }}>POST</option>
|
||||
<option value="GET" {{ 'selected' if connection.unload_method == 'GET' }}>GET</option>
|
||||
<select class="select" name="unload_method" aria-label="{{ t('How to ask it to unload') }}" style="flex: none">
|
||||
<option value="POST" {{ 'selected' if connection.unload_method != 'GET' }}>{{ t("POST") }}</option>
|
||||
<option value="GET" {{ 'selected' if connection.unload_method == 'GET' }}>{{ t("GET") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
@@ -92,10 +108,10 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="headers-{{ connection.id }}">Extra headers</label>
|
||||
<label class="field__label" for="headers-{{ connection.id }}">{{ t("Extra headers") }}</label>
|
||||
<textarea class="textarea input--mono" id="headers-{{ connection.id }}"
|
||||
name="extra_headers" rows="2"
|
||||
placeholder="HTTP-Referer: https://example.org">{% for name, value in (connection.extra_headers_json or {}).items() %}{{ name }}: {{ value }}
|
||||
placeholder="{{ t('HTTP-Referer: https://example.org') }}">{% for name, value in (connection.extra_headers_json or {}).items() %}{{ name }}: {{ value }}
|
||||
{% endfor %}</textarea>
|
||||
<p class="field__hint">
|
||||
One <code>Name: value</code> per line, sent with every request to this
|
||||
@@ -109,7 +125,7 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if connection.enabled }}>
|
||||
<span>Enabled — its models are offered in chats</span>
|
||||
<span>{{ t("Enabled — its models are offered in chats") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,76 +29,89 @@
|
||||
{% include "partials/_sidebar_close.html" %}
|
||||
</header>
|
||||
|
||||
<nav class="sidebar__scroll" aria-label="Administration">
|
||||
<nav class="sidebar__scroll" aria-label="{{ t('Administration') }}">
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Administration</div>
|
||||
<a class="nav-item {{ 'is-active' if section == 'general' }}" href="/admin/general">
|
||||
{{ icon("gear", "icon--sm") }}
|
||||
<span class="nav-item__label">General</span>
|
||||
<span class="nav-item__label">{{ t("General") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'customization' }}"
|
||||
href="/admin/customization">
|
||||
{{ icon("sun", "icon--sm") }}
|
||||
<span class="nav-item__label">Customization</span>
|
||||
<span class="nav-item__label">{{ t("Customization") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
|
||||
href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Connections</span>
|
||||
<span class="nav-item__label">{{ t("Connections") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="nav-item__label">Models</span>
|
||||
<span class="nav-item__label">{{ t("Models") }}</span>
|
||||
</a>
|
||||
{# Beside Connections and Models because it is about them: which
|
||||
provider's models may read which part of the people's data. #}
|
||||
<a class="nav-item {{ 'is-active' if section == 'data-groups' }}" href="/admin/data-groups">
|
||||
{{ icon("shield", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ t("Data groups") }}</span>
|
||||
</a>
|
||||
{# Its own entry rather than a card on Agents, where it started. Sitting
|
||||
there made it read as an agent-chat feature -- which is what the owner
|
||||
took it for, reasonably, since that is what the page is called. #}
|
||||
<a class="nav-item {{ 'is-active' if section == 'crowd' }}" href="/admin/crowd">
|
||||
{{ icon("users", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
|
||||
{{ icon("speaker", "icon--sm") }}
|
||||
<span class="nav-item__label">Audio</span>
|
||||
<span class="nav-item__label">{{ t("Audio") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'search' }}" href="/admin/search">
|
||||
{{ icon("globe", "icon--sm") }}
|
||||
<span class="nav-item__label">Web search</span>
|
||||
<span class="nav-item__label">{{ t("Web search") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'images' }}" href="/admin/images">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="nav-item__label">Image generation</span>
|
||||
<span class="nav-item__label">{{ t("Image generation") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'extraction' }}"
|
||||
href="/admin/extraction">
|
||||
{{ icon("file-text", "icon--sm") }}
|
||||
<span class="nav-item__label">Extraction</span>
|
||||
<span class="nav-item__label">{{ t("Extraction") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'tools' }}" href="/admin/tools">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
<span class="nav-item__label">{{ t("Tools") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'agents' }}" href="/admin/agents">
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Agents</span>
|
||||
<span class="nav-item__label">{{ t("Agents") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'schedules' }}" href="/admin/schedules">
|
||||
{{ icon("clock", "icon--sm") }}
|
||||
<span class="nav-item__label">Scheduling</span>
|
||||
<span class="nav-item__label">{{ t("Scheduling") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'mcp' }}" href="/admin/mcp">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">MCP servers</span>
|
||||
<span class="nav-item__label">{{ t("MCP servers") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'prompts' }}" href="/admin/prompts">
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Prompts</span>
|
||||
<span class="nav-item__label">{{ t("Prompts") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'suggestions' }}"
|
||||
href="/admin/suggestions">
|
||||
{{ icon("star", "icon--sm") }}
|
||||
<span class="nav-item__label">Suggestions</span>
|
||||
<span class="nav-item__label">{{ t("Suggestions") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">Users</span>
|
||||
<span class="nav-item__label">{{ t("Users") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'updates' }}" href="/admin/updates">
|
||||
{{ icon("refresh", "icon--sm") }}
|
||||
<span class="nav-item__label">Updates</span>
|
||||
<span class="nav-item__label">{{ t("Updates") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'groups' }}" href="/admin/groups">
|
||||
{{ icon("users", "icon--sm") }}
|
||||
@@ -111,7 +124,7 @@
|
||||
<div class="sidebar__footer">
|
||||
<a class="nav-item" href="/chat">
|
||||
{{ icon("chat", "icon--sm") }}
|
||||
<span class="nav-item__label">Back to chats</span>
|
||||
<span class="nav-item__label">{{ t("Back to chats") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -122,7 +135,7 @@
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="{{ t('Switch theme') }}">
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<div class="model-row__title">
|
||||
<a class="model-row__name" href="/admin/mcp/{{ server.id }}/edit">{{ server.name }}</a>
|
||||
<span class="badge">{{ tool_count }} tool{{ '' if tool_count == 1 else 's' }}</span>
|
||||
{% if not server.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||
{% if not server.public %}<span class="badge">restricted</span>{% endif %}
|
||||
{% if server.allow_private %}<span class="badge">private network</span>{% endif %}
|
||||
{% if not server.enabled %}<span class="badge badge--danger">{{ t("disabled") }}</span>{% endif %}
|
||||
{% if not server.public %}<span class="badge">{{ t("restricted") }}</span>{% endif %}
|
||||
{% if server.allow_private %}<span class="badge">{{ t("private network") }}</span>{% endif %}
|
||||
{% if server.protocol_version %}
|
||||
<span class="badge badge--leaf">MCP {{ server.protocol_version }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
<div class="card__header">
|
||||
<h2 class="card__title">
|
||||
{{ fragment.label }}
|
||||
{% if overridden %}<span class="badge badge--leaf">edited</span>{% endif %}
|
||||
{% if overridden %}<span class="badge badge--leaf">{{ t("edited") }}</span>{% endif %}
|
||||
{% for family in fragment.families %}<span class="badge">{{ family }}</span>{% endfor %}
|
||||
{% if fragment.when_tools %}<span class="badge">with tools</span>{% endif %}
|
||||
{% if fragment.when_tools %}<span class="badge">{{ t("with tools") }}</span>{% endif %}
|
||||
</h2>
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-post="/admin/prompts/default"
|
||||
hx-vals='{"key": "{{ fragment.key }}"}'
|
||||
hx-target="#{{ field_id }}" hx-swap="outerHTML"
|
||||
hx-confirm="Put the built-in wording back in this box? Your edit is lost, but nothing is saved until you press Save settings.">
|
||||
Use default
|
||||
</button>
|
||||
hx-confirm="Put the built-in wording back in this box? Your edit is lost, but nothing is saved until you press Save settings.">{{ t("Use default") }}</button>
|
||||
</div>
|
||||
|
||||
{% if fragment.hint %}<p class="card__lede">{{ fragment.hint }}</p>{% endif %}
|
||||
|
||||
@@ -27,15 +27,10 @@
|
||||
</p>
|
||||
|
||||
{% if title_prompt %}
|
||||
<h3 class="admin-section-title">Chat title request</h3>
|
||||
<p class="card__lede">
|
||||
Sent on its own after the first reply, not as part of any conversation.
|
||||
</p>
|
||||
<h3 class="admin-section-title">{{ t("Chat title request") }}</h3>
|
||||
<p class="card__lede">{{ t("Sent on its own after the first reply, not as part of any conversation.") }}</p>
|
||||
<pre class="prompt-preview"><code>{{ title_prompt }}</code></pre>
|
||||
{% else %}
|
||||
<h3 class="admin-section-title">Chat title request</h3>
|
||||
<p class="card__lede">
|
||||
Empty, so no model is asked to name a chat. Chats are named from the first
|
||||
thing said in them.
|
||||
</p>
|
||||
<h3 class="admin-section-title">{{ t("Chat title request") }}</h3>
|
||||
<p class="card__lede">{{ t("Empty, so no model is asked to name a chat. Chats are named from the first thing said in them.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
{% endif %}
|
||||
|
||||
{% if outcome %}
|
||||
<p class="field__hint">This is what the model would read back:</p>
|
||||
<p class="field__hint">{{ t("This is what the model would read back:") }}</p>
|
||||
<pre class="tool-result__text">{{ outcome.content }}</pre>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
An <strong>Agent</strong> chat can read files, write files and run commands on
|
||||
An <strong>{{ t("Agent") }}</strong> chat can read files, write files and run commands on
|
||||
a machine reached over SSH. Nothing runs on this server. People add their own
|
||||
connections under <strong>Connections</strong>; what you decide here is
|
||||
connections under <strong>{{ t("Connections") }}</strong>; what you decide here is
|
||||
whether the feature exists and what one reply may spend.
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
whatever host somebody points a connection at. A container built for the
|
||||
job is a very different thing from a key to a live server, and {{ brand.name }}
|
||||
cannot tell them apart. What a model reads — a web page, a file, the output
|
||||
of the last command — is untrusted, and in <strong>Auto</strong> mode
|
||||
of the last command — is untrusted, and in <strong>{{ t("Auto") }}</strong> mode
|
||||
nothing stands between that and a command running.
|
||||
</span>
|
||||
</div>
|
||||
@@ -30,17 +30,17 @@
|
||||
{% endif %}
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/agents" class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Switch</h2>
|
||||
<h2 class="card__title">{{ t("Switch") }}</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||||
<span>Allow agent chats</span>
|
||||
<span>{{ t("Allow agent chats") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off, nobody can start one and no agent tool is offered, whatever
|
||||
@@ -49,13 +49,13 @@
|
||||
</p>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
People also need the <strong>Run commands</strong> permission, a model
|
||||
flagged <strong>Agent execution</strong>, and a connection of their own.
|
||||
People also need the <strong>{{ t("Run commands") }}</strong> permission, a model
|
||||
flagged <strong>{{ t("Agent execution") }}</strong>, and a connection of their own.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Connections to this machine</h2>
|
||||
<h2 class="card__title">{{ t("Connections to this machine") }}</h2>
|
||||
<p class="card__lede">
|
||||
Agent chats reach a machine over SSH, and the point of that is that it is
|
||||
not this one — nothing runs on the host holding the database and the
|
||||
@@ -76,11 +76,11 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="loopback_port">The allowed port</label>
|
||||
<label class="field__label" for="loopback_port">{{ t("The allowed port") }}</label>
|
||||
<input class="input" type="number" id="loopback_port" name="loopback_port"
|
||||
min="0" max="65535" step="1" value="{{ values.loopback_port or 0 }}">
|
||||
<p class="field__hint">
|
||||
Only read when the position above is <strong>Only on one port</strong>.
|
||||
Only read when the position above is <strong>{{ t("Only on one port") }}</strong>.
|
||||
Port 22 is refused whatever is typed here — that one is this host's own
|
||||
sshd, not a container that published its port on the loopback interface.
|
||||
</p>
|
||||
@@ -88,11 +88,8 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The modes</h2>
|
||||
<p class="field__hint">
|
||||
Set per chat and switchable at any time. This is what each one means; the
|
||||
two lists below adjust them.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("The modes") }}</h2>
|
||||
<p class="field__hint">{{ t("Set per chat and switchable at any time. This is what each one means; the two lists below adjust them.") }}</p>
|
||||
<dl class="mode-list">
|
||||
{% for value, label, hint in modes %}
|
||||
<div class="mode-list__row">
|
||||
@@ -104,9 +101,9 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What never needs asking</h2>
|
||||
<h2 class="card__title">{{ t("What never needs asking") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="allow_default">Always allow</label>
|
||||
<label class="field__label" for="allow_default">{{ t("Always allow") }}</label>
|
||||
<textarea class="textarea input--mono" id="allow_default" name="allow_default" rows="5"
|
||||
spellcheck="false">{{ allow_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
@@ -120,18 +117,18 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What always needs asking</h2>
|
||||
<h2 class="card__title">{{ t("What always needs asking") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="deny_default">Always ask</label>
|
||||
<label class="field__label" for="deny_default">{{ t("Always ask") }}</label>
|
||||
<textarea class="textarea input--mono" id="deny_default" name="deny_default" rows="5"
|
||||
spellcheck="false">{{ deny_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
Checked before everything, including <strong>Auto</strong>. Treat it as
|
||||
Checked before everything, including <strong>{{ t("Auto") }}</strong>. Treat it as
|
||||
a guard against an accident rather than against an adversary:
|
||||
<code>rm -rf /*</code> here does not stop <code>/bin/rm -rf /</code>, and
|
||||
nothing pattern-shaped could. The same limit as above applies, and it
|
||||
cuts the other way here: a command line that runs more than one thing
|
||||
matches none of these, so in <strong>Auto</strong>
|
||||
matches none of these, so in <strong>{{ t("Auto") }}</strong>
|
||||
<code>shutdown -h now</code> asks and <code>shutdown -h now &</code>
|
||||
runs. Anything that must never happen belongs on the far side, in that
|
||||
account’s own permissions.
|
||||
@@ -140,40 +137,34 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What one command may spend</h2>
|
||||
<h2 class="card__title">{{ t("What one command may spend") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_timeout">Timeout (seconds)</label>
|
||||
<label class="field__label" for="default_timeout">{{ t("Timeout (seconds)") }}</label>
|
||||
<input class="input" id="default_timeout" name="default_timeout"
|
||||
value="{{ values.default_timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_timeout">Longest a command may ask for</label>
|
||||
<label class="field__label" for="max_timeout">{{ t("Longest a command may ask for") }}</label>
|
||||
<input class="input" id="max_timeout" name="max_timeout"
|
||||
value="{{ values.max_timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_output_bytes">Most output to keep</label>
|
||||
<label class="field__label" for="max_output_bytes">{{ t("Most output to keep") }}</label>
|
||||
<input class="input" id="max_output_bytes" name="max_output_bytes"
|
||||
value="{{ values.max_output_bytes }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Characters. The rest is cut off and the model is told so.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Characters. The rest is cut off and the model is told so.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Background commands</h2>
|
||||
<p class="card__lede">
|
||||
A command that would outlast its timeout can be left running instead of
|
||||
killed — detached on the far side, checked on later. It is how a long
|
||||
install, build or download becomes possible at all.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Background commands") }}</h2>
|
||||
<p class="card__lede">{{ t("A command that would outlast its timeout can be left running instead of killed — detached on the far side, checked on later. It is how a long install, build or download becomes possible at all.") }}</p>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="background_enabled"
|
||||
{{ 'checked' if values.background_enabled }}>
|
||||
<span>Allow commands to run in the background</span>
|
||||
<span>{{ t("Allow commands to run in the background") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off means byte-for-byte the old behaviour: a command that hits its
|
||||
@@ -188,63 +179,44 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="background_on_timeout"
|
||||
{{ 'checked' if values.background_on_timeout }}>
|
||||
<span>Keep a timed-out command running instead of killing it</span>
|
||||
<span>{{ t("Keep a timed-out command running instead of killing it") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off leaves the timeout a hard stop; the model can still choose to
|
||||
background a command up front.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Off leaves the timeout a hard stop; the model can still choose to background a command up front.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="background_notify"
|
||||
{{ 'checked' if values.background_notify }}>
|
||||
<span>Wake the model when a background job finishes</span>
|
||||
<span>{{ t("Wake the model when a background job finishes") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
On, a finished job starts (or joins) a reply carrying its result. Off,
|
||||
the model only sees it the next time it runs of its own accord.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("On, a finished job starts (or joins) a reply carrying its result. Off, the model only sees it the next time it runs of its own accord.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="background_max_jobs">Most jobs watched at once</label>
|
||||
<label class="field__label" for="background_max_jobs">{{ t("Most jobs watched at once") }}</label>
|
||||
<input class="input" id="background_max_jobs" name="background_max_jobs"
|
||||
value="{{ values.background_max_jobs }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Each is a periodic reconnect to the machine. Jobs past this still run;
|
||||
they are simply not watched, and the model is not woken for them.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Each is a periodic reconnect to the machine. Jobs past this still run; they are simply not watched, and the model is not woken for them.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What one reply may spend</h2>
|
||||
<p class="field__hint">
|
||||
Four separate bounds, because they fail differently: the clock stops one
|
||||
slow command eating an afternoon, tool output stops a model filling its own
|
||||
context with build logs and having no room to answer, written tokens stop
|
||||
one that keeps going, and the step count is a backstop against a runaway.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("What one reply may spend") }}</h2>
|
||||
<p class="field__hint">{{ t("Four separate bounds, because they fail differently: the clock stops one slow command eating an afternoon, tool output stops a model filling its own context with build logs and having no room to answer, written tokens stop one that keeps going, and the step count is a backstop against a runaway.") }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_completion_tokens">
|
||||
Most a reply may write
|
||||
</label>
|
||||
<label class="field__label" for="max_completion_tokens">{{ t("Most a reply may write") }}</label>
|
||||
<input class="input" id="max_completion_tokens" name="max_completion_tokens"
|
||||
value="{{ values.max_completion_tokens }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
In tokens, across every round of one reply. This is the bound that
|
||||
normally ends a long piece of work. Zero means no ceiling.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("In tokens, across every round of one reply. This is the bound that normally ends a long piece of work. Zero means no ceiling.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_wall_seconds">Longest a reply may take</label>
|
||||
<label class="field__label" for="max_wall_seconds">{{ t("Longest a reply may take") }}</label>
|
||||
<input class="input" id="max_wall_seconds" name="max_wall_seconds"
|
||||
value="{{ values.max_wall_seconds }}" inputmode="numeric">
|
||||
<p class="field__hint">Time spent waiting for you to answer does not count.</p>
|
||||
<p class="field__hint">{{ t("Time spent waiting for you to answer does not count.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_total_output_bytes">Most output across a reply</label>
|
||||
<label class="field__label" for="max_total_output_bytes">{{ t("Most output across a reply") }}</label>
|
||||
<input class="input" id="max_total_output_bytes" name="max_total_output_bytes"
|
||||
value="{{ values.max_total_output_bytes }}" inputmode="numeric">
|
||||
</div>
|
||||
@@ -252,61 +224,44 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="nudge_unfinished"
|
||||
{{ 'checked' if values.nudge_unfinished }}>
|
||||
<span>Ask it to carry on when it stops with tasks outstanding</span>
|
||||
<span>{{ t("Ask it to carry on when it stops with tasks outstanding") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Only ever against a plan, and only while tasks on it are still open —
|
||||
that is the one thing there is to be objectively wrong about. A reply
|
||||
with no plan that says it has finished is believed. It is asked at most
|
||||
twice in a row, and if it stops a third time that is recorded in the
|
||||
transcript rather than argued with.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Only ever against a plan, and only while tasks on it are still open — that is the one thing there is to be objectively wrong about. A reply with no plan that says it has finished is believed. It is asked at most twice in a row, and if it stops a third time that is recorded in the transcript rather than argued with.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_steps">Most rounds of tool calls</label>
|
||||
<label class="field__label" for="max_steps">{{ t("Most rounds of tool calls") }}</label>
|
||||
<input class="input" id="max_steps" name="max_steps"
|
||||
value="{{ values.max_steps }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
A backstop, not a working budget. An agent reply is meant to run until
|
||||
the task is done, so a number low enough to be what stops it is a number
|
||||
that stops it halfway. Use the token ceiling above for a real limit.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("A backstop, not a working budget. An agent reply is meant to run until the task is done, so a number low enough to be what stops it is a number that stops it halfway. Use the token ceiling above for a real limit.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Asking you things</h2>
|
||||
<h2 class="card__title">{{ t("Asking you things") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="approval_timeout">How long a question waits</label>
|
||||
<label class="field__label" for="approval_timeout">{{ t("How long a question waits") }}</label>
|
||||
<input class="input" id="approval_timeout" name="approval_timeout"
|
||||
value="{{ values.approval_timeout }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Seconds. After this the reply carries on without an answer and says so.
|
||||
At least a minute, whatever is typed here.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Seconds. After this the reply carries on without an answer and says so. At least a minute, whatever is typed here.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="ask_free_text" value="true"
|
||||
{{ 'checked' if values.ask_free_text }}>
|
||||
<span>Let people write their own answer</span>
|
||||
<span>{{ t("Let people write their own answer") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
When a model asks a question it can offer answers to pick from, and by
|
||||
default a box to write something else. Turn this off if you would rather
|
||||
nobody typed free text into a prompt a model composed.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("When a model asks a question it can offer answers to pick from, and by default a box to write something else. Turn this off if you would rather nobody typed free text into a prompt a model composed.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The terminal</h2>
|
||||
<h2 class="card__title">{{ t("The terminal") }}</h2>
|
||||
<p class="field__hint">
|
||||
A panel beside an agent chat holding an interactive shell on that chat's
|
||||
own connection. What somebody types there is <em>theirs</em>: the modes and
|
||||
own connection. What somebody types there is <em>{{ t("theirs") }}</em>: the modes and
|
||||
the two lists above govern the model, not the person at the keyboard, who
|
||||
could open the same shell with an ssh client. The model cannot see the
|
||||
panel; sending it something is a button they press.
|
||||
@@ -316,47 +271,45 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="terminal_enabled" value="true"
|
||||
{{ 'checked' if values.terminal_enabled }}>
|
||||
<span>Allow the terminal panel</span>
|
||||
<span>{{ t("Allow the terminal panel") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
People also need the <strong>Open a terminal</strong> permission.
|
||||
People also need the <strong>{{ t("Open a terminal") }}</strong> permission.
|
||||
{{ terminal_count }} shell{{ '' if terminal_count == 1 else 's' }} open right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="terminal_idle_timeout">Close a shell after</label>
|
||||
<label class="field__label" for="terminal_idle_timeout">{{ t("Close a shell after") }}</label>
|
||||
<input class="input" id="terminal_idle_timeout" name="terminal_idle_timeout"
|
||||
value="{{ values.terminal_idle_timeout }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Seconds with nobody watching <em>and</em> nothing typed. Closing the
|
||||
Seconds with nobody watching <em>{{ t("and") }}</em> nothing typed. Closing the
|
||||
panel does not end the session — a build carries on and is still there
|
||||
on the way back — so this is what eventually ends one.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="terminal_max_sessions">Most shells at once</label>
|
||||
<label class="field__label" for="terminal_max_sessions">{{ t("Most shells at once") }}</label>
|
||||
<input class="input" id="terminal_max_sessions" name="terminal_max_sessions"
|
||||
value="{{ values.terminal_max_sessions }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="terminal_max_per_user">Most shells per person</label>
|
||||
<label class="field__label" for="terminal_max_per_user">{{ t("Most shells per person") }}</label>
|
||||
<input class="input" id="terminal_max_per_user" name="terminal_max_per_user"
|
||||
value="{{ values.terminal_max_per_user }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
One per chat. Each holds an SSH connection open on the far machine.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("One per chat. Each holds an SSH connection open on the far machine.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="terminal_integration"
|
||||
{{ 'checked' if values.terminal_integration }}>
|
||||
<span>Mark where commands begin and end</span>
|
||||
<span>{{ t("Mark where commands begin and end") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Gives bash and zsh the same invisible markers VS Code and WezTerm use,
|
||||
so <strong>Copy</strong>, <strong>Send</strong> and the automatic
|
||||
so <strong>{{ t("Copy") }}</strong>, <strong>{{ t("Send") }}</strong> and the automatic
|
||||
toggle know which output belongs to which command. Written by the shell
|
||||
into a temporary file it deletes itself, and any other shell is started
|
||||
exactly as it was before. Off means those buttons fall back to copying
|
||||
@@ -366,7 +319,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">The project directory</h2>
|
||||
<h2 class="section-title">{{ t("The project directory") }}</h2>
|
||||
<p class="muted">
|
||||
A listing of the directory a chat works in, so a reply does not spend its
|
||||
first rounds finding out what is there — and so files can be attached by
|
||||
@@ -379,20 +332,17 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="index_enabled"
|
||||
{{ 'checked' if values.index_enabled }}>
|
||||
<span>List the project directory</span>
|
||||
<span>{{ t("List the project directory") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off means no listing is built at all, and the file picker offers only
|
||||
what is in the library.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Off means no listing is built at all, and the file picker offers only what is in the library.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="index_chars">Characters of it in the prompt</label>
|
||||
<label class="field__label" for="index_chars">{{ t("Characters of it in the prompt") }}</label>
|
||||
<input class="input" id="index_chars" name="index_chars"
|
||||
value="{{ values.index_chars }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
This is spent on <em>every</em> request in an agent chat, so it is a
|
||||
This is spent on <em>{{ t("every") }}</em> request in an agent chat, so it is a
|
||||
budget rather than a limit: directories that will not fit are shown as
|
||||
a count and the model is told to look inside them itself.
|
||||
<strong>0</strong> keeps the listing for the file picker and puts none
|
||||
@@ -404,7 +354,7 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="instructions_enabled"
|
||||
{{ 'checked' if values.instructions_enabled }}>
|
||||
<span>Read the project's own instructions</span>
|
||||
<span>{{ t("Read the project's own instructions") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Looks for <code>AGENTS.md</code> or <code>CLAUDE.md</code> in the root
|
||||
@@ -412,14 +362,14 @@
|
||||
the conventions of the project it is working in. The file is written by
|
||||
whoever works on that project, so it is treated as untrusted: it can say
|
||||
how to work, and cannot grant permission for anything. The exact wording
|
||||
around it is the <em>The project's own instructions</em> fragment on
|
||||
around it is the <em>{{ t("The project's own instructions") }}</em> fragment on
|
||||
<a href="/admin/prompts">Prompts</a>, and clearing that fragment removes
|
||||
the only path by which the file reaches a model.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="instructions_chars">Characters of it to use</label>
|
||||
<label class="field__label" for="instructions_chars">{{ t("Characters of it to use") }}</label>
|
||||
<input class="input" id="instructions_chars" name="instructions_chars"
|
||||
value="{{ values.instructions_chars }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
@@ -430,7 +380,7 @@
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -445,107 +395,14 @@
|
||||
button was pressed, which is what keeps each group's save handler writing one
|
||||
key.
|
||||
#}
|
||||
{# A third settings group on this page, saved by its own form -- the reason the
|
||||
Helpers card gives. A crowd is not an agent-chat feature either, but this is the
|
||||
page somebody opens to find out what one turn may set going. #}
|
||||
<form method="post" action="/admin/agents/crowd" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">A crowd</h2>
|
||||
<p class="field__hint">
|
||||
A chat can have more than one model in it. The chat's own model answers, then
|
||||
each of the others in turn; then the order runs <strong>backwards</strong>,
|
||||
each one asked whether it disagrees with anything; and it ends back at the
|
||||
first, which either closes or sends them round again.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
One turn costs <strong>models × rounds × 2 − 1</strong> replies — four
|
||||
models over two rounds is fifteen — and on a single local endpoint every
|
||||
change of speaker also loads a different model. Larger crowds of smaller
|
||||
models, and sometimes of bigger ones, start going round in circles: that is
|
||||
what the round limit is for, and it is a limit ordinary work will reach
|
||||
rather than a runaway backstop.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if crowd.enabled }}>
|
||||
<span>Let a chat have a crowd</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off by default. With it on, each chat's settings panel offers the other
|
||||
models; a chat with none ticked behaves exactly as it always has.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_models">Most models besides the chat's own</label>
|
||||
<input class="input" id="crowd_max_models" name="max_models"
|
||||
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
|
||||
<p class="field__hint">
|
||||
Four is already eight replies a turn at one round each. More voices past
|
||||
that tend to repeat each other rather than add anything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_rounds">Most rounds</label>
|
||||
<input class="input" id="crowd_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
|
||||
<p class="field__hint">
|
||||
A round is out and back. Two gives the first model one chance to change its
|
||||
mind after hearing the objections, which is the point of the whole thing;
|
||||
three is where going in circles starts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_wall_seconds">Longest a turn may take</label>
|
||||
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
|
||||
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
|
||||
<p class="field__hint">
|
||||
Across every speaker, not each. A member whose endpoint has stalled cannot
|
||||
then hold the round open all afternoon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="collapse_agreement" value="true"
|
||||
{{ 'checked' if crowd.collapse_agreement }}>
|
||||
<span>Fold away a short "I agree" on the way back</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
The disagreements are what a crowd is for; a column of bubbles saying
|
||||
nothing is what makes somebody switch it off. The text is still there
|
||||
behind a disclosure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/agents/subagents" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Helpers</h2>
|
||||
<h2 class="card__title">{{ t("Helpers") }}</h2>
|
||||
<p class="field__hint">{{ t("A reply can hand a self-contained piece of work to a second model that runs on its own and reports back — several at once, which is what makes research fan out instead of queueing. This applies to ordinary chats as much as agent ones.") }}</p>
|
||||
<p class="field__hint">
|
||||
A reply can hand a self-contained piece of work to a second model that
|
||||
runs on its own and reports back — several at once, which is what makes
|
||||
research fan out instead of queueing. This applies to ordinary chats as
|
||||
much as agent ones.
|
||||
</p>
|
||||
<p class="field__hint">
|
||||
<strong>Asking another model a question uses the same switch and the same
|
||||
allowance below</strong>, because it costs the same thing: one reply
|
||||
<strong>{{ t("Asking another model a question uses the same switch and the same allowance below") }}</strong>, because it costs the same thing: one reply
|
||||
setting another reply going. Which people may do it is a separate
|
||||
permission — <strong>Ask another model</strong> — and which models may is a
|
||||
permission — <strong>{{ t("Ask another model") }}</strong> — and which models may is a
|
||||
switch on each model's own page.
|
||||
</p>
|
||||
|
||||
@@ -554,9 +411,9 @@
|
||||
<span>
|
||||
A helper cannot ask anybody anything, so nothing in its chat can stop
|
||||
for approval. It therefore gets only what this chat could already do
|
||||
<em>without</em> asking: it reads, it searches, and on a machine it runs
|
||||
<em>{{ t("without") }}</em> asking: it reads, it searches, and on a machine it runs
|
||||
a short fixed list of read-only commands and nothing else, in every mode
|
||||
including <strong>Auto</strong>. It cannot send helpers of its own.
|
||||
including <strong>{{ t("Auto") }}</strong>. It cannot send helpers of its own.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -564,11 +421,11 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if subagents.enabled }}>
|
||||
<span>Let a model delegate</span>
|
||||
<span>{{ t("Let a model delegate") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
People also need the <strong>Delegate to a helper</strong> permission,
|
||||
and the model needs the <strong>Tools</strong> capability. Off by
|
||||
People also need the <strong>{{ t("Delegate to a helper") }}</strong> permission,
|
||||
and the model needs the <strong>{{ t("Tools") }}</strong> capability. Off by
|
||||
default: a reply that spawns helpers spends model time multiplicatively,
|
||||
and on one local endpoint four at once is four times the queue rather
|
||||
than four times the speed.
|
||||
@@ -576,83 +433,60 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_per_reply">
|
||||
Most helpers one reply may send
|
||||
</label>
|
||||
<label class="field__label" for="sub_max_per_reply">{{ t("Most helpers one reply may send") }}</label>
|
||||
<input class="input" id="sub_max_per_reply" name="max_per_reply"
|
||||
type="number" min="1" max="20" step="1"
|
||||
value="{{ subagents.max_per_reply }}">
|
||||
<p class="field__hint">
|
||||
Fanning out across a handful of independent questions is what this is
|
||||
for. A reply that wants twenty has misread the tool. Questions put to
|
||||
other models count against this same number, so one reply cannot spend
|
||||
the allowance twice.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Fanning out across a handful of independent questions is what this is for. A reply that wants twenty has misread the tool. Questions put to other models count against this same number, so one reply cannot spend the allowance twice.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_concurrent">Running at once, instance-wide</label>
|
||||
<label class="field__label" for="sub_max_concurrent">{{ t("Running at once, instance-wide") }}</label>
|
||||
<input class="input" id="sub_max_concurrent" name="max_concurrent"
|
||||
type="number" min="1" max="50" step="1"
|
||||
value="{{ subagents.max_concurrent }}">
|
||||
<p class="field__hint">
|
||||
Each is a whole generation against the same endpoint the reply that
|
||||
asked for it is waiting on. Past this a model is told to do the work
|
||||
itself rather than made to wait.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Each is a whole generation against the same endpoint the reply that asked for it is waiting on. Past this a model is told to do the work itself rather than made to wait.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_completion_tokens">Most a helper may write</label>
|
||||
<label class="field__label" for="sub_max_completion_tokens">{{ t("Most a helper may write") }}</label>
|
||||
<input class="input" id="sub_max_completion_tokens" name="max_completion_tokens"
|
||||
type="number" min="0" max="5000000" step="1000"
|
||||
value="{{ subagents.max_completion_tokens }}">
|
||||
<p class="field__hint">
|
||||
In tokens, across every round. A helper answers one question, so this
|
||||
should run out well before the reply that asked does. Zero means no
|
||||
ceiling.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("In tokens, across every round. A helper answers one question, so this should run out well before the reply that asked does. Zero means no ceiling.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_wall_seconds">Longest a helper may take</label>
|
||||
<label class="field__label" for="sub_wall_seconds">{{ t("Longest a helper may take") }}</label>
|
||||
<input class="input" id="sub_wall_seconds" name="wall_seconds"
|
||||
type="number" min="30" max="7200" step="30"
|
||||
value="{{ subagents.wall_seconds }}">
|
||||
<p class="field__hint">
|
||||
Seconds. Past it the helper is <em>stopped</em>, not abandoned: what it
|
||||
Seconds. Past it the helper is <em>{{ t("stopped") }}</em>, not abandoned: what it
|
||||
had written is kept and handed back with a note saying it is partial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_rounds">Most rounds of tool calls</label>
|
||||
<label class="field__label" for="sub_max_rounds">{{ t("Most rounds of tool calls") }}</label>
|
||||
<input class="input" id="sub_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="200" step="1"
|
||||
value="{{ subagents.max_rounds }}">
|
||||
<p class="field__hint">
|
||||
A backstop, as it is above. The clock and the token ceiling are what
|
||||
normally end one.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("A backstop, as it is above. The clock and the token ceiling are what normally end one.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="keep_transcript" value="true"
|
||||
{{ 'checked' if subagents.keep_transcript }}>
|
||||
<span>Keep a helper's own chat afterwards</span>
|
||||
<span>{{ t("Keep a helper's own chat afterwards") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off means it is deleted once its answer has been handed over, which is
|
||||
what keeps this cheap to use. Turn it on to work out why one came back
|
||||
with something odd. Kept chats are temporary either way and are swept a
|
||||
day later, and neither appears in anybody's sidebar.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Off means it is deleted once its answer has been handed over, which is what keeps this cheap to use. Turn it on to work out why one came back with something odd. Kept chats are temporary either way and are swept a day later, and neither appears in anybody's sidebar.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,42 +6,34 @@
|
||||
{% block heading %}Audio{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Two endpoints speaking the OpenAI audio API: one that turns speech into text
|
||||
so a message can be dictated, one that reads a reply out. They are configured
|
||||
separately because they usually are separate servers — whisper.cpp and Kokoro,
|
||||
say, or Speaches for both.
|
||||
</p>
|
||||
<p class="admin-lede">{{ t("Two endpoints speaking the OpenAI audio API: one that turns speech into text so a message can be dictated, one that reads a reply out. They are configured separately because they usually are separate servers — whisper.cpp and Kokoro, say, or Speaches for both.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Audio settings saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Audio settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/audio">
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Dictation
|
||||
{% if values.stt_enabled %}<span class="badge badge--success">on</span>
|
||||
{% else %}<span class="badge">off</span>{% endif %}
|
||||
{% if values.stt_enabled %}<span class="badge badge--success">{{ t("on") }}</span>
|
||||
{% else %}<span class="badge">{{ t("off") }}</span>{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
Adds a microphone to the composer. Recordings are sent to this endpoint
|
||||
and never written to disk.
|
||||
</p>
|
||||
<p class="card__lede">{{ t("Adds a microphone to the composer. Recordings are sent to this endpoint and never written to disk.") }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="stt_enabled" value="true"
|
||||
{{ 'checked' if values.stt_enabled }}>
|
||||
<span>Allow messages to be dictated</span>
|
||||
<span>{{ t("Allow messages to be dictated") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="stt-base-url">Base URL</label>
|
||||
<label class="field__label" for="stt-base-url">{{ t("Base URL") }}</label>
|
||||
<input class="input" id="stt-base-url" name="stt_base_url" type="url"
|
||||
value="{{ values.stt_base_url }}" placeholder="http://127.0.0.1:8081">
|
||||
value="{{ values.stt_base_url }}" placeholder="{{ t('http://127.0.0.1:8081') }}">
|
||||
<p class="field__hint">
|
||||
Where <code>/v1/audio/transcriptions</code> lives — whisper.cpp's
|
||||
<code>whisper-server</code>, Speaches, or anything else speaking it.
|
||||
@@ -49,27 +41,27 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="stt-api-key">API key</label>
|
||||
<label class="field__label" for="stt-api-key">{{ t("API key") }}</label>
|
||||
<input class="input" id="stt-api-key" name="stt_api_key" type="password"
|
||||
value="{{ unchanged if masked.stt else '' }}"
|
||||
placeholder="{{ masked.stt or 'None needed for a local server' }}"
|
||||
autocomplete="off">
|
||||
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
|
||||
<p class="field__hint">{{ t("Encrypted at rest. Clear the field to remove it.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="stt-model">Model</label>
|
||||
<label class="field__label" for="stt-model">{{ t("Model") }}</label>
|
||||
<input class="input" id="stt-model" name="stt_model"
|
||||
value="{{ values.stt_model }}" placeholder="whisper-1">
|
||||
value="{{ values.stt_model }}" placeholder="{{ t('whisper-1') }}">
|
||||
<p class="field__hint">
|
||||
A server hosting one model <strong>ignores this</strong> and uses
|
||||
A server hosting one model <strong>{{ t("ignores this") }}</strong> and uses
|
||||
whatever it was started with — <code>whisper-1</code> is then just a
|
||||
label. It only selects anything on a server that hosts several.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="stt-language">Language</label>
|
||||
<label class="field__label" for="stt-language">{{ t("Language") }}</label>
|
||||
<input class="input" id="stt-language" name="stt_language" maxlength="16"
|
||||
value="{{ values.stt_language }}" placeholder="detect">
|
||||
value="{{ values.stt_language }}" placeholder="{{ t('detect') }}">
|
||||
<p class="field__hint">
|
||||
An ISO code such as <code>en</code> or <code>sk</code>. Leave empty to
|
||||
let the server detect it, which is what whisper does best.
|
||||
@@ -89,46 +81,43 @@
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Read aloud
|
||||
{% if values.tts_enabled %}<span class="badge badge--success">on</span>
|
||||
{% else %}<span class="badge">off</span>{% endif %}
|
||||
{% if values.tts_enabled %}<span class="badge badge--success">{{ t("on") }}</span>
|
||||
{% else %}<span class="badge">{{ t("off") }}</span>{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
Adds a speaker button to every reply. Each reader can pick their own voice
|
||||
in their settings; what is chosen here is the default.
|
||||
</p>
|
||||
<p class="card__lede">{{ t("Adds a speaker button to every reply. Each reader can pick their own voice in their settings; what is chosen here is the default.") }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="tts_enabled" value="true"
|
||||
{{ 'checked' if values.tts_enabled }}>
|
||||
<span>Allow replies to be read out</span>
|
||||
<span>{{ t("Allow replies to be read out") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-base-url">Base URL</label>
|
||||
<label class="field__label" for="tts-base-url">{{ t("Base URL") }}</label>
|
||||
<input class="input" id="tts-base-url" name="tts_base_url" type="url"
|
||||
value="{{ values.tts_base_url }}" placeholder="http://127.0.0.1:8880">
|
||||
value="{{ values.tts_base_url }}" placeholder="{{ t('http://127.0.0.1:8880') }}">
|
||||
<p class="field__hint">
|
||||
Where <code>/v1/audio/speech</code> lives — Kokoro-FastAPI, OpenAI, or
|
||||
anything else speaking it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-api-key">API key</label>
|
||||
<label class="field__label" for="tts-api-key">{{ t("API key") }}</label>
|
||||
<input class="input" id="tts-api-key" name="tts_api_key" type="password"
|
||||
value="{{ unchanged if masked.tts else '' }}"
|
||||
placeholder="{{ masked.tts or 'None needed for a local server' }}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-model">Model</label>
|
||||
<label class="field__label" for="tts-model">{{ t("Model") }}</label>
|
||||
<input class="input" id="tts-model" name="tts_model"
|
||||
value="{{ values.tts_model }}" placeholder="tts-1">
|
||||
value="{{ values.tts_model }}" placeholder="{{ t('tts-1') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-voice">Default voice</label>
|
||||
<label class="field__label" for="tts-voice">{{ t("Default voice") }}</label>
|
||||
<select class="select" id="tts-voice" name="tts_voice">
|
||||
{% with selected = values.tts_voice %}
|
||||
{% include "partials/_voice_options.html" %}
|
||||
@@ -143,7 +132,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-format">Format</label>
|
||||
<label class="field__label" for="tts-format">{{ t("Format") }}</label>
|
||||
<select class="select" id="tts-format" name="tts_format">
|
||||
{% for format in formats %}
|
||||
<option value="{{ format }}" {{ 'selected' if format == values.tts_format }}>
|
||||
@@ -153,7 +142,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tts-speed">Speed</label>
|
||||
<label class="field__label" for="tts-speed">{{ t("Speed") }}</label>
|
||||
<input class="input" id="tts-speed" name="tts_speed" type="number"
|
||||
min="0.25" max="4" step="0.05" value="{{ values.tts_speed }}">
|
||||
</div>
|
||||
@@ -163,12 +152,9 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="tts_autoplay" value="true"
|
||||
{{ 'checked' if values.tts_autoplay }}>
|
||||
<span>Read new replies aloud as they finish, by default</span>
|
||||
<span>{{ t("Read new replies aloud as they finish, by default") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Only the starting value for each account — anyone can turn it off in
|
||||
their own settings, and nobody is made to listen.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Only the starting value for each account — anyone can turn it off in their own settings, and nobody is made to listen.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
@@ -180,6 +166,6 @@
|
||||
<div id="audio-test-tts"></div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">{{ t("Save settings") }}</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,30 +17,27 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Add a connection</h2>
|
||||
<h2 class="card__title">{{ t("Add a connection") }}</h2>
|
||||
<form method="post" action="/admin/connections" >
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||
<label class="field__label" for="new-name">{{ t("Name") }}</label>
|
||||
<input class="input" id="new-name" name="name" required placeholder="{{ t('Local LM Studio') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-url">Base URL</label>
|
||||
<label class="field__label" for="new-url">{{ t("Base URL") }}</label>
|
||||
<input class="input input--mono" id="new-url" name="base_url" required
|
||||
placeholder="http://localhost:1234/v1">
|
||||
placeholder="{{ t('http://localhost:1234/v1') }}">
|
||||
<p class="field__hint">
|
||||
With or without the trailing <code>/v1</code> — both are accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-key">API key</label>
|
||||
<label class="field__label" for="new-key">{{ t("API key") }}</label>
|
||||
<input class="input input--mono" id="new-key" name="api_key" type="password"
|
||||
autocomplete="off" placeholder="sk-…">
|
||||
<p class="field__hint">
|
||||
Encrypted before it is stored, and never sent back to the browser.
|
||||
Leave empty for endpoints that need no key.
|
||||
</p>
|
||||
autocomplete="off" placeholder="{{ t('sk-…') }}">
|
||||
<p class="field__hint">{{ t("Encrypted before it is stored, and never sent back to the browser. Leave empty for endpoints that need no key.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||
@@ -59,9 +56,7 @@
|
||||
{% if not connections %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
Nothing configured yet. Add a connection above and its models appear here.
|
||||
</p>
|
||||
<p class="empty__text">{{ t("Nothing configured yet. Add a connection above and its models appear here.") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "crowd" %}
|
||||
|
||||
{% block title %}A crowd - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}A crowd{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
{#
|
||||
Its own page rather than a card on Agents, which is where it shipped in 1.6.0.
|
||||
Sitting there made it read as an agent-chat feature -- the owner took it for one,
|
||||
reasonably, because that is what the page is called -- and a crowd has nothing to
|
||||
do with agent chats: it works in any conversation.
|
||||
#}
|
||||
<p class="admin-lede">{{ t("Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/crowd" class="form-grid">
|
||||
<section class="card">
|
||||
<p class="field__hint">
|
||||
A chat can have more than one model in it. The chat's own model answers, then
|
||||
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
|
||||
each one asked whether it disagrees with anything; and it ends back at the
|
||||
first, which either closes or sends them round again.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
|
||||
models over two rounds is fifteen — and on a single local endpoint every
|
||||
change of speaker also loads a different model. Larger crowds of smaller
|
||||
models, and sometimes of bigger ones, start going round in circles: that is
|
||||
what the round limit is for, and it is a limit ordinary work will reach
|
||||
rather than a runaway backstop.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if crowd.enabled }}>
|
||||
<span>{{ t("Let a chat have a crowd") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
|
||||
<input class="input" id="crowd_max_models" name="max_models"
|
||||
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
|
||||
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
|
||||
<input class="input" id="crowd_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
|
||||
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
|
||||
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
|
||||
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
|
||||
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="collapse_agreement" value="true"
|
||||
{{ 'checked' if crowd.collapse_agreement }}>
|
||||
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
What this installation is called and what it looks like. Every box below ships
|
||||
with a default; leaving one alone means it is <em>not</em> stored, so a later
|
||||
with a default; leaving one alone means it is <em>{{ t("not") }}</em> stored, so a later
|
||||
release can still improve the wording. Clearing a box gives you the shipped
|
||||
version back rather than nothing.
|
||||
</p>
|
||||
@@ -24,38 +24,38 @@
|
||||
<form method="post" action="/admin/customization/identity" enctype="multipart/form-data"
|
||||
class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Identity</h2>
|
||||
<h2 class="card__title">{{ t("Identity") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="instance-name">Name</label>
|
||||
<label class="field__label" for="instance-name">{{ t("Name") }}</label>
|
||||
<input class="input" id="instance-name" name="instance_name"
|
||||
value="{{ values.instance_name }}" maxlength="120" placeholder="LLeMbas">
|
||||
value="{{ values.instance_name }}" maxlength="120" placeholder="{{ t('LLeMbas') }}">
|
||||
<p class="field__hint">
|
||||
Shown in the sidebar, in every page title, in the launcher when this is
|
||||
installed as an app, and to the model — it is told which installation it
|
||||
is answering in. Empty means <strong>LLeMbas</strong>.
|
||||
is answering in. Empty means <strong>{{ t("LLeMbas") }}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="tagline">Tagline</label>
|
||||
<label class="field__label" for="tagline">{{ t("Tagline") }}</label>
|
||||
<input class="input" id="tagline" name="tagline"
|
||||
value="{{ values.tagline }}" maxlength="200">
|
||||
<p class="field__hint">
|
||||
One line, used in the page description. The sign-in page has a line of
|
||||
its own under <strong>Wording</strong> below.
|
||||
its own under <strong>{{ t("Wording") }}</strong> below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="logo">Logo</label>
|
||||
<label class="field__label" for="logo">{{ t("Logo") }}</label>
|
||||
{% if current.logo_path %}
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
<img src="/branding/{{ current.logo_path }}" alt="" width="48" height="48"
|
||||
style="vertical-align: middle; border-radius: var(--radius)">
|
||||
<label class="checkbox" style="display: inline-flex; margin-left: var(--sp-3)">
|
||||
<input type="checkbox" name="remove_logo" value="true">
|
||||
<span>Remove it</span>
|
||||
<span>{{ t("Remove it") }}</span>
|
||||
</label>
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -63,47 +63,40 @@
|
||||
accept="image/png,image/jpeg,image/webp,image/gif">
|
||||
<p class="field__hint">
|
||||
Replaces the leaf mark everywhere it appears. PNG, JPEG, WEBP or GIF,
|
||||
under 2 MB, square. <strong>SVG is deliberately not accepted</strong>:
|
||||
under 2 MB, square. <strong>{{ t("SVG is deliberately not accepted") }}</strong>:
|
||||
these files are served to people who are not signed in, and an SVG can
|
||||
carry a script. The launcher icons are made from this automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="favicon">Favicon</label>
|
||||
<label class="field__label" for="favicon">{{ t("Favicon") }}</label>
|
||||
{% if current.favicon_path %}
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
<img src="/branding/{{ current.favicon_path }}" alt="" width="16" height="16"
|
||||
style="vertical-align: middle">
|
||||
<label class="checkbox" style="display: inline-flex; margin-left: var(--sp-3)">
|
||||
<input type="checkbox" name="remove_favicon" value="true">
|
||||
<span>Remove it</span>
|
||||
<span>{{ t("Remove it") }}</span>
|
||||
</label>
|
||||
</p>
|
||||
{% endif %}
|
||||
<input class="input" id="favicon" name="favicon" type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif">
|
||||
<p class="field__hint">
|
||||
Optional. Without one, a logo you upload is used at 32px, and without
|
||||
that the shipped leaf.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Optional. Without one, a logo you upload is used at 32px, and without that the shipped leaf.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save identity</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save identity") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Wording -------------------------------------------------------------- #}
|
||||
<form method="post" action="/admin/customization/flavour" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Wording</h2>
|
||||
<p class="field__hint">
|
||||
The lines with a bit of character in them. They live in the empty states,
|
||||
the error pages and the sign-in screen — never in the functional interface,
|
||||
where a button says what it does. Replace them with your own, or leave them.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Wording") }}</h2>
|
||||
<p class="field__hint">{{ t("The lines with a bit of character in them. They live in the empty states, the error pages and the sign-in screen — never in the functional interface, where a button says what it does. Replace them with your own, or leave them.") }}</p>
|
||||
|
||||
{% for entry in flavour %}
|
||||
<div class="field">
|
||||
@@ -117,7 +110,7 @@
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save wording</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save wording") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -145,14 +138,8 @@
|
||||
#}
|
||||
<form method="post" action="/admin/customization/themes" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Themes</h2>
|
||||
<p class="field__hint">
|
||||
A theme is a set of colours, not a stylesheet — nothing in this interface
|
||||
hard-codes one, so a third palette composes with everything. Pick which
|
||||
built-in it starts from and change only what you want; everything left
|
||||
empty is inherited. The soft variants behind focus rings and selected rows
|
||||
are worked out from the accent, so you do not have to.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Themes") }}</h2>
|
||||
<p class="field__hint">{{ t("A theme is a set of colours, not a stylesheet — nothing in this interface hard-codes one, so a third palette composes with everything. Pick which built-in it starts from and change only what you want; everything left empty is inherited. The soft variants behind focus rings and selected rows are worked out from the accent, so you do not have to.") }}</p>
|
||||
|
||||
{# The spare block is offered until the cap, so the form cannot grow without
|
||||
end and the page cannot stop offering one while there is room. #}
|
||||
@@ -166,10 +153,10 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="theme-{{ index }}-id">Id</label>
|
||||
<label class="field__label" for="theme-{{ index }}-id">{{ t("Id") }}</label>
|
||||
<input class="input" id="theme-{{ index }}-id" name="theme_{{ index }}_id"
|
||||
value="{{ existing.id if existing else '' }}" maxlength="24"
|
||||
pattern="[a-z][a-z0-9-]*" placeholder="dusk">
|
||||
pattern="[a-z][a-z0-9-]*" placeholder="{{ t('dusk') }}">
|
||||
<p class="field__hint">
|
||||
{% if existing %}
|
||||
Lowercase letters, digits and hyphens. Clear it to remove this theme.
|
||||
@@ -179,13 +166,13 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="theme-{{ index }}-label">Name</label>
|
||||
<label class="field__label" for="theme-{{ index }}-label">{{ t("Name") }}</label>
|
||||
<input class="input" id="theme-{{ index }}-label" name="theme_{{ index }}_label"
|
||||
value="{{ existing.label if existing else '' }}" maxlength="60"
|
||||
placeholder="Dusk">
|
||||
placeholder="{{ t('Dusk') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="theme-{{ index }}-base">Starts from</label>
|
||||
<label class="field__label" for="theme-{{ index }}-base">{{ t("Starts from") }}</label>
|
||||
<select class="input" id="theme-{{ index }}-base" name="theme_{{ index }}_base">
|
||||
{% for base in bases %}
|
||||
<option value="{{ base }}"
|
||||
@@ -203,7 +190,7 @@
|
||||
{% if existing and existing.tokens %}
|
||||
<span class="badge">{{ existing.tokens | length }} set</span>
|
||||
{% else %}
|
||||
<span class="faint">all inherited</span>
|
||||
<span class="faint">{{ t("all inherited") }}</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<div class="field-row">
|
||||
@@ -215,7 +202,7 @@
|
||||
<input class="input" id="theme-{{ index }}-{{ name }}"
|
||||
name="theme_{{ index }}_{{ name }}" type="text"
|
||||
value="{{ existing.tokens.get(name, '') if existing else '' }}"
|
||||
maxlength="40" placeholder="inherited"
|
||||
maxlength="40" placeholder="{{ t('inherited') }}"
|
||||
spellcheck="false">
|
||||
<p class="field__hint"><code>--{{ name }}</code></p>
|
||||
</div>
|
||||
@@ -234,14 +221,14 @@
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save themes</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save themes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Stylesheet ----------------------------------------------------------- #}
|
||||
<form method="post" action="/admin/customization/css" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Stylesheet</h2>
|
||||
<h2 class="card__title">{{ t("Stylesheet") }}</h2>
|
||||
<p class="field__hint">
|
||||
Served as <code>/branding.css</code> after everything else, so these rules
|
||||
win. It is a file rather than a block inside the page on purpose: a
|
||||
@@ -251,21 +238,17 @@
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="custom-css">CSS</label>
|
||||
<label class="field__label" for="custom-css">{{ t("CSS") }}</label>
|
||||
<textarea class="input textarea" id="custom-css" name="custom_css" rows="10"
|
||||
spellcheck="false"
|
||||
placeholder=".sidebar__brand { letter-spacing: 0.02em; }"
|
||||
>{{ values.custom_css }}</textarea>
|
||||
<p class="field__hint">
|
||||
Up to 40,000 characters. Nothing here is validated: a rule that does not
|
||||
parse is dropped by the browser, quietly, as it would be in any
|
||||
stylesheet.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Up to 40,000 characters. Nothing here is validated: a rule that does not parse is dropped by the browser, quietly, as it would be in any stylesheet.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save stylesheet</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save stylesheet") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "data-groups" %}
|
||||
|
||||
{% block title %}{{ group.name }} - {{ t("Data groups") }} - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ group.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/data-groups">{{ icon("chevron-left", "icon--sm") }} {{ t("All data groups") }}</a>
|
||||
{% if owner %}· {{ t("A personal group of %(who)s.", who=owner.email) }}{% endif %}
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
The delete form is declared before the main one and reached by `form=`, so it
|
||||
can never end up nested inside it -- a nested <form> truncates its parent, the
|
||||
1.3.0 bug that made a whole admin page unsaveable.
|
||||
#}
|
||||
<form id="delete-group" method="post" action="/admin/data-groups/{{ group.id }}/delete"></form>
|
||||
|
||||
<form method="post" action="/admin/data-groups/{{ group.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("Name") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ group.name }}" required>
|
||||
<p class="field__hint"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">{{ t("What it is for") }}</label>
|
||||
<input class="input" id="description" name="description"
|
||||
value="{{ group.description }}" maxlength="2000">
|
||||
<p class="field__hint"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not owner %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("Connections in this group") }}</h2>
|
||||
<p class="card__lede">{{ t("Their models read this group's data and nothing else. A connection taken out of a group goes back to the default one. Moving a connection moves no data: it starts reading the other group's.") }}</p>
|
||||
<input type="hidden" name="connections_sent" value="1">
|
||||
<div class="field">
|
||||
{% for connection in connections %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="connection_ids" value="{{ connection.id }}"
|
||||
{{ 'checked' if connection.id in member_ids }}
|
||||
{{ 'disabled' if group.is_default and connection.id in member_ids }}>
|
||||
<span>{{ connection.name }}{% if not connection.enabled %} <span class="badge">{{ t("disabled") }}</span>{% endif %}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="field__hint">{{ t("There are no connections yet.") }}</p>
|
||||
{% endfor %}
|
||||
{% if group.is_default %}
|
||||
<p class="field__hint">{{ t("A connection leaves the default group by being put into another one.") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("Services that read this group") }}</h2>
|
||||
<p class="card__lede">{{ t("The embedder is sent the full text of every document, note, skill and report it indexes, and the reviewer is sent every generated picture with its prompt. A group can name its own, so its data does not have to go to the instance's.") }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="embedding">{{ t("Embedding model") }}</label>
|
||||
<select class="select" id="embedding" name="embedding">
|
||||
<option value="">{{ t("The instance's choice") }}</option>
|
||||
{% for model in embedders %}
|
||||
<option value="{{ model.model_id }}|{{ model.connection_id }}"
|
||||
{{ 'selected' if group.embedding_model_id == model.model_id and (not group.embedding_connection_id or group.embedding_connection_id == model.connection_id) }}>
|
||||
{{ model.label }} · {{ model.connection.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">{{ t("Changing it leaves this group's search keyword-only until the index is rebuilt on the Extraction page.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="reviewer">{{ t("Image reviewer") }}</label>
|
||||
<select class="select" id="reviewer" name="reviewer">
|
||||
<option value="">{{ t("The instance's choice") }}</option>
|
||||
{% for model in reviewers %}
|
||||
<option value="{{ model.model_id }}|{{ model.connection_id }}"
|
||||
{{ 'selected' if group.review_model_id == model.model_id and (not group.review_connection_id or group.review_connection_id == model.connection_id) }}>
|
||||
{{ model.label }} · {{ model.connection.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">{{ t("Only models that can see images are listed.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
{% if not group.is_default %}
|
||||
<button class="btn btn--danger" type="submit" form="delete-group"
|
||||
data-confirm-button="{{ t('Delete the data group “%(name)s”?', name=group.name) }}">
|
||||
{{ icon("trash", "icon--sm") }} {{ t("Delete") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-6)">
|
||||
<h2 class="card__title">{{ t("Where this group's data goes") }}</h2>
|
||||
<p class="card__lede">{{ t("Every provider this group's data is sent to, as the instance has it set. A person holding “Manage their own data groups” may have arranged their own differently. Speech to text and text to speech are not grouped: audio is sent and not kept.") }}</p>
|
||||
{% if exits %}
|
||||
{# Rows rather than a table, so a phone gets a stack instead of a sideways
|
||||
scroll -- the same list-row pattern as every other admin list. #}
|
||||
<div class="model-rows">
|
||||
{% for exit in exits %}
|
||||
<div class="model-row">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">{{ labels.get(exit.what, exit.what) }}</span>
|
||||
<span class="model-row__id">
|
||||
{%- if exit.model %}{{ exit.model }} · {% endif %}{{ exit.connection -}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="model-row__meta">
|
||||
{% if exit.elsewhere %}
|
||||
<span class="badge badge--danger">{{ t("its connection is in %(group)s", group=exit.elsewhere) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="field__hint">{{ t("Nothing: no connection is in this group, and no service reads it.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="field__hint">
|
||||
{{ t("Holds") }}:
|
||||
{% for label, n in counts.items() %}{{ n }} {{ labels.get(label, label) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "data-groups" %}
|
||||
|
||||
{% block title %}{{ t("Data groups") }} - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ t("Data groups") }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">{{ t("A data group keeps one provider's models away from the data another provider's models have been given. Every connection is in one group. Its models read only that group's memories, notes, skills, knowledge, reports and personalities, and a chat stays in the group it was started in.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Data group deleted.") if saved == "deleted" else t("Settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="admin-section-title">
|
||||
{{ t("Instance groups") }} <span class="badge">{{ groups|length }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="model-rows">
|
||||
{% for group in groups %}
|
||||
<a class="model-row" href="/admin/data-groups/{{ group.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">{{ group.name }}</span>
|
||||
{% if group.description %}
|
||||
<span class="model-row__id">{{ group.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="model-row__meta">
|
||||
{% if group.is_default %}<span class="badge badge--leaf">{{ t("default") }}</span>{% endif %}
|
||||
<span class="badge">{{ t("%(n)s connections", n=connections[group.id]) }}</span>
|
||||
<span class="badge">{{ t("%(n)s chats", n=counts[group.id]["chats"]) }}</span>
|
||||
{% if group.embedding_model_id %}<span class="badge">{{ t("own embedder") }}</span>{% endif %}
|
||||
{% if group.review_model_id %}<span class="badge">{{ t("own reviewer") }}</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/admin/data-groups" class="btn-row">
|
||||
<input class="input" name="name" placeholder="{{ t('New data group name') }}" required
|
||||
aria-label="{{ t('New data group name') }}">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} {{ t("Create data group") }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">{{ t("A new group holds nothing and reads nothing until a connection is put into it. Nothing moves on its own.") }}</p>
|
||||
</section>
|
||||
|
||||
{#
|
||||
Personal groups are somebody's own arrangement of their own data -- made by
|
||||
a person holding "Manage their own data groups" -- and are listed so an
|
||||
administrator can see they exist, not so they can be rearranged from here.
|
||||
#}
|
||||
{% if personal %}
|
||||
<h2 class="admin-section-title">
|
||||
{{ t("Personal groups") }} <span class="badge">{{ personal|length }}</span>
|
||||
</h2>
|
||||
<div class="model-rows">
|
||||
{% for group in personal %}
|
||||
<a class="model-row" href="/admin/data-groups/{{ group.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">{{ group.name }}</span>
|
||||
<span class="model-row__id">{{ owners[group.owner_id].email if group.owner_id in owners else "" }}</span>
|
||||
</div>
|
||||
<div class="model-row__meta">
|
||||
<span class="badge">{{ t("%(n)s chats", n=counts[group.id]["chats"]) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -6,11 +6,7 @@
|
||||
{% block heading %}Extraction and search{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
What happens to a file between the upload and the model, and how anything is
|
||||
found again afterwards. The two are the same pipeline: what is extracted
|
||||
decides what there is to search.
|
||||
</p>
|
||||
<p class="admin-lede">{{ t("What happens to a file between the upload and the model, and how anything is found again afterwards. The two are the same pipeline: what is extracted decides what there is to search.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
@@ -19,75 +15,58 @@
|
||||
{# --- Extraction ----------------------------------------------------------- #}
|
||||
<form method="post" action="/admin/extraction" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">What a file may cost</h2>
|
||||
<p class="field__hint">
|
||||
Every number here is a trade, and a large one usually breaks a request
|
||||
rather than being slow — the text of a whole book does not fit in a
|
||||
context window, and a model handed it fails the request outright rather
|
||||
than reading the first half.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("What a file may cost") }}</h2>
|
||||
<p class="field__hint">{{ t("Every number here is a trade, and a large one usually breaks a request rather than being slow — the text of a whole book does not fit in a context window, and a model handed it fails the request outright rather than reading the first half.") }}</p>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_upload_mb">Largest upload</label>
|
||||
<label class="field__label" for="max_upload_mb">{{ t("Largest upload") }}</label>
|
||||
<input class="input" id="max_upload_mb" name="max_upload_mb"
|
||||
type="number" min="1" max="512" step="1" value="{{ values.max_upload_mb }}">
|
||||
<p class="field__hint">Megabytes, before anything is done to it.</p>
|
||||
<p class="field__hint">{{ t("Megabytes, before anything is done to it.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_extracted_chars">Text kept per file</label>
|
||||
<label class="field__label" for="max_extracted_chars">{{ t("Text kept per file") }}</label>
|
||||
<input class="input" id="max_extracted_chars" name="max_extracted_chars"
|
||||
type="number" min="1000" max="5000000" step="1000"
|
||||
value="{{ values.max_extracted_chars }}">
|
||||
<p class="field__hint">
|
||||
Characters. Roughly four to a token, so 120,000 is about 30,000 tokens
|
||||
— already most of a small context window. The rest is cut and the
|
||||
model is told so.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Characters. Roughly four to a token, so 120,000 is about 30,000 tokens — already most of a small context window. The rest is cut and the model is told so.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_pdf_pages">Pages read from a PDF</label>
|
||||
<label class="field__label" for="max_pdf_pages">{{ t("Pages read from a PDF") }}</label>
|
||||
<input class="input" id="max_pdf_pages" name="max_pdf_pages"
|
||||
type="number" min="1" max="5000" step="1" value="{{ values.max_pdf_pages }}">
|
||||
<p class="field__hint">
|
||||
Extraction is slow and happens once, at upload. Beyond this the file
|
||||
is still stored; only its text stops.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Extraction is slow and happens once, at upload. Beyond this the file is still stored; only its text stops.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_image_edge">Longest image edge</label>
|
||||
<label class="field__label" for="max_image_edge">{{ t("Longest image edge") }}</label>
|
||||
<input class="input" id="max_image_edge" name="max_image_edge"
|
||||
type="number" min="128" max="8192" step="16"
|
||||
value="{{ values.max_image_edge }}">
|
||||
<p class="field__hint">
|
||||
Pixels. Images are re-encoded before they are sent, because a phone
|
||||
photo is several megabytes of base64.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Pixels. Images are re-encoded before they are sent, because a phone photo is several megabytes of base64.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="jpeg_quality">JPEG quality</label>
|
||||
<label class="field__label" for="jpeg_quality">{{ t("JPEG quality") }}</label>
|
||||
<input class="input" id="jpeg_quality" name="jpeg_quality"
|
||||
type="number" min="30" max="100" step="1" value="{{ values.jpeg_quality }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="orphan_hours">Keep abandoned uploads for</label>
|
||||
<label class="field__label" for="orphan_hours">{{ t("Keep abandoned uploads for") }}</label>
|
||||
<input class="input" id="orphan_hours" name="orphan_hours"
|
||||
type="number" min="1" max="8760" step="1" value="{{ values.orphan_hours }}">
|
||||
<p class="field__hint">
|
||||
Hours. A file chosen in the composer and never sent. Swept at startup.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Hours. A file chosen in the composer and never sent. Swept at startup.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="extra_text_extensions">Also treat as text</label>
|
||||
<label class="field__label" for="extra_text_extensions">{{ t("Also treat as text") }}</label>
|
||||
<textarea class="textarea input--mono" id="extra_text_extensions"
|
||||
name="extra_text_extensions" rows="4" spellcheck="false"
|
||||
>{{ extensions_text }}</textarea>
|
||||
@@ -102,28 +81,24 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="reject_unreadable_pdf" value="true"
|
||||
{{ 'checked' if values.reject_unreadable_pdf }}>
|
||||
<span>Refuse a PDF whose text cannot be read</span>
|
||||
<span>{{ t("Refuse a PDF whose text cannot be read") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off, a scanned PDF is stored with an explanation saying why it
|
||||
contributes nothing — there is no OCR here. That is usually what
|
||||
somebody wants: the file is still attached and still downloadable.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Off, a scanned PDF is stored with an explanation saying why it contributes nothing — there is no OCR here. That is usually what somebody wants: the file is still attached and still downloadable.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save extraction</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save extraction") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Semantic search ------------------------------------------------------ #}
|
||||
<form method="post" action="/admin/extraction/search" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Searching by meaning</h2>
|
||||
<h2 class="card__title">{{ t("Searching by meaning") }}</h2>
|
||||
<p class="field__hint">
|
||||
Keyword search finds a document that uses your words. This finds one that
|
||||
means the same thing — <em>"how do I get in"</em> reaching a note about
|
||||
means the same thing — <em>{{ t('"how do I get in"') }}</em> reaching a note about
|
||||
passwords. Both run and the two rankings are fused, so nothing that
|
||||
keyword search found is lost.
|
||||
</p>
|
||||
@@ -133,7 +108,7 @@
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
No model is marked as producing embeddings. Tick
|
||||
<strong>embeddings</strong> on one under
|
||||
<strong>{{ t("embeddings") }}</strong> on one under
|
||||
<a href="/admin/models">Models</a> — usually a small dedicated model
|
||||
such as <code>nomic-embed-text</code> or <code>bge-m3</code>, not a chat
|
||||
model.
|
||||
@@ -153,9 +128,9 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="embedding_model_id">Embedding model</label>
|
||||
<label class="field__label" for="embedding_model_id">{{ t("Embedding model") }}</label>
|
||||
<select class="input" id="embedding_model_id" name="embedding_model_id">
|
||||
<option value="">None — keyword search only</option>
|
||||
<option value="">{{ t("None — keyword search only") }}</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if values.embedding_model_id == model.model_id }}>
|
||||
@@ -164,7 +139,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
<strong>None</strong> is not a degraded mode: it is the keyword search
|
||||
<strong>{{ t("None") }}</strong> is not a degraded mode: it is the keyword search
|
||||
this has always had, with nothing stored and nothing sent anywhere.
|
||||
Changing the model changes what a vector means, so anything already
|
||||
indexed is ignored until it is rebuilt.
|
||||
@@ -173,53 +148,37 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="chunk_chars">Piece size</label>
|
||||
<label class="field__label" for="chunk_chars">{{ t("Piece size") }}</label>
|
||||
<input class="input" id="chunk_chars" name="chunk_chars"
|
||||
type="number" min="200" max="8000" step="50" value="{{ values.chunk_chars }}">
|
||||
<p class="field__hint">
|
||||
Characters. A record is split on paragraph boundaries into pieces of
|
||||
about this size, and each is embedded separately — a document is found
|
||||
by its best piece, not by its average.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Characters. A record is split on paragraph boundaries into pieces of about this size, and each is embedded separately — a document is found by its best piece, not by its average.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="chunk_overlap">Overlap</label>
|
||||
<label class="field__label" for="chunk_overlap">{{ t("Overlap") }}</label>
|
||||
<input class="input" id="chunk_overlap" name="chunk_overlap"
|
||||
type="number" min="0" max="4000" step="10" value="{{ values.chunk_overlap }}">
|
||||
<p class="field__hint">
|
||||
How much of each piece is repeated at the start of the next, so a
|
||||
sentence across a boundary is whole somewhere. Capped at half the
|
||||
piece size.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("How much of each piece is repeated at the start of the next, so a sentence across a boundary is whole somewhere. Capped at half the piece size.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="embed_batch">Pieces per request</label>
|
||||
<label class="field__label" for="embed_batch">{{ t("Pieces per request") }}</label>
|
||||
<input class="input" id="embed_batch" name="embed_batch"
|
||||
type="number" min="1" max="256" step="1" value="{{ values.embed_batch }}">
|
||||
<p class="field__hint">
|
||||
Lower this if the endpoint refuses large requests; raise it if a
|
||||
rebuild is slow and the far side has room.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Lower this if the endpoint refuses large requests; raise it if a rebuild is slow and the far side has room.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save search</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save search") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- The index ------------------------------------------------------------ #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">The index</h2>
|
||||
<p class="field__hint">
|
||||
Documents, notes, skills and reports are indexed as they are written. A
|
||||
rebuild is for everything that already existed — or for after changing the
|
||||
model or the piece size, both of which make what is stored stop meaning
|
||||
anything. It runs in the background and can be left.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("The index") }}</h2>
|
||||
<p class="field__hint">{{ t("Documents, notes, skills and reports are indexed as they are written. A rebuild is for everything that already existed — or for after changing the model or the piece size, both of which make what is stored stop meaning anything. It runs in the background and can be left.") }}</p>
|
||||
|
||||
{% include "admin/_index_progress.html" %}
|
||||
</section>
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
{% block heading %}General{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Instance-wide settings. These are stored in the database and take effect
|
||||
immediately — no restart, and they survive one.
|
||||
</p>
|
||||
<p class="admin-lede">{{ t("Instance-wide settings. These are stored in the database and take effect immediately — no restart, and they survive one.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Settings saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
@@ -29,30 +26,40 @@
|
||||
<form method="post" action="/admin/general">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Default system prompt</h2>
|
||||
<p class="card__lede">
|
||||
Applied to every chat that does not have a prompt of its own. A model's
|
||||
prompt overrides this, and a chat's prompt overrides both — most specific
|
||||
wins outright rather than the three being stacked together.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Language") }}</h2>
|
||||
<p class="card__lede">{{ t("What the interface is in for anybody who has not chosen for themselves. Everyone can pick their own under Appearance in their settings, and that choice wins here.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
|
||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||
placeholder="You are a helpful assistant.">{{ values.system_prompt }}</textarea>
|
||||
<p class="field__hint">Leave empty to send no system prompt at all.</p>
|
||||
<label class="field__label" for="language">{{ t("Interface language") }}</label>
|
||||
<select class="select" id="language" name="language">
|
||||
{% for code, name in languages %}
|
||||
<option value="{{ code }}" {{ 'selected' if values.language == code
|
||||
or (not values.language and code == "en") }}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
This is the interface only. Models answer in whatever language you write
|
||||
to them in, which is a line you can edit under
|
||||
<a href="/admin/prompts">Prompts</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Compaction</h2>
|
||||
<p class="card__lede">
|
||||
A long conversation eventually fills the model's context. When it gets
|
||||
close, the earlier turns are summarised and the summary is sent in their
|
||||
place. The messages themselves are kept and stay readable in the
|
||||
transcript — they simply stop being sent.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Default system prompt") }}</h2>
|
||||
<p class="card__lede">{{ t("Applied to every chat that does not have a prompt of its own. A model's prompt overrides this, and a chat's prompt overrides both — most specific wins outright rather than the three being stacked together.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label" for="compact-threshold">Compact at</label>
|
||||
<label class="field__label visually-hidden" for="system-prompt">{{ t("System prompt") }}</label>
|
||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||
placeholder="{{ t('You are a helpful assistant.') }}">{{ values.system_prompt }}</textarea>
|
||||
<p class="field__hint">{{ t("Leave empty to send no system prompt at all.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("Compaction") }}</h2>
|
||||
<p class="card__lede">{{ t("A long conversation eventually fills the model's context. When it gets close, the earlier turns are summarised and the summary is sent in their place. The messages themselves are kept and stay readable in the transcript — they simply stop being sent.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label" for="compact-threshold">{{ t("Compact at") }}</label>
|
||||
<input class="input" id="compact-threshold" name="compact_threshold" type="number"
|
||||
min="0" max="99" value="{{ values.compact_threshold }}">
|
||||
<p class="field__hint">
|
||||
@@ -67,20 +74,16 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Tool calls in an ordinary chat</h2>
|
||||
<p class="card__lede">
|
||||
A model ends its own turn the moment it stops asking for tools — that is
|
||||
it saying it has what it needs, and nothing here overrides it. This is a
|
||||
ceiling for the case where it never says so.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Tool calls in an ordinary chat") }}</h2>
|
||||
<p class="card__lede">{{ t("A model ends its own turn the moment it stops asking for tools — that is it saying it has what it needs, and nothing here overrides it. This is a ceiling for the case where it never says so.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max-chat-rounds">Most rounds of tool calls</label>
|
||||
<label class="field__label" for="max-chat-rounds">{{ t("Most rounds of tool calls") }}</label>
|
||||
<input class="input" id="max-chat-rounds" name="max_chat_rounds" type="number"
|
||||
min="0" max="100" value="{{ values.max_chat_rounds }}">
|
||||
<p class="field__hint">
|
||||
Several tools can be called in one round, so this is not a count of
|
||||
tools. Leave room for at least two: <code>knowledge_get</code> and
|
||||
<code>notes_get</code> read a document by an id a <em>search</em>
|
||||
<code>notes_get</code> read a document by an id a <em>{{ t("search") }}</em>
|
||||
returned, so a ceiling of one leaves the library searchable and not
|
||||
readable. <code>0</code> means no ceiling, which is how an agent chat
|
||||
already works — those are bounded under
|
||||
@@ -93,9 +96,9 @@
|
||||
<h2 class="card__title">
|
||||
Registration
|
||||
{% if values.allow_signup %}
|
||||
<span class="badge badge--success">open</span>
|
||||
<span class="badge badge--success">{{ t("open") }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge--danger">closed</span>
|
||||
<span class="badge badge--danger">{{ t("closed") }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
@@ -103,20 +106,16 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allow_signup" value="true"
|
||||
{{ 'checked' if values.allow_signup }}>
|
||||
<span>Anyone who can reach this instance may create an account</span>
|
||||
<span>{{ t("Anyone who can reach this instance may create an account") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Turn this off once your users exist. Sign-in is unaffected — existing
|
||||
accounts keep working, and the "Create one" link disappears from the
|
||||
sign-in page.
|
||||
</p>
|
||||
<p class="field__hint">{{ t('Turn this off once your users exist. Sign-in is unaffected — existing accounts keep working, and the "Create one" link disappears from the sign-in page.') }}</p>
|
||||
</div>
|
||||
|
||||
{% if values.allow_signup and user_count > 0 %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<div>
|
||||
<strong>Registration is open.</strong>
|
||||
<strong>{{ t("Registration is open.") }}</strong>
|
||||
<div class="text-sm" style="margin-top: var(--sp-1)">
|
||||
This instance has {{ user_count }} account{{ '' if user_count == 1 else 's' }}.
|
||||
Anyone who can reach it can add another, and every account can use your
|
||||
@@ -132,6 +131,6 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">{{ t("Save settings") }}</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/groups">{{ icon("chevron-left", "icon--sm") }} All groups</a>
|
||||
· A group only ever <em>adds</em>. Anything already in the baseline is shown
|
||||
· A group only ever <em>{{ t("adds") }}</em>. Anything already in the baseline is shown
|
||||
below as such, so a tick here that changes nothing looks like one.
|
||||
</p>
|
||||
|
||||
@@ -18,20 +18,20 @@
|
||||
|
||||
<form method="post" action="/admin/groups/{{ group.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Name</h2>
|
||||
<h2 class="card__title">{{ t("Name") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ group.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">What it is for</label>
|
||||
<label class="field__label" for="description">{{ t("What it is for") }}</label>
|
||||
<input class="input" id="description" name="description"
|
||||
value="{{ group.description }}" maxlength="1000">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Permissions this group adds</h2>
|
||||
<h2 class="card__title">{{ t("Permissions this group adds") }}</h2>
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
<div class="field">
|
||||
<span class="field__label">{{ section_name }}</span>
|
||||
@@ -42,7 +42,7 @@
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
{% if baseline[definition.key] %}
|
||||
<span class="badge">already in the baseline</span>
|
||||
<span class="badge">{{ t("already in the baseline") }}</span>
|
||||
{% endif %}
|
||||
<span class="perm-row__desc">{{ definition.description }}</span>
|
||||
</span>
|
||||
@@ -59,11 +59,11 @@
|
||||
only place somebody will read it.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Quotas</h2>
|
||||
<h2 class="card__title">{{ t("Quotas") }}</h2>
|
||||
<p class="card__lede">
|
||||
Resolved across a person's groups by <strong>maximum</strong> — the union
|
||||
Resolved across a person's groups by <strong>{{ t("maximum") }}</strong> — the union
|
||||
rule applied to numbers, so a second group can only grant more.
|
||||
<strong>Leave a box empty</strong> for “no opinion”, and use
|
||||
<strong>{{ t("Leave a box empty") }}</strong> for “no opinion”, and use
|
||||
<strong>0</strong> for “no limit”, which beats any number another group
|
||||
sets. Administrators are unlimited whatever is here.
|
||||
</p>
|
||||
@@ -73,7 +73,7 @@
|
||||
<label class="field__label" for="limit-{{ key }}">{{ label }}</label>
|
||||
<input class="input" id="limit-{{ key }}" name="limit_{{ key }}"
|
||||
type="number" min="0" step="1"
|
||||
value="{{ limits.get(key, '') }}" placeholder="no opinion">
|
||||
value="{{ limits.get(key, '') }}" placeholder="{{ t('no opinion') }}">
|
||||
<p class="field__hint">{{ description }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -85,11 +85,8 @@
|
||||
and a full-form POST from either side overwrote what the other had shown.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Members</h2>
|
||||
<p class="card__lede">
|
||||
The one place membership is edited. A user's own page links here rather
|
||||
than offering a second control for the same value.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Members") }}</h2>
|
||||
<p class="card__lede">{{ t("The one place membership is edited. A user's own page links here rather than offering a second control for the same value.") }}</p>
|
||||
<div class="checkbox-row">
|
||||
{% for person in users %}
|
||||
<label class="checkbox">
|
||||
@@ -102,12 +99,8 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models this group unlocks</h2>
|
||||
<p class="card__lede">
|
||||
A model marked public is available to everyone; one that is not is
|
||||
available to the groups named here. Model access is separate from
|
||||
permissions — one says what somebody may do, the other what with.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Models this group unlocks") }}</h2>
|
||||
<p class="card__lede">{{ t("A model marked public is available to everyone; one that is not is available to the groups named here. Model access is separate from permissions — one says what somebody may do, the other what with.") }}</p>
|
||||
<div class="checkbox-row">
|
||||
{% for model in models %}
|
||||
<label class="checkbox">
|
||||
@@ -120,22 +113,18 @@
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save group</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save group") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Remove</h2>
|
||||
<h2 class="card__title">{{ t("Remove") }}</h2>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
data-confirm="Delete {{ group.name }}? Its members keep their accounts.">
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete this group
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Members keep their accounts and lose whatever this group granted them. Every
|
||||
share naming this group goes too — nothing cascades to those, so they are
|
||||
deleted explicitly.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Members keep their accounts and lose whatever this group granted them. Every share naming this group goes too — nothing cascades to those, so they are deleted explicitly.") }}</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Permissions are a <strong>union</strong>: everyone starts with the baseline
|
||||
Permissions are a <strong>{{ t("union") }}</strong>: everyone starts with the baseline
|
||||
below, and each group they belong to can add more. A group never takes
|
||||
something away, so being in a second group can only widen what someone can do —
|
||||
which is what keeps “why can this person not do X?” answerable without
|
||||
@@ -19,11 +19,8 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Baseline permissions</h2>
|
||||
<p class="card__lede">
|
||||
What every signed-in user can do before any group is considered. Turn
|
||||
something off here and grant it through a group to make it opt-in.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Baseline permissions") }}</h2>
|
||||
<p class="card__lede">{{ t("What every signed-in user can do before any group is considered. Turn something off here and grant it through a group to make it opt-in.") }}</p>
|
||||
|
||||
<form method="post" action="/admin/permissions/defaults">
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
@@ -41,7 +38,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save baseline</button></div>
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">{{ t("Save baseline") }}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -69,22 +66,20 @@
|
||||
{% if granted[group.id] %}
|
||||
<span class="badge badge--leaf">+{{ granted[group.id] }} permission{{ '' if granted[group.id] == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if group.limits_json %}<span class="badge">quotas</span>{% endif %}
|
||||
{% if group.limits_json %}<span class="badge">{{ t("quotas") }}</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">
|
||||
No groups yet. Everybody gets the baseline above and nothing more.
|
||||
</p>
|
||||
<p class="empty__text">{{ t("No groups yet. Everybody gets the baseline above and nothing more.") }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/admin/groups" class="btn-row">
|
||||
<input class="input" name="name" placeholder="New group name" required
|
||||
aria-label="New group name">
|
||||
<input class="input" name="name" placeholder="{{ t('New group name') }}" required
|
||||
aria-label="{{ t('New group name') }}">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Create group
|
||||
</button>
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
{% block heading %}Image generation{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Lets a model draw a picture and show it in the conversation, on a ComfyUI you
|
||||
are running. It is offered as a tool the model chooses to call, so nothing
|
||||
changes for a conversation that never asks for one — and it is only offered
|
||||
once there is a ComfyUI, a workflow and at least one checkpoint, because a
|
||||
tool that fails on its first call is worse than a tool nobody was given.
|
||||
</p>
|
||||
<p class="admin-lede">{{ t("Lets a model draw a picture and show it in the conversation, on a ComfyUI you are running. It is offered as a tool the model chooses to call, so nothing changes for a conversation that never asks for one — and it is only offered once there is a ComfyUI, a workflow and at least one checkpoint, because a tool that fails on its first call is worse than a tool nobody was given.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
@@ -22,13 +16,13 @@
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Image generation
|
||||
{% if values.enabled %}<span class="badge badge--success">on</span>
|
||||
{% else %}<span class="badge">off</span>{% endif %}
|
||||
{% if values.enabled %}<span class="badge badge--success">{{ t("on") }}</span>
|
||||
{% else %}<span class="badge">{{ t("off") }}</span>{% endif %}
|
||||
</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||||
<span>Offer image generation to models that support tools</span>
|
||||
<span>{{ t("Offer image generation to models that support tools") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Who may use it is a permission — <code>tools.image</code> under
|
||||
@@ -38,22 +32,18 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">ComfyUI</h2>
|
||||
<h2 class="card__title">{{ t("ComfyUI") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="base_url">Base URL</label>
|
||||
<label class="field__label" for="base_url">{{ t("Base URL") }}</label>
|
||||
<input class="input input--mono" id="base_url" name="base_url" type="url"
|
||||
placeholder="http://127.0.0.1:8188" value="{{ values.base_url }}">
|
||||
<p class="field__hint">
|
||||
Where ComfyUI is listening. An address on this machine or this network is
|
||||
fine here and is not checked against the request-forgery rules — you
|
||||
typed it, unlike an address a model asks for.
|
||||
</p>
|
||||
placeholder="{{ t('http://127.0.0.1:8188') }}" value="{{ values.base_url }}">
|
||||
<p class="field__hint">{{ t("Where ComfyUI is listening. An address on this machine or this network is fine here and is not checked against the request-forgery rules — you typed it, unlike an address a model asks for.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="api_key">API key</label>
|
||||
<label class="field__label" for="api_key">{{ t("API key") }}</label>
|
||||
<input class="input input--mono" id="api_key" name="api_key" type="password"
|
||||
autocomplete="off" placeholder="No key set"
|
||||
autocomplete="off" placeholder="{{ t('No key set') }}"
|
||||
value="{{ unchanged if values.api_key_encrypted else '' }}">
|
||||
<p class="field__hint">
|
||||
{% if values.api_key_encrypted %}
|
||||
@@ -66,29 +56,21 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="timeout">Timeout (seconds)</label>
|
||||
<label class="field__label" for="timeout">{{ t("Timeout (seconds)") }}</label>
|
||||
<input class="input" id="timeout" name="timeout" type="number"
|
||||
min="10" max="3600" step="10" value="{{ values.timeout | int }}">
|
||||
<p class="field__hint">
|
||||
How long to wait for one image, queue included. Far longer than any other
|
||||
timeout here, because the thing being waited for genuinely takes that long.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("How long to wait for one image, queue included. Far longer than any other timeout here, because the thing being waited for genuinely takes that long.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Checkpoints</h2>
|
||||
<h2 class="card__title">{{ t("Checkpoints") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="checkpoints">Available checkpoints</label>
|
||||
<label class="field__label" for="checkpoints">{{ t("Available checkpoints") }}</label>
|
||||
<textarea class="textarea input--mono" id="checkpoints" name="checkpoints" rows="4"
|
||||
spellcheck="false"
|
||||
placeholder="sd_xl_base_1.0.safetensors">{{ checkpoints_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
One filename per line, exactly as ComfyUI spells it. This is the list the
|
||||
model chooses from, so leaving out a checkpoint is how you stop it being
|
||||
used. Press Test below to read them off ComfyUI — that fills this in the
|
||||
first time and never overwrites it afterwards.
|
||||
</p>
|
||||
placeholder="{{ t('sd_xl_base_1.0.safetensors') }}">{{ checkpoints_text }}</textarea>
|
||||
<p class="field__hint">{{ t("One filename per line, exactly as ComfyUI spells it. This is the list the model chooses from, so leaving out a checkpoint is how you stop it being used. Press Test below to read them off ComfyUI — that fills this in the first time and never overwrites it afterwards.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
@@ -101,21 +83,16 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What a generation uses by default</h2>
|
||||
<p class="card__lede">
|
||||
What every picture is drawn with unless the model names otherwise. Leave a
|
||||
box empty to use the built-in value shown beside it — the built-ins are
|
||||
SD1.5-era, and 512×512 on an SDXL checkpoint is what produces the
|
||||
duplicated limbs.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("What a generation uses by default") }}</h2>
|
||||
<p class="card__lede">{{ t("What every picture is drawn with unless the model names otherwise. Leave a box empty to use the built-in value shown beside it — the built-ins are SD1.5-era, and 512×512 on an SDXL checkpoint is what produces the duplicated limbs.") }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_checkpoint">Checkpoint</label>
|
||||
<label class="field__label" for="default_checkpoint">{{ t("Checkpoint") }}</label>
|
||||
{# A select rather than a box, from the list above. Typing a name that is
|
||||
not there produced a picture drawn with whatever happened to be first,
|
||||
with nothing anywhere saying so. #}
|
||||
<select class="select" id="default_checkpoint" name="default_checkpoint">
|
||||
<option value="">The first in the list above</option>
|
||||
<option value="">{{ t("The first in the list above") }}</option>
|
||||
{% for name in values.checkpoints or [] %}
|
||||
<option value="{{ name }}" {{ 'selected' if values.default_checkpoint == name }}>
|
||||
{{ name }}
|
||||
@@ -126,13 +103,13 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_width">Width</label>
|
||||
<label class="field__label" for="default_width">{{ t("Width") }}</label>
|
||||
<input class="input" type="number" id="default_width" name="default_width"
|
||||
min="64" max="2048" step="64" placeholder="512"
|
||||
value="{{ values.default_width }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_height">Height</label>
|
||||
<label class="field__label" for="default_height">{{ t("Height") }}</label>
|
||||
<input class="input" type="number" id="default_height" name="default_height"
|
||||
min="64" max="2048" step="64" placeholder="512"
|
||||
value="{{ values.default_height }}">
|
||||
@@ -153,19 +130,19 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_steps">Steps</label>
|
||||
<label class="field__label" for="default_steps">{{ t("Steps") }}</label>
|
||||
<input class="input" type="number" id="default_steps" name="default_steps"
|
||||
min="1" max="150" step="1" placeholder="20"
|
||||
value="{{ values.default_steps }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_cfg">Guidance (cfg)</label>
|
||||
<label class="field__label" for="default_cfg">{{ t("Guidance (cfg)") }}</label>
|
||||
<input class="input" type="number" id="default_cfg" name="default_cfg"
|
||||
min="0" max="30" step="0.5" placeholder="8"
|
||||
value="{{ values.default_cfg }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_denoise">Denoise</label>
|
||||
<label class="field__label" for="default_denoise">{{ t("Denoise") }}</label>
|
||||
<input class="input" type="number" id="default_denoise" name="default_denoise"
|
||||
min="0" max="1" step="0.05" placeholder="1"
|
||||
value="{{ values.default_denoise }}">
|
||||
@@ -174,13 +151,13 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_sampler">Sampler</label>
|
||||
<label class="field__label" for="default_sampler">{{ t("Sampler") }}</label>
|
||||
{# From what the Test button read off ComfyUI. There are forty-odd and
|
||||
spelling one wrong is a refused workflow, so it is picked rather than
|
||||
typed — and the current value is kept as an option even when the list
|
||||
has not been read, or saving this page would quietly clear it. #}
|
||||
<select class="select" id="default_sampler" name="default_sampler">
|
||||
<option value="">euler (built-in)</option>
|
||||
<option value="">{{ t("euler (built-in)") }}</option>
|
||||
{% for name in values.samplers or [] %}
|
||||
<option value="{{ name }}" {{ 'selected' if values.default_sampler == name }}>
|
||||
{{ name }}
|
||||
@@ -194,9 +171,9 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_scheduler">Scheduler</label>
|
||||
<label class="field__label" for="default_scheduler">{{ t("Scheduler") }}</label>
|
||||
<select class="select" id="default_scheduler" name="default_scheduler">
|
||||
<option value="">normal (built-in)</option>
|
||||
<option value="">{{ t("normal (built-in)") }}</option>
|
||||
{% for name in values.schedulers or [] %}
|
||||
<option value="{{ name }}" {{ 'selected' if values.default_scheduler == name }}>
|
||||
{{ name }}
|
||||
@@ -211,7 +188,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_batch">Images per run</label>
|
||||
<label class="field__label" for="default_batch">{{ t("Images per run") }}</label>
|
||||
<input class="input" type="number" id="default_batch" name="default_batch"
|
||||
min="1" max="8" step="1" placeholder="1"
|
||||
value="{{ values.default_batch }}">
|
||||
@@ -225,38 +202,28 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_negative">Negative prompt</label>
|
||||
<label class="field__label" for="default_negative">{{ t("Negative prompt") }}</label>
|
||||
<input class="input" type="text" id="default_negative" name="default_negative"
|
||||
placeholder="text, watermark" value="{{ values.default_negative }}">
|
||||
<p class="field__hint">
|
||||
Used when the model does not write one of its own. It writes one often,
|
||||
so this is a floor rather than something always applied — “always add
|
||||
these words” belongs in the instructions below, where the model is told
|
||||
to include them.
|
||||
</p>
|
||||
placeholder="{{ t('text, watermark') }}" value="{{ values.default_negative }}">
|
||||
<p class="field__hint">{{ t("Used when the model does not write one of its own. It writes one often, so this is a floor rather than something always applied — “always add these words” belongs in the instructions below, where the model is told to include them.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Checking the result</h2>
|
||||
<h2 class="card__title">{{ t("Checking the result") }}</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="review_enabled" value="true"
|
||||
{{ 'checked' if values.review_enabled }}>
|
||||
<span>Look at each image before showing it, and try again if it is wrong</span>
|
||||
<span>{{ t("Look at each image before showing it, and try again if it is wrong") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
A vision model is shown the picture and the request it came from, and says
|
||||
keep or retry. Only clearly wrong images are retried — a missing subject, a
|
||||
mangled picture — because taste is not a fault and the next attempt is not
|
||||
promised to be better. The reader sees only the image that was kept.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("A vision model is shown the picture and the request it came from, and says keep or retry. Only clearly wrong images are retried — a missing subject, a mangled picture — because taste is not a fault and the next attempt is not promised to be better. The reader sees only the image that was kept.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="review_model_id">Model that reviews</label>
|
||||
<label class="field__label" for="review_model_id">{{ t("Model that reviews") }}</label>
|
||||
<select class="select" id="review_model_id" name="review_model_id">
|
||||
<option value="">The chat's own model, when it has vision</option>
|
||||
<option value="">{{ t("The chat's own model, when it has vision") }}</option>
|
||||
{% for model in vision_models %}
|
||||
{% if model.capabilities_json.get("vision") %}
|
||||
{# The model's own id, not the row's primary key: "Test & refresh"
|
||||
@@ -270,38 +237,26 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
Only models marked as having vision are listed. If there is nothing to ask,
|
||||
the first image is kept and nothing fails.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Only models marked as having vision are listed. If there is nothing to ask, the first image is kept and nothing fails.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_tries">Attempts per image</label>
|
||||
<label class="field__label" for="max_tries">{{ t("Attempts per image") }}</label>
|
||||
<input class="input" id="max_tries" name="max_tries" type="number"
|
||||
min="1" max="10" value="{{ values.max_tries }}">
|
||||
<p class="field__hint">
|
||||
Including the first. The last attempt is kept whatever the review says, so
|
||||
a request always produces a picture.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Including the first. The last attempt is kept whatever the review says, so a request always produces a picture.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Memory</h2>
|
||||
<h2 class="card__title">{{ t("Memory") }}</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="preserve_vram" value="true"
|
||||
{{ 'checked' if values.preserve_vram }}>
|
||||
<span>Preserve VRAM: unload the language model while ComfyUI works</span>
|
||||
<span>{{ t("Preserve VRAM: unload the language model while ComfyUI works") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
For a machine that cannot hold both at once. Before generating, the chat's
|
||||
own connection is asked to unload — set an unload URL on it under
|
||||
Connections, or nothing happens. Afterwards ComfyUI is asked to free its
|
||||
own models, and the language model loads again by itself on the next
|
||||
request.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("For a machine that cannot hold both at once. Before generating, the chat's own connection is asked to unload — set an unload URL on it under Connections, or nothing happens. Afterwards ComfyUI is asked to free its own models, and the language model loads again by itself on the next request.") }}</p>
|
||||
</div>
|
||||
|
||||
{% if values.preserve_vram and values.review_enabled %}
|
||||
@@ -317,28 +272,28 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Extra instructions</h2>
|
||||
<h2 class="card__title">{{ t("Extra instructions") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label visually-hidden" for="instructions">Extra instructions</label>
|
||||
<label class="field__label visually-hidden" for="instructions">{{ t("Extra instructions") }}</label>
|
||||
<textarea class="textarea" id="instructions" name="instructions" rows="4"
|
||||
placeholder="Prefer the SDXL template for anything photographic.">{{ values.instructions }}</textarea>
|
||||
placeholder="{{ t('Prefer the SDXL template for anything photographic.') }}">{{ values.instructions }}</textarea>
|
||||
<p class="field__hint">
|
||||
Added to what every model is told about image generation, on this instance.
|
||||
Where “always put <em>text, watermark</em> in the negative prompt” lives.
|
||||
Where “always put <em>{{ t("text, watermark") }}</em> in the negative prompt” lives.
|
||||
Leave it empty and nothing is added at all.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Workflows</h2>
|
||||
<h2 class="card__title">{{ t("Workflows") }}</h2>
|
||||
<p class="field__hint">
|
||||
A workflow is a ComfyUI graph exported with <strong>Export (API)</strong>, with
|
||||
A workflow is a ComfyUI graph exported with <strong>{{ t("Export (API)") }}</strong>, with
|
||||
<code>{{ '{{prompt}}' }}</code> and the other placeholders where the values go.
|
||||
The model picks between them by their descriptions, so write the description
|
||||
for a reader who cannot see the graph.
|
||||
@@ -353,10 +308,7 @@
|
||||
{% if not workflows %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
{{ icon("image", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No workflows yet. One is needed before anything can be drawn; the default
|
||||
one is filled in for you when you add the first.
|
||||
</p>
|
||||
<p class="empty__text">{{ t("No workflows yet. One is needed before anything can be drawn; the default one is filled in for you when you add the first.") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="model-rows">
|
||||
@@ -365,9 +317,9 @@
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">
|
||||
{{ item.name }}
|
||||
{% if not item.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||
{% if not item.enabled %}<span class="badge badge--danger">{{ t("disabled") }}</span>{% endif %}
|
||||
{% if values.default_workflow_id == item.id %}
|
||||
<span class="badge">default</span>
|
||||
<span class="badge">{{ t("default") }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<code class="model-row__id">{{ item.slug }}</code>
|
||||
@@ -395,20 +347,18 @@
|
||||
<input type="hidden" name="max_tries" value="{{ values.max_tries }}">
|
||||
<input type="hidden" name="preserve_vram" value="{{ 'true' if values.preserve_vram }}">
|
||||
<input type="hidden" name="instructions" value="{{ values.instructions }}">
|
||||
<label class="field__label" for="default_workflow_id">Default workflow</label>
|
||||
<label class="field__label" for="default_workflow_id">{{ t("Default workflow") }}</label>
|
||||
<div class="btn-row">
|
||||
<select class="select" id="default_workflow_id" name="default_workflow_id">
|
||||
<option value="">The first one</option>
|
||||
<option value="">{{ t("The first one") }}</option>
|
||||
{% for item in workflows %}
|
||||
<option value="{{ item.id }}"
|
||||
{{ 'selected' if values.default_workflow_id == item.id }}>{{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn" type="submit">Set default</button>
|
||||
<button class="btn" type="submit">{{ t("Set default") }}</button>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
Used when the model names no template and the chat has no preference.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Used when the model names no template and the chat has no preference.") }}</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -8,18 +8,14 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Remote servers speaking the Model Context Protocol over HTTP. Their tools are
|
||||
offered beside the built-in ones to models marked <strong>MCP servers</strong>.
|
||||
offered beside the built-in ones to models marked <strong>{{ t("MCP servers") }}</strong>.
|
||||
The list of tools is discovered and cached — press <strong>Test &
|
||||
refresh</strong> after adding one, and again whenever the server changes.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("shield", "icon--sm") }}
|
||||
<span>
|
||||
A server's tool names and descriptions are sent to the model as
|
||||
instructions, and what it returns is read back as fact. Add servers you
|
||||
trust, the way you would a dependency.
|
||||
</span>
|
||||
<span>{{ t("A server's tool names and descriptions are sent to the model as instructions, and what it returns is read back as fact. Add servers you trust, the way you would a dependency.") }}</span>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
@@ -35,10 +31,7 @@
|
||||
{% if not servers %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No servers yet. You will need the URL of an MCP endpoint that speaks
|
||||
streamable HTTP — local ones launched as a subprocess are not supported.
|
||||
</p>
|
||||
<p class="empty__text">{{ t("No servers yet. You will need the URL of an MCP endpoint that speaks streamable HTTP — local ones launched as a subprocess are not supported.") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
|
||||
@@ -27,18 +27,18 @@
|
||||
class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The server</h2>
|
||||
<h2 class="card__title">{{ t("The server") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ server.name }}" required
|
||||
maxlength="120" placeholder="GitHub">
|
||||
maxlength="120" placeholder="{{ t('GitHub') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="slug">Identifier</label>
|
||||
<label class="field__label" for="slug">{{ t("Identifier") }}</label>
|
||||
<input class="input input--mono" id="slug" name="slug" value="{{ server.slug }}" required
|
||||
maxlength="24" pattern="[a-z0-9][a-z0-9_\-]*" placeholder="github">
|
||||
maxlength="24" pattern="[a-z0-9][a-z0-9_\-]*" placeholder="{{ t('github') }}">
|
||||
<p class="field__hint">
|
||||
Prefixed onto every tool name this server offers, so that two servers
|
||||
both exposing <code>search</code> do not collide.
|
||||
@@ -46,40 +46,37 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="url">Endpoint URL</label>
|
||||
<label class="field__label" for="url">{{ t("Endpoint URL") }}</label>
|
||||
<input class="input input--mono" id="url" name="url" value="{{ server.url }}" required
|
||||
placeholder="https://mcp.example.com/mcp">
|
||||
<p class="field__hint">
|
||||
The streamable-HTTP endpoint itself, the one that accepts a POST. A
|
||||
server that answers with a redirect to somewhere else will be refused.
|
||||
</p>
|
||||
placeholder="{{ t('https://mcp.example.com/mcp') }}">
|
||||
<p class="field__hint">{{ t("The streamable-HTTP endpoint itself, the one that accepts a POST. A server that answers with a redirect to somewhere else will be refused.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="headers">Extra headers</label>
|
||||
<label class="field__label" for="headers">{{ t("Extra headers") }}</label>
|
||||
<textarea class="textarea input--mono" id="headers" name="headers" rows="3"
|
||||
spellcheck="false">{{ headers_text }}</textarea>
|
||||
<p class="field__hint">One <code>Name: value</code> per line.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="timeout">Timeout (seconds)</label>
|
||||
<label class="field__label" for="timeout">{{ t("Timeout (seconds)") }}</label>
|
||||
<input class="input" id="timeout" name="timeout" value="{{ server.timeout }}"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_chars">Most characters to keep per call</label>
|
||||
<label class="field__label" for="max_chars">{{ t("Most characters to keep per call") }}</label>
|
||||
<input class="input" id="max_chars" name="max_chars" value="{{ server.max_chars }}"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Credential</h2>
|
||||
<h2 class="card__title">{{ t("Credential") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_placement">How it is sent</label>
|
||||
<label class="field__label" for="secret_placement">{{ t("How it is sent") }}</label>
|
||||
<select class="select" id="secret_placement" name="secret_placement">
|
||||
{% for value, label in secret_placements %}
|
||||
<option value="{{ value }}" {{ 'selected' if value == server.secret_placement }}>
|
||||
@@ -90,15 +87,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_name">Header or parameter name</label>
|
||||
<label class="field__label" for="secret_name">{{ t("Header or parameter name") }}</label>
|
||||
<input class="input input--mono" id="secret_name" name="secret_name"
|
||||
value="{{ server.secret_name }}" maxlength="120">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret">Secret</label>
|
||||
<label class="field__label" for="secret">{{ t("Secret") }}</label>
|
||||
<input class="input input--mono" id="secret" name="secret" type="password"
|
||||
autocomplete="off" placeholder="No secret set"
|
||||
autocomplete="off" placeholder="{{ t('No secret set') }}"
|
||||
value="{{ unchanged if server.secret_encrypted else '' }}">
|
||||
<p class="field__hint">
|
||||
{% if server.secret_encrypted %}
|
||||
@@ -113,11 +110,8 @@
|
||||
|
||||
{% if tools %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Tools it offers</h2>
|
||||
<p class="field__hint">
|
||||
Discovered at the last refresh. Untick one to withhold it — a tool this
|
||||
server adds later is offered by default.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Tools it offers") }}</h2>
|
||||
<p class="field__hint">{{ t("Discovered at the last refresh. Untick one to withhold it — a tool this server adds later is offered by default.") }}</p>
|
||||
|
||||
<input type="hidden" name="tool_choices" value="1">
|
||||
<div class="checkbox-row checkbox-row--stacked">
|
||||
@@ -141,7 +135,7 @@
|
||||
</section>
|
||||
{% elif not is_new %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Tools it offers</h2>
|
||||
<h2 class="card__title">{{ t("Tools it offers") }}</h2>
|
||||
<p class="field__hint">
|
||||
Nothing discovered yet. Save, then press <strong>Test & refresh</strong>
|
||||
on the <a href="/admin/mcp">list</a>.
|
||||
@@ -150,11 +144,11 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Guidance</h2>
|
||||
<h2 class="card__title">{{ t("Guidance") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<textarea class="textarea" name="guidance" rows="4"
|
||||
placeholder="- Use the GitHub tools for anything about our repositories."
|
||||
placeholder="{{ t('- Use the GitHub tools for anything about our repositories.') }}"
|
||||
>{{ server.guidance }}</textarea>
|
||||
<p class="field__hint">
|
||||
Added to the system message whenever any tool from this server is
|
||||
@@ -170,18 +164,18 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Availability</h2>
|
||||
<h2 class="card__title">{{ t("Availability") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if server.enabled }}>
|
||||
<span>Enabled — offered in chats</span>
|
||||
<span>{{ t("Enabled — offered in chats") }}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allow_private" value="true"
|
||||
{{ 'checked' if server.allow_private }}>
|
||||
<span>May reach private and loopback addresses</span>
|
||||
<span>{{ t("May reach private and loopback addresses") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
@@ -193,12 +187,12 @@
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="true" {{ 'checked' if server.public }}>
|
||||
<span>Available to everyone</span>
|
||||
<span>{{ t("Available to everyone") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Groups with access</span>
|
||||
<span class="field__label">{{ t("Groups with access") }}</span>
|
||||
{% if groups %}
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
@@ -209,7 +203,7 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field__hint">Ignored while the server is available to everyone.</p>
|
||||
<p class="field__hint">{{ t("Ignored while the server is available to everyone.") }}</p>
|
||||
{% else %}
|
||||
<p class="field__hint">
|
||||
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
||||
@@ -218,7 +212,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="position">Position</label>
|
||||
<label class="field__label" for="position">{{ t("Position") }}</label>
|
||||
<input class="input" id="position" name="position" value="{{ server.position }}"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
@@ -232,9 +226,7 @@
|
||||
{% if not is_new %}
|
||||
<button class="btn btn--danger" type="submit" formnovalidate
|
||||
formaction="/admin/mcp/{{ server.id }}/delete"
|
||||
data-confirm-button="Delete the server “{{ server.name }}”? Chats that used its tools keep their transcripts.">
|
||||
Delete
|
||||
</button>
|
||||
data-confirm-button="Delete the server “{{ server.name }}”? Chats that used its tools keep their transcripts.">{{ t("Delete") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -54,19 +54,16 @@
|
||||
enctype="multipart/form-data" class="btn-row">
|
||||
<input class="input input--file" type="file" name="image"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif" required
|
||||
aria-label="Model image">
|
||||
aria-label="{{ t('Model image') }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload image</button>
|
||||
</form>
|
||||
{% if model.image_path %}
|
||||
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
|
||||
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
|
||||
<button class="btn btn--sm btn--danger" type="submit">{{ t("Remove image") }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated
|
||||
badge whose colour is derived from its id.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated badge whose colour is derived from its id.") }}</p>
|
||||
</section>
|
||||
|
||||
{# Empty, hidden, and outside every other form: the Detect button further down
|
||||
@@ -77,18 +74,18 @@
|
||||
|
||||
<form method="post" action="/admin/models/{{ model.id }}">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Presentation</h2>
|
||||
<h2 class="card__title">{{ t("Presentation") }}</h2>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="display-name">Display name</label>
|
||||
<label class="field__label" for="display-name">{{ t("Display name") }}</label>
|
||||
<input class="input" id="display-name" name="display_name"
|
||||
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
|
||||
<p class="field__hint">Shown instead of the raw id. Empty uses the id.</p>
|
||||
<p class="field__hint">{{ t("Shown instead of the raw id. Empty uses the id.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="position">Position</label>
|
||||
<label class="field__label" for="position">{{ t("Position") }}</label>
|
||||
<input class="input" id="position" name="position" type="number" min="1"
|
||||
max="{{ total }}" value="{{ position_of }}">
|
||||
<p class="field__hint">
|
||||
@@ -99,19 +96,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="context-length">Context length</label>
|
||||
<label class="field__label" for="context-length">{{ t("Context length") }}</label>
|
||||
<input class="input" id="context-length" name="context_length" type="number"
|
||||
min="0" step="1" placeholder="unknown"
|
||||
min="0" step="1" placeholder="{{ t('unknown') }}"
|
||||
value="{{ model.context_length or '' }}">
|
||||
<p class="field__hint">
|
||||
How many tokens this model can hold, filled in from the endpoint where
|
||||
it says. Leave it empty if you do not know: the context percentage and
|
||||
automatic compaction both stay off rather than working from a guess.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("How many tokens this model can hold, filled in from the endpoint where it says. Leave it empty if you do not know: the context percentage and automatic compaction both stay off rather than working from a guess.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Reasoning efforts this model accepts</span>
|
||||
<span class="field__label">{{ t("Reasoning efforts this model accepts") }}</span>
|
||||
<div class="btn-row">
|
||||
{% for value in efforts %}
|
||||
<label class="checkbox">
|
||||
@@ -162,14 +155,14 @@
|
||||
</button>
|
||||
|
||||
<p class="field__hint">
|
||||
The vocabulary is <strong>not the same for every model</strong>, and
|
||||
The vocabulary is <strong>{{ t("not the same for every model") }}</strong>, and
|
||||
sending one a model does not know is not ignored — it is rendered into
|
||||
the model's chat template, which raises and fails the whole reply.
|
||||
gpt-oss takes <span class="mono">low/medium/high</span>; Bonsai takes
|
||||
<span class="mono">low/medium/xhigh</span> and refuses
|
||||
<span class="mono">high</span>; OpenAI has added
|
||||
<span class="mono">minimal</span>, <span class="mono">xhigh</span> and
|
||||
<span class="mono">max</span> at various points.
|
||||
gpt-oss takes <span class="mono">{{ t("low/medium/high") }}</span>; Bonsai takes
|
||||
<span class="mono">{{ t("low/medium/xhigh") }}</span> and refuses
|
||||
<span class="mono">{{ t("high") }}</span>; OpenAI has added
|
||||
<span class="mono">{{ t("minimal") }}</span>, <span class="mono">{{ t("xhigh") }}</span> and
|
||||
<span class="mono">{{ t("max") }}</span> at various points.
|
||||
<br>
|
||||
Tick none and the common three are offered, which is right for almost
|
||||
everything. If an endpoint ever refuses one anyway, that reply is
|
||||
@@ -179,9 +172,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||||
<label class="field__label" for="default-effort">{{ t("Default reasoning effort") }}</label>
|
||||
<select class="select" id="default-effort" name="default_effort">
|
||||
<option value="">None — send nothing</option>
|
||||
<option value="">{{ t("None — send nothing") }}</option>
|
||||
{% for value in model_efforts %}
|
||||
<option value="{{ value }}"
|
||||
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||||
@@ -190,19 +183,19 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
A <em>seed</em>, not a per-request setting: it is copied onto a chat when
|
||||
A <em>{{ t("seed") }}</em>, not a per-request setting: it is copied onto a chat when
|
||||
the chat is created and when somebody switches to this model, and from
|
||||
then on the chat's own value is what is sent. Changing it here therefore
|
||||
does nothing to chats that already exist. The composer's picker shows
|
||||
whichever level is actually in force, so what somebody sees there is
|
||||
what goes out. Anyone can change it per chat with
|
||||
<span class="mono">/effort</span>, and the control only appears on a
|
||||
model marked <strong>Reasoning</strong> above.
|
||||
<span class="mono">{{ t("/effort") }}</span>, and the control only appears on a
|
||||
model marked <strong>{{ t("Reasoning") }}</strong> above.
|
||||
<br>
|
||||
Sent two ways at once, because there is no one field that works: OpenAI
|
||||
and vLLM read <span class="mono">reasoning_effort</span>, while
|
||||
and vLLM read <span class="mono">{{ t("reasoning_effort") }}</span>, while
|
||||
llama.cpp drops it silently and reads only
|
||||
<span class="mono">chat_template_kwargs</span> — which is the route by
|
||||
<span class="mono">{{ t("chat_template_kwargs") }}</span> — which is the route by
|
||||
which it reaches gpt-oss. Both go out, and only on a chat that has an
|
||||
effort set, so an endpoint strict about unknown parameters is untouched
|
||||
until somebody chooses one.
|
||||
@@ -210,9 +203,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<label class="field__label" for="description">{{ t("Description") }}</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="2"
|
||||
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
||||
placeholder="{{ t('What is this model good at?') }}">{{ model.description }}</textarea>
|
||||
<p class="field__hint">
|
||||
Shown in the chat settings panel and your users' settings — and, if any
|
||||
model here may ask another one a question, read by those models too.
|
||||
@@ -220,26 +213,18 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="notes">Facts for other models</label>
|
||||
<label class="field__label" for="notes">{{ t("Facts for other models") }}</label>
|
||||
<textarea class="textarea" id="notes" name="notes" rows="3"
|
||||
placeholder="Parameters, quantisation, a benchmark figure, what it is bad at">{{ model.notes }}</textarea>
|
||||
<p class="field__hint">
|
||||
Never shown to a person. It goes into the list of the other models that a
|
||||
model sees when it is allowed to ask one of them a question, so write what
|
||||
would help it choose: size, what this one is good and bad at, a score you
|
||||
trust. Leave it empty and the description above carries that on its own.
|
||||
</p>
|
||||
placeholder="{{ t('Parameters, quantisation, a benchmark figure, what it is bad at') }}">{{ model.notes }}</textarea>
|
||||
<p class="field__hint">{{ t("Never shown to a person. It goes into the list of the other models that a model sees when it is allowed to ask one of them a question, so write what would help it choose: size, what this one is good and bad at, a score you trust. Leave it empty and the description above carries that on its own.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">System prompt</h2>
|
||||
<p class="card__lede">
|
||||
Used by chats on this model that have no prompt of their own. A chat's own
|
||||
prompt overrides this; this overrides the instance prompt.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("System prompt") }}</h2>
|
||||
<p class="card__lede">{{ t("Used by chats on this model that have no prompt of their own. A chat's own prompt overrides this; this overrides the instance prompt.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
|
||||
<label class="field__label visually-hidden" for="system-prompt">{{ t("System prompt") }}</label>
|
||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
|
||||
>{{ model.system_prompt }}</textarea>
|
||||
@@ -254,14 +239,14 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Capabilities</h2>
|
||||
<h2 class="card__title">{{ t("Capabilities") }}</h2>
|
||||
<p class="card__lede">
|
||||
What this endpoint can do. Endpoints rarely advertise it reliably, so it
|
||||
is your call. <strong>reasoning</strong> shows the thinking block,
|
||||
<strong>vision</strong> lets images be sent, and <strong>tools</strong> is
|
||||
is your call. <strong>{{ t("reasoning") }}</strong> shows the thinking block,
|
||||
<strong>{{ t("vision") }}</strong> lets images be sent, and <strong>{{ t("tools") }}</strong> is
|
||||
whether a tool list may be sent at all — turn it on for a model that does
|
||||
not support tool calling and every one of its replies fails.
|
||||
<strong>embeddings</strong> is the odd one out: it says this is not a chat
|
||||
<strong>{{ t("embeddings") }}</strong> is the odd one out: it says this is not a chat
|
||||
model at all, but one that turns text into vectors, and it is what
|
||||
<a href="/admin/extraction">Extraction</a> picks from.
|
||||
</p>
|
||||
@@ -280,14 +265,14 @@
|
||||
<h2 class="card__title">
|
||||
Built-in tools
|
||||
{% if not (model.capabilities_json or {}).get("tools") %}
|
||||
<span class="badge">needs tools</span>
|
||||
<span class="badge">{{ t("needs tools") }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
What this model is given, as opposed to what it is capable of. None of it
|
||||
applies unless <strong>tools</strong> is ticked above. Each one is also
|
||||
applies unless <strong>{{ t("tools") }}</strong> is ticked above. Each one is also
|
||||
subject to the instance being configured for it and to the reader's own
|
||||
permissions — this only decides whether it is offered to <em>this</em>
|
||||
permissions — this only decides whether it is offered to <em>{{ t("this") }}</em>
|
||||
model.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
@@ -306,17 +291,17 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Availability</h2>
|
||||
<h2 class="card__title">{{ t("Availability") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
|
||||
<span>Enabled — offered in chats</span>
|
||||
<span>{{ t("Enabled — offered in chats") }}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
||||
<span>Pinned — shortcut in the chat sidebar</span>
|
||||
<span>{{ t("Pinned — shortcut in the chat sidebar") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,16 +309,13 @@
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
||||
<span>Available to everyone</span>
|
||||
<span>{{ t("Available to everyone") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Uncheck to restrict this model to chosen groups. Administrators always
|
||||
have access.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Uncheck to restrict this model to chosen groups. Administrators always have access.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Groups with access</span>
|
||||
<span class="field__label">{{ t("Groups with access") }}</span>
|
||||
{% if groups %}
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
@@ -344,7 +326,7 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field__hint">Ignored while the model is available to everyone.</p>
|
||||
<p class="field__hint">{{ t("Ignored while the model is available to everyone.") }}</p>
|
||||
{% else %}
|
||||
<p class="field__hint">
|
||||
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
||||
@@ -354,7 +336,7 @@
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save changes") }}</button>
|
||||
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -362,28 +344,28 @@
|
||||
{# Outside the form above, and it has to be: two forms cannot nest, and this one
|
||||
posts somewhere else. See the note beside the Detect button. #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Default personality</h2>
|
||||
<h2 class="card__title">{{ t("Default personality") }}</h2>
|
||||
<p class="card__lede">
|
||||
Who this model is before it has worked out who it is with somebody. Different
|
||||
from the system prompt above: that is an instruction you write, this is a
|
||||
character it can be — and, with <strong>Edit its own personality</strong>
|
||||
character it can be — and, with <strong>{{ t("Edit its own personality") }}</strong>
|
||||
ticked, one it rewrites for itself.
|
||||
</p>
|
||||
<p class="card__lede">
|
||||
<strong>A personality belongs to a person.</strong> Each account gets its own
|
||||
<strong>{{ t("A personality belongs to a person.") }}</strong> Each account gets its own
|
||||
version of this model's character, starting from what you write here and
|
||||
diverging from it the first time the model writes its own. Changing this
|
||||
afterwards does not reach anybody who already has one, and it is not stacked
|
||||
underneath theirs — two personalities at once would contradict each other and
|
||||
nobody could tell which was losing. What the model *is*, as opposed to who it
|
||||
has become with somebody, belongs in <strong>Description</strong> and
|
||||
<strong>Facts for other models</strong> above, which are the same for everyone.
|
||||
has become with somebody, belongs in <strong>{{ t("Description") }}</strong> and
|
||||
<strong>{{ t("Facts for other models") }}</strong> above, which are the same for everyone.
|
||||
</p>
|
||||
<form method="post" action="/admin/models/{{ model.id }}/persona">
|
||||
<div class="field">
|
||||
<label class="field__label visually-hidden" for="persona">Default personality</label>
|
||||
<label class="field__label visually-hidden" for="persona">{{ t("Default personality") }}</label>
|
||||
<textarea class="textarea" id="persona" name="content" rows="6"
|
||||
placeholder="Nothing yet. Write one, or let the model write its own."
|
||||
placeholder="{{ t('Nothing yet. Write one, or let the model write its own.') }}"
|
||||
>{{ persona.content if persona else "" }}</textarea>
|
||||
<p class="field__hint">
|
||||
Up to {{ persona_limit }} characters, in the first person. Empty removes it
|
||||
@@ -392,11 +374,11 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit">Save personality</button>
|
||||
<button class="btn" type="submit">{{ t("Save personality") }}</button>
|
||||
{% if persona and persona.author == "model" %}
|
||||
<span class="badge badge--leaf">last written by the model</span>
|
||||
<span class="badge badge--leaf">{{ t("last written by the model") }}</span>
|
||||
{% elif persona %}
|
||||
<span class="badge">last written here</span>
|
||||
<span class="badge">{{ t("last written here") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -407,20 +389,16 @@
|
||||
<h2 class="card__title">
|
||||
Earlier defaults <span class="badge">{{ persona.revisions|length }}</span>
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
What this default said before each change. Each person's own personality keeps
|
||||
its own history, which they can see and restore in their own settings — this is
|
||||
the starting point's history, not theirs.
|
||||
</p>
|
||||
<p class="card__lede">{{ t("What this default said before each change. Each person's own personality keeps its own history, which they can see and restore in their own settings — this is the starting point's history, not theirs.") }}</p>
|
||||
<ul class="model-list">
|
||||
{% for revision in persona.revisions %}
|
||||
<li class="model-list__item">
|
||||
<div style="min-width: 0">
|
||||
<strong>{{ revision.created_at.strftime("%Y-%m-%d %H:%M") }}</strong>
|
||||
{% if revision.author == "model" %}
|
||||
<span class="badge badge--leaf">model</span>
|
||||
<span class="badge badge--leaf">{{ t("model") }}</span>
|
||||
{% else %}
|
||||
<span class="badge">you</span>
|
||||
<span class="badge">{{ t("you") }}</span>
|
||||
{% endif %}
|
||||
{% if revision.note %}<div class="text-xs faint">{{ revision.note }}</div>{% endif %}
|
||||
<div class="text-xs faint">
|
||||
@@ -428,8 +406,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/models/{{ model.id }}/persona/revert"
|
||||
data-confirm="Put this version back? The current one is kept in the history."
|
||||
data-confirm-label="Restore" data-confirm-danger="false">
|
||||
data-confirm="{{ t('Put this version back? The current one is kept in the history.') }}"
|
||||
data-confirm-label="{{ t('Restore') }}" data-confirm-danger="false">
|
||||
<input type="hidden" name="revision_id" value="{{ revision.id }}">
|
||||
<button class="btn btn--sm" type="submit">
|
||||
{{ icon("refresh", "icon--sm") }} Restore
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Every model discovered across your connections. The order here is the order
|
||||
users see in the picker. <strong>Pinning</strong> does not reorder anything —
|
||||
it puts a shortcut in the chat sidebar. The <strong>default</strong> is what a
|
||||
users see in the picker. <strong>{{ t("Pinning") }}</strong> does not reorder anything —
|
||||
it puts a shortcut in the chat sidebar. The <strong>{{ t("default") }}</strong> is what a
|
||||
new chat starts with.
|
||||
</p>
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
<form class="filter-form" method="get" action="/admin/models">
|
||||
<input type="hidden" name="filter" value="{{ active_filter }}">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search models…" aria-label="Search models">
|
||||
<select class="select" name="connection" aria-label="Connection">
|
||||
<option value="">All connections</option>
|
||||
placeholder="{{ t('Search models…') }}" aria-label="{{ t('Search models') }}">
|
||||
<select class="select" name="connection" aria-label="{{ t('Connection') }}">
|
||||
<option value="">{{ t("All connections") }}</option>
|
||||
{% for conn in connections %}
|
||||
<option value="{{ conn.id }}" {{ 'selected' if conn.id == connection_id }}>
|
||||
{{ conn.name }}
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
{% if not models %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">Nothing matches that filter.</p>
|
||||
<p class="empty__text">{{ t("Nothing matches that filter.") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -67,15 +67,15 @@
|
||||
<div class="bulk-bar">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" data-select-all="#model-rows"
|
||||
aria-label="Select every model on this page">
|
||||
<span class="bulk-bar__label">Select all</span>
|
||||
aria-label="{{ t('Select every model on this page') }}">
|
||||
<span class="bulk-bar__label">{{ t("Select all") }}</span>
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<span class="bulk-bar__label">With selected:</span>
|
||||
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
||||
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
||||
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
|
||||
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
||||
<span class="bulk-bar__label">{{ t("With selected:") }}</span>
|
||||
<button class="btn btn--sm" name="action" value="enable" type="submit">{{ t("Enable") }}</button>
|
||||
<button class="btn btn--sm" name="action" value="disable" type="submit">{{ t("Disable") }}</button>
|
||||
<button class="btn btn--sm" name="action" value="public" type="submit">{{ t("Make public") }}</button>
|
||||
<button class="btn btn--sm" name="action" value="private" type="submit">{{ t("Restrict") }}</button>
|
||||
</div>
|
||||
|
||||
<div class="model-rows" id="model-rows">
|
||||
@@ -92,11 +92,11 @@
|
||||
<div class="model-row__title">
|
||||
<a class="model-row__name" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
|
||||
{% if model.model_id == default_model %}
|
||||
<span class="badge badge--leaf">default</span>
|
||||
<span class="badge badge--leaf">{{ t("default") }}</span>
|
||||
{% endif %}
|
||||
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
||||
{% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||
{% if not model.public %}<span class="badge">restricted</span>{% endif %}
|
||||
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
|
||||
{% if not model.enabled %}<span class="badge badge--danger">{{ t("disabled") }}</span>{% endif %}
|
||||
{% if not model.public %}<span class="badge">{{ t("restricted") }}</span>{% endif %}
|
||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||
{% if on %}<span class="badge badge--leaf">{{ name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
@@ -106,12 +106,12 @@
|
||||
|
||||
<div class="model-row__actions">
|
||||
{# formaction lets these post elsewhere without nesting a second form. #}
|
||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move up"
|
||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="{{ t('Move up') }}"
|
||||
formaction="/admin/models/{{ model.id }}/move" name="direction" value="up"
|
||||
{{ 'disabled' if page == 1 and loop.first }}>
|
||||
{{ icon("arrow-up", "icon--sm") }}
|
||||
</button>
|
||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move down"
|
||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="{{ t('Move down') }}"
|
||||
formaction="/admin/models/{{ model.id }}/move" name="direction" value="down"
|
||||
{{ 'disabled' if page == pages and loop.last }}>
|
||||
{{ icon("arrow-down", "icon--sm") }}
|
||||
@@ -135,13 +135,13 @@
|
||||
{% if page > 1 %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">{{ t("Previous") }}</span>
|
||||
{% endif %}
|
||||
<span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
|
||||
{% if page < pages %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">{{ t("Next") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
use each tool, what it has been asked to remember. These sit above whichever
|
||||
system prompt was authored for the instance, the model or the chat — that
|
||||
prompt still wins where the two disagree. Clear a box to leave that piece out
|
||||
altogether; press <strong>Use default</strong> to put the built-in wording back.
|
||||
altogether; press <strong>{{ t("Use default") }}</strong> to put the built-in wording back.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Prompts saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Prompts saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
@@ -39,7 +39,7 @@
|
||||
#}
|
||||
<form method="post" action="/admin/prompts" id="prompt-form">
|
||||
<div class="tabs">
|
||||
<div class="tabs__bar" role="radiogroup" aria-label="Prompt groups">
|
||||
<div class="tabs__bar" role="radiogroup" aria-label="{{ t('Prompt groups') }}">
|
||||
{% for key, label, fragments in groups %}
|
||||
<input class="visually-hidden" type="radio" name="prompts-tab"
|
||||
id="tab-{{ key }}" {{ 'checked' if loop.first }}>
|
||||
@@ -61,9 +61,9 @@
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Length</h2>
|
||||
<h2 class="card__title">{{ t("Length") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max-harness-chars">Preamble character cap</label>
|
||||
<label class="field__label" for="max-harness-chars">{{ t("Preamble character cap") }}</label>
|
||||
<input class="input" id="max-harness-chars" name="max_harness_chars" type="number"
|
||||
min="0" max="100000" value="{{ max_harness_chars }}">
|
||||
<p class="field__hint">
|
||||
@@ -75,22 +75,20 @@
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn--primary" type="submit">Save settings</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save settings") }}</button>
|
||||
{#
|
||||
data-confirm-button, not data-confirm: this button acts on its own through
|
||||
formaction, and confirming the whole form would also catch plain Save.
|
||||
#}
|
||||
<button class="btn" type="submit" formaction="/admin/prompts/reset"
|
||||
data-confirm-button="Put every prompt back to its built-in wording? Everything you have edited here is lost."
|
||||
data-confirm-title="Restore defaults" data-confirm-label="Restore">
|
||||
Restore all defaults
|
||||
</button>
|
||||
data-confirm-button="{{ t('Put every prompt back to its built-in wording? Everything you have edited here is lost.') }}"
|
||||
data-confirm-title="{{ t('Restore defaults') }}" data-confirm-label="{{ t('Restore') }}">{{ t("Restore all defaults") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Variables</h2>
|
||||
<h2 class="card__title">{{ t("Variables") }}</h2>
|
||||
<p class="card__lede">
|
||||
Write these in double braces. Anything in double braces that is not on this
|
||||
list is left exactly as you typed it — variable names are lowercase letters,
|
||||
@@ -105,11 +103,11 @@
|
||||
<span>{{ variable.description }}</span>
|
||||
<span class="ref-row__value">
|
||||
{% if variable.name not in resolved %}
|
||||
<em>only in the title request</em>
|
||||
<em>{{ t("only in the title request") }}</em>
|
||||
{% elif resolved[variable.name] %}
|
||||
{{ resolved[variable.name] | truncate(80) }}
|
||||
{% else %}
|
||||
<em>empty</em>
|
||||
<em>{{ t("empty") }}</em>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -124,32 +122,27 @@
|
||||
replaced -- an outerHTML swap would re-fire it and loop forever.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Preview</h2>
|
||||
<p class="card__lede">
|
||||
The whole system message, assembled from what is in the boxes below —
|
||||
including changes you have not saved yet. Your own memories and skills are
|
||||
used, because a preview against invented ones cannot tell you whether it
|
||||
reads well against what is actually there.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Preview") }}</h2>
|
||||
<p class="card__lede">{{ t("The whole system message, assembled from what is in the boxes below — including changes you have not saved yet. Your own memories and skills are used, because a preview against invented ones cannot tell you whether it reads well against what is actually there.") }}</p>
|
||||
|
||||
<div class="grid grid--2" id="preview-controls">
|
||||
<div class="field">
|
||||
<label class="field__label" for="preview-model">Answering model</label>
|
||||
<label class="field__label" for="preview-model">{{ t("Answering model") }}</label>
|
||||
<select class="select" id="preview-model" name="preview_model">
|
||||
{% for model in models %}
|
||||
<option value="{{ model.label }}">{{ model.label }}</option>
|
||||
{% else %}
|
||||
<option value="">No models configured</option>
|
||||
<option value="">{{ t("No models configured") }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="preview-bases">Knowledge bases in scope</label>
|
||||
<label class="field__label" for="preview-bases">{{ t("Knowledge bases in scope") }}</label>
|
||||
<input class="input" id="preview-bases" name="preview_bases" value="Contracts, Recipes">
|
||||
<p class="field__hint">Empty means a chat that can see everything.</p>
|
||||
<p class="field__hint">{{ t("Empty means a chat that can see everything.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="preview-documents">Attached files</label>
|
||||
<label class="field__label" for="preview-documents">{{ t("Attached files") }}</label>
|
||||
<input class="input" id="preview-documents" name="preview_documents"
|
||||
value="{{ sample_documents }}">
|
||||
</div>
|
||||
@@ -161,7 +154,7 @@
|
||||
filled when there is one.
|
||||
#}
|
||||
<div class="field">
|
||||
<label class="field__label" for="preview-situation">This chat is</label>
|
||||
<label class="field__label" for="preview-situation">{{ t("This chat is") }}</label>
|
||||
<select class="select" id="preview-situation" name="preview_situation">
|
||||
{% for value, label in situations %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
@@ -169,7 +162,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="preview-mode">Agent mode</label>
|
||||
<label class="field__label" for="preview-mode">{{ t("Agent mode") }}</label>
|
||||
<select class="select" id="preview-mode" name="preview_mode">
|
||||
{% for value, label in modes.items() %}
|
||||
<option value="{{ value }}"{{ ' selected' if value == 'edit' }}>{{ label }}</option>
|
||||
@@ -181,7 +174,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Tools offered</span>
|
||||
<span class="field__label">{{ t("Tools offered") }}</span>
|
||||
{% for family in families %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="preview_family" value="{{ family }}" checked>
|
||||
@@ -206,14 +199,8 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Tool descriptions</h2>
|
||||
<p class="card__lede">
|
||||
Defined in code — part of the schema sent to the endpoint alongside the
|
||||
prompt, not guidance layered on top of it. They are statements of fact about
|
||||
what each tool does, so they change when the tool does; editing them here
|
||||
would let the text quietly become a lie. A custom tool's description will be
|
||||
editable, because a custom tool is a row rather than a function.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Tool descriptions") }}</h2>
|
||||
<p class="card__lede">{{ t("Defined in code — part of the schema sent to the endpoint alongside the prompt, not guidance layered on top of it. They are statements of fact about what each tool does, so they change when the tool does; editing them here would let the text quietly become a lie. A custom tool's description will be editable, because a custom tool is a row rather than a function.") }}</p>
|
||||
<div class="ref-list">
|
||||
{% for tool in registry %}
|
||||
<div class="ref-row">
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
A <strong>schedule</strong> runs a piece of work because time has passed
|
||||
A <strong>{{ t("schedule") }}</strong> runs a piece of work because time has passed
|
||||
rather than because somebody asked just now — a daily summary, a check every
|
||||
Monday, a reminder in an hour. Each one has its own chat and replies into it,
|
||||
or files a report. People make their own under <strong>Scheduled</strong>;
|
||||
or files a report. People make their own under <strong>{{ t("Scheduled") }}</strong>;
|
||||
what you decide here is whether the feature exists and what it may spend.
|
||||
</p>
|
||||
|
||||
@@ -20,24 +20,24 @@
|
||||
This is the one thing here that spends model time with nobody watching. A
|
||||
schedule pointed at a chat that can run commands would run them unattended,
|
||||
so a scheduled task is never an agent chat — but everything else a model can
|
||||
reach, it can reach on a timer. Give <strong>Schedule work</strong> under
|
||||
reach, it can reach on a timer. Give <strong>{{ t("Schedule work") }}</strong> under
|
||||
Groups & permissions to the people who should have it; it is off for
|
||||
everybody by default.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/schedules" class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Switch</h2>
|
||||
<h2 class="card__title">{{ t("Switch") }}</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||||
<span>Let scheduled work run</span>
|
||||
<span>{{ t("Let scheduled work run") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off by default. Turning this off stops everything firing and deletes
|
||||
@@ -53,65 +53,48 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">How often it looks</h2>
|
||||
<h2 class="card__title">{{ t("How often it looks") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tick">Check for due work every</label>
|
||||
<label class="field__label" for="tick">{{ t("Check for due work every") }}</label>
|
||||
<input class="input" type="number" id="tick" name="tick_seconds" min="5" max="300"
|
||||
value="{{ values.tick_seconds }}">
|
||||
<p class="field__hint">
|
||||
Seconds. This is how late a run can be, not how often anything happens:
|
||||
the finest a schedule can be set to is one minute, so anything under
|
||||
that buys nothing. One indexed query per tick.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Seconds. This is how late a run can be, not how often anything happens: the finest a schedule can be set to is one minute, so anything under that buys nothing. One indexed query per tick.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="min-interval">Nothing may repeat faster than</label>
|
||||
<label class="field__label" for="min-interval">{{ t("Nothing may repeat faster than") }}</label>
|
||||
<input class="input" type="number" id="min-interval" name="min_interval_seconds"
|
||||
min="60" max="86400" value="{{ values.min_interval_seconds }}">
|
||||
<p class="field__hint">
|
||||
Seconds. A floor on how often one schedule may come round. Raise it if
|
||||
people are setting things to run more often than the work takes.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Seconds. A floor on how often one schedule may come round. Raise it if people are setting things to run more often than the work takes.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What it may spend</h2>
|
||||
<h2 class="card__title">{{ t("What it may spend") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="per-user">Schedules per person</label>
|
||||
<label class="field__label" for="per-user">{{ t("Schedules per person") }}</label>
|
||||
<input class="input" type="number" id="per-user" name="max_per_user" min="1" max="200"
|
||||
value="{{ values.max_per_user }}">
|
||||
<p class="field__hint">
|
||||
Refused at the point of creation, with the reason. Existing schedules
|
||||
over a lowered limit keep running; only new ones are refused.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Refused at the point of creation, with the reason. Existing schedules over a lowered limit keep running; only new ones are refused.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="concurrent">Runs at once</label>
|
||||
<label class="field__label" for="concurrent">{{ t("Runs at once") }}</label>
|
||||
<input class="input" type="number" id="concurrent" name="max_concurrent" min="1" max="20"
|
||||
value="{{ values.max_concurrent }}">
|
||||
<p class="field__hint">
|
||||
Fifty schedules due at nine o'clock must not open fifty replies against
|
||||
one endpoint. The rest wait their turn rather than being dropped.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Fifty schedules due at nine o'clock must not open fifty replies against one endpoint. The rest wait their turn rather than being dropped.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="queued">Turns that may pile up in one chat</label>
|
||||
<label class="field__label" for="queued">{{ t("Turns that may pile up in one chat") }}</label>
|
||||
<input class="input" type="number" id="queued" name="max_queued" min="1" max="50"
|
||||
value="{{ values.max_queued }}">
|
||||
<p class="field__hint">
|
||||
A schedule that comes round faster than its chat can answer would build
|
||||
a backlog for ever. Past this, a run is skipped and says so on the
|
||||
schedule rather than joining the queue.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("A schedule that comes round faster than its chat can answer would build a backlog for ever. Past this, a run is skipped and says so on the schedule rather than joining the queue.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,28 +6,23 @@
|
||||
{% block heading %}Web search{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Lets a model look things up while it answers. It is offered as a tool the
|
||||
model chooses to call, so nothing changes for a question that does not need
|
||||
it — and it is only offered to models marked as supporting tools, because
|
||||
sending a tool list to one that does not fails the whole request.
|
||||
</p>
|
||||
<p class="admin-lede">{{ t("Lets a model look things up while it answers. It is offered as a tool the model chooses to call, so nothing changes for a question that does not need it — and it is only offered to models marked as supporting tools, because sending a tool list to one that does not fails the whole request.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Search settings saved.</span></div>
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Search settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/search">
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Web search
|
||||
{% if values.enabled %}<span class="badge badge--success">on</span>
|
||||
{% else %}<span class="badge">off</span>{% endif %}
|
||||
{% if values.enabled %}<span class="badge badge--success">{{ t("on") }}</span>
|
||||
{% else %}<span class="badge">{{ t("off") }}</span>{% endif %}
|
||||
</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||||
<span>Offer web search to models that support tools</span>
|
||||
<span>{{ t("Offer web search to models that support tools") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Who may use it is a permission — <code>tools.web_search</code> under
|
||||
@@ -37,7 +32,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Provider</h2>
|
||||
<h2 class="card__title">{{ t("Provider") }}</h2>
|
||||
<div class="field">
|
||||
{% for provider in providers %}
|
||||
<label class="checkbox" style="align-items: flex-start">
|
||||
@@ -45,7 +40,7 @@
|
||||
{{ 'checked' if values.provider == provider.key }}>
|
||||
<span>
|
||||
<strong>{{ provider.label }}</strong>
|
||||
{% if not provider.needs_setup %}<span class="badge">no setup</span>{% endif %}
|
||||
{% if not provider.needs_setup %}<span class="badge">{{ t("no setup") }}</span>{% endif %}
|
||||
<div class="text-xs faint">{{ provider.description }}</div>
|
||||
{% if problems[provider.key] %}
|
||||
<div class="text-xs" style="color: var(--danger)">{{ problems[provider.key] }}</div>
|
||||
@@ -57,13 +52,13 @@
|
||||
|
||||
<div class="grid grid--3">
|
||||
<div class="field">
|
||||
<label class="field__label" for="max-results">Results per search</label>
|
||||
<label class="field__label" for="max-results">{{ t("Results per search") }}</label>
|
||||
<input class="input" id="max-results" name="max_results" type="number"
|
||||
min="1" max="20" value="{{ values.max_results }}">
|
||||
<p class="field__hint">A ceiling — a model asking for more gets this.</p>
|
||||
<p class="field__hint">{{ t("A ceiling — a model asking for more gets this.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="safesearch">Safe search</label>
|
||||
<label class="field__label" for="safesearch">{{ t("Safe search") }}</label>
|
||||
<select class="select" id="safesearch" name="safesearch">
|
||||
{% for option in safesearch_options %}
|
||||
<option value="{{ option }}" {{ 'selected' if option == values.safesearch }}>
|
||||
@@ -73,16 +68,16 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="timeout">Timeout (seconds)</label>
|
||||
<label class="field__label" for="timeout">{{ t("Timeout (seconds)") }}</label>
|
||||
<input class="input" id="timeout" name="timeout" type="number"
|
||||
min="5" max="120" step="1" value="{{ values.timeout }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="region">DuckDuckGo region</label>
|
||||
<label class="field__label" for="region">{{ t("DuckDuckGo region") }}</label>
|
||||
<input class="input" id="region" name="region" maxlength="16"
|
||||
value="{{ values.region }}" placeholder="wt-wt">
|
||||
value="{{ values.region }}" placeholder="{{ t('wt-wt') }}">
|
||||
<p class="field__hint">
|
||||
<code>wt-wt</code> is no region at all. <code>uk-en</code>,
|
||||
<code>de-de</code> and so on bias results to a country.
|
||||
@@ -91,15 +86,12 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">SearXNG</h2>
|
||||
<p class="card__lede">
|
||||
Only used when SearXNG is the chosen provider. Your own instance, so no
|
||||
third party sees the queries.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("SearXNG") }}</h2>
|
||||
<p class="card__lede">{{ t("Only used when SearXNG is the chosen provider. Your own instance, so no third party sees the queries.") }}</p>
|
||||
<div class="field">
|
||||
<label class="field__label" for="searxng-base-url">Instance URL</label>
|
||||
<label class="field__label" for="searxng-base-url">{{ t("Instance URL") }}</label>
|
||||
<input class="input" id="searxng-base-url" name="searxng_base_url" type="url"
|
||||
value="{{ values.searxng_base_url }}" placeholder="http://127.0.0.1:8888">
|
||||
value="{{ values.searxng_base_url }}" placeholder="{{ t('http://127.0.0.1:8888') }}">
|
||||
<p class="field__hint">
|
||||
A stock SearXNG refuses JSON. Add <code>- json</code> under
|
||||
<code>search.formats</code> in its <code>settings.yml</code> and restart
|
||||
@@ -109,16 +101,16 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Saving links</h2>
|
||||
<h2 class="card__title">{{ t("Saving links") }}</h2>
|
||||
<p class="card__lede">
|
||||
Applies to the composer's <strong>Link</strong> option and to anything the
|
||||
Applies to the composer's <strong>{{ t("Link") }}</strong> option and to anything the
|
||||
model fetches: {{ brand.name }} retrieves the page and keeps its text.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="fetch_enabled" value="true"
|
||||
{{ 'checked' if values.fetch_enabled }}>
|
||||
<span>Let a model fetch a page itself</span>
|
||||
<span>{{ t("Let a model fetch a page itself") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Offers the <code>fetch</code> tool, so a model can read an address it
|
||||
@@ -132,7 +124,7 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allow_private_fetch" value="true"
|
||||
{{ 'checked' if values.allow_private_fetch }}>
|
||||
<span>Allow fetching addresses on this machine and this network</span>
|
||||
<span>{{ t("Allow fetching addresses on this machine and this network") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off by default, and worth leaving off. This server can reach your
|
||||
@@ -145,36 +137,36 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Firecrawl</h2>
|
||||
<h2 class="card__title">{{ t("Firecrawl") }}</h2>
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="firecrawl-base-url">API URL</label>
|
||||
<label class="field__label" for="firecrawl-base-url">{{ t("API URL") }}</label>
|
||||
<input class="input" id="firecrawl-base-url" name="firecrawl_base_url" type="url"
|
||||
value="{{ values.firecrawl_base_url }}"
|
||||
placeholder="https://api.firecrawl.dev">
|
||||
<p class="field__hint">Change only for a self-hosted Firecrawl.</p>
|
||||
placeholder="{{ t('https://api.firecrawl.dev') }}">
|
||||
<p class="field__hint">{{ t("Change only for a self-hosted Firecrawl.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="firecrawl-api-key">API key</label>
|
||||
<label class="field__label" for="firecrawl-api-key">{{ t("API key") }}</label>
|
||||
<input class="input" id="firecrawl-api-key" name="firecrawl_api_key" type="password"
|
||||
value="{{ unchanged if masked else '' }}"
|
||||
placeholder="{{ masked or 'fc-...' }}" autocomplete="off">
|
||||
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
|
||||
<p class="field__hint">{{ t("Encrypted at rest. Clear the field to remove it.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||
<div class="form-actions"><button class="btn btn--primary" type="submit">{{ t("Save settings") }}</button></div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Try it</h2>
|
||||
<h2 class="card__title">{{ t("Try it") }}</h2>
|
||||
<p class="card__lede">
|
||||
Runs a real search against the <em>saved</em> settings, which is what a chat
|
||||
Runs a real search against the <em>{{ t("saved") }}</em> settings, which is what a chat
|
||||
would do. Save first if you have just changed something.
|
||||
</p>
|
||||
<div class="row" style="gap: var(--sp-2)">
|
||||
<input class="input" id="test-query" name="query" placeholder="mallorn tree"
|
||||
<input class="input" id="test-query" name="query" placeholder="{{ t('mallorn tree') }}"
|
||||
style="flex: 1">
|
||||
<button class="btn" type="button" hx-post="/admin/search/test"
|
||||
hx-include="#test-query" hx-target="#search-test" hx-swap="outerHTML">
|
||||
|
||||
@@ -22,93 +22,88 @@
|
||||
<div class="card__header">
|
||||
<h2 class="card__title">
|
||||
{{ suggestion.name }}
|
||||
{% if not suggestion.enabled %}<span class="badge">hidden</span>{% endif %}
|
||||
{% if not suggestion.enabled %}<span class="badge">{{ t("hidden") }}</span>{% endif %}
|
||||
{% if loop.index > max_shown and suggestion.enabled %}
|
||||
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">
|
||||
below the cut
|
||||
</span>
|
||||
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">{{ t("below the cut") }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
|
||||
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
|
||||
data-confirm-title="Delete suggestion"
|
||||
aria-label="Delete suggestion" title="Delete suggestion">
|
||||
data-confirm-title="{{ t('Delete suggestion') }}"
|
||||
aria-label="{{ t('Delete suggestion') }}" title="{{ t('Delete suggestion') }}">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name-{{ suggestion.id }}">Name</label>
|
||||
<label class="field__label" for="name-{{ suggestion.id }}">{{ t("Name") }}</label>
|
||||
<input class="input" id="name-{{ suggestion.id }}" name="name"
|
||||
value="{{ suggestion.name }}" maxlength="120">
|
||||
<p class="field__hint">The heading on the card.</p>
|
||||
<p class="field__hint">{{ t("The heading on the card.") }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="position-{{ suggestion.id }}">Position</label>
|
||||
<label class="field__label" for="position-{{ suggestion.id }}">{{ t("Position") }}</label>
|
||||
<input class="input" id="position-{{ suggestion.id }}" name="position" type="number"
|
||||
min="1" value="{{ suggestion.position + 1 }}">
|
||||
<p class="field__hint">Order on the screen, lowest first.</p>
|
||||
<p class="field__hint">{{ t("Order on the screen, lowest first.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description-{{ suggestion.id }}">Description</label>
|
||||
<label class="field__label" for="description-{{ suggestion.id }}">{{ t("Description") }}</label>
|
||||
<input class="input" id="description-{{ suggestion.id }}" name="description"
|
||||
value="{{ suggestion.description }}" maxlength="300">
|
||||
<p class="field__hint">One line under the name, saying what it is for.</p>
|
||||
<p class="field__hint">{{ t("One line under the name, saying what it is for.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="prompt-{{ suggestion.id }}">Prompt</label>
|
||||
<label class="field__label" for="prompt-{{ suggestion.id }}">{{ t("Prompt") }}</label>
|
||||
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
|
||||
rows="4">{{ suggestion.prompt }}</textarea>
|
||||
<p class="field__hint">
|
||||
Sent as the first message. Nothing is added to it, so a prompt that needs
|
||||
material should ask for it.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Sent as the first message. Nothing is added to it, so a prompt that needs material should ask for it.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if suggestion.enabled }}>
|
||||
<span>Show this one</span>
|
||||
<span>{{ t("Show this one") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Save</button>
|
||||
<button class="btn btn--primary btn--sm" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
{{ icon("sparkle", "empty__mark") }}
|
||||
<h2 class="empty__title">No suggestions</h2>
|
||||
<p class="empty__text">The new-chat screen shows its empty state instead.</p>
|
||||
<h2 class="empty__title">{{ t("No suggestions") }}</h2>
|
||||
<p class="empty__text">{{ t("The new-chat screen shows its empty state instead.") }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not at_limit %}
|
||||
<form class="card" method="post" action="/admin/suggestions">
|
||||
<h2 class="card__title">Add a suggestion</h2>
|
||||
<h2 class="card__title">{{ t("Add a suggestion") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<label class="field__label" for="new-name">{{ t("Name") }}</label>
|
||||
<input class="input" id="new-name" name="name" maxlength="120"
|
||||
placeholder="Summarise a document" required>
|
||||
placeholder="{{ t('Summarise a document') }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-description">Description</label>
|
||||
<label class="field__label" for="new-description">{{ t("Description") }}</label>
|
||||
<input class="input" id="new-description" name="description" maxlength="300"
|
||||
placeholder="What it is for, in one line.">
|
||||
placeholder="{{ t('What it is for, in one line.') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-prompt">Prompt</label>
|
||||
<label class="field__label" for="new-prompt">{{ t("Prompt") }}</label>
|
||||
<textarea class="textarea" id="new-prompt" name="prompt" rows="4"
|
||||
placeholder="Sent as the first message when the card is clicked."></textarea>
|
||||
placeholder="{{ t('Sent as the first message when the card is clicked.') }}"></textarea>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Add</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Add") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
@@ -20,19 +20,19 @@
|
||||
class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What it is</h2>
|
||||
<h2 class="card__title">{{ t("What it is") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ tool.name }}" required
|
||||
maxlength="120" placeholder="Weather">
|
||||
<p class="field__hint">Shown in the transcript when the model uses it.</p>
|
||||
maxlength="120" placeholder="{{ t('Weather') }}">
|
||||
<p class="field__hint">{{ t("Shown in the transcript when the model uses it.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="slug">Identifier</label>
|
||||
<label class="field__label" for="slug">{{ t("Identifier") }}</label>
|
||||
<input class="input input--mono" id="slug" name="slug" value="{{ tool.slug }}" required
|
||||
maxlength="48" placeholder="weather" pattern="[a-z0-9][a-z0-9_\-]*">
|
||||
maxlength="48" placeholder="{{ t('weather') }}" pattern="[a-z0-9][a-z0-9_\-]*">
|
||||
<p class="field__hint">
|
||||
The name the model calls, and the key its guidance is stored under.
|
||||
Lowercase letters, digits, hyphens and underscores.
|
||||
@@ -41,19 +41,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<label class="field__label" for="description">{{ t("Description") }}</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="3"
|
||||
placeholder="Look up the current weather for a city."
|
||||
placeholder="{{ t('Look up the current weather for a city.') }}"
|
||||
>{{ tool.description }}</textarea>
|
||||
<p class="field__hint">
|
||||
Sent to the model verbatim. This is the whole basis on which it decides
|
||||
whether to call this tool, so say what it does and when it is the right
|
||||
thing to use.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Sent to the model verbatim. This is the whole basis on which it decides whether to call this tool, so say what it does and when it is the right thing to use.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="parameters">Parameters</label>
|
||||
<label class="field__label" for="parameters">{{ t("Parameters") }}</label>
|
||||
<textarea class="textarea input--mono" id="parameters" name="parameters" rows="10"
|
||||
spellcheck="false">{{ parameters_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
@@ -66,10 +62,10 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The call</h2>
|
||||
<h2 class="card__title">{{ t("The call") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="method">Method</label>
|
||||
<label class="field__label" for="method">{{ t("Method") }}</label>
|
||||
<select class="select" id="method" name="method">
|
||||
{% for method in methods %}
|
||||
<option value="{{ method }}" {{ 'selected' if method == tool.method }}>{{ method }}</option>
|
||||
@@ -78,46 +74,39 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="url_template">URL</label>
|
||||
<label class="field__label" for="url_template">{{ t("URL") }}</label>
|
||||
<input class="input input--mono" id="url_template" name="url_template" required
|
||||
value="{{ tool.url_template }}"
|
||||
placeholder="https://api.example.com/weather/{{ '{{city}}' }}">
|
||||
<p class="field__hint">
|
||||
Placeholders are filled from the arguments and escaped, so a value
|
||||
cannot add a path segment or a query of its own. The scheme and host
|
||||
must be written out — they cannot come from an argument.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Placeholders are filled from the arguments and escaped, so a value cannot add a path segment or a query of its own. The scheme and host must be written out — they cannot come from an argument.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="headers">Headers</label>
|
||||
<label class="field__label" for="headers">{{ t("Headers") }}</label>
|
||||
<textarea class="textarea input--mono" id="headers" name="headers" rows="3"
|
||||
spellcheck="false"
|
||||
placeholder="Accept: application/json">{{ headers_text }}</textarea>
|
||||
placeholder="{{ t('Accept: application/json') }}">{{ headers_text }}</textarea>
|
||||
<p class="field__hint">One <code>Name: value</code> per line. Placeholders work here too.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="body_template">Body</label>
|
||||
<label class="field__label" for="body_template">{{ t("Body") }}</label>
|
||||
<textarea class="textarea input--mono" id="body_template" name="body_template" rows="4"
|
||||
spellcheck="false">{{ tool.body_template }}</textarea>
|
||||
<p class="field__hint">
|
||||
Ignored for GET. Placeholders are escaped for JSON, so a value cannot
|
||||
end the string it sits in and add a field.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Ignored for GET. Placeholders are escaped for JSON, so a value cannot end the string it sits in and add a field.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="timeout">Timeout (seconds)</label>
|
||||
<label class="field__label" for="timeout">{{ t("Timeout (seconds)") }}</label>
|
||||
<input class="input" id="timeout" name="timeout" value="{{ tool.timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Credential</h2>
|
||||
<h2 class="card__title">{{ t("Credential") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_placement">How it is sent</label>
|
||||
<label class="field__label" for="secret_placement">{{ t("How it is sent") }}</label>
|
||||
<select class="select" id="secret_placement" name="secret_placement">
|
||||
{% for value, label in secret_placements %}
|
||||
<option value="{{ value }}" {{ 'selected' if value == tool.secret_placement }}>
|
||||
@@ -128,15 +117,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_name">Header or parameter name</label>
|
||||
<label class="field__label" for="secret_name">{{ t("Header or parameter name") }}</label>
|
||||
<input class="input input--mono" id="secret_name" name="secret_name"
|
||||
value="{{ tool.secret_name }}" maxlength="120">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret">Secret</label>
|
||||
<label class="field__label" for="secret">{{ t("Secret") }}</label>
|
||||
<input class="input input--mono" id="secret" name="secret" type="password"
|
||||
autocomplete="off" placeholder="No secret set"
|
||||
autocomplete="off" placeholder="{{ t('No secret set') }}"
|
||||
value="{{ unchanged if tool.secret_encrypted else '' }}">
|
||||
<p class="field__hint">
|
||||
{% if tool.secret_encrypted %}
|
||||
@@ -151,10 +140,10 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The answer</h2>
|
||||
<h2 class="card__title">{{ t("The answer") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="response_mode">Read the response as</label>
|
||||
<label class="field__label" for="response_mode">{{ t("Read the response as") }}</label>
|
||||
<select class="select" id="response_mode" name="response_mode">
|
||||
{% for value, label in response_modes %}
|
||||
<option value="{{ value }}" {{ 'selected' if value == tool.response_mode }}>{{ label }}</option>
|
||||
@@ -163,32 +152,26 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="response_path">Path into the JSON</label>
|
||||
<label class="field__label" for="response_path">{{ t("Path into the JSON") }}</label>
|
||||
<input class="input input--mono" id="response_path" name="response_path"
|
||||
value="{{ tool.response_path }}" placeholder="data.items.0.title" maxlength="300">
|
||||
<p class="field__hint">
|
||||
Dotted; a number indexes a list. Leave empty for the whole document.
|
||||
A path that leads nowhere gives the whole document rather than nothing.
|
||||
</p>
|
||||
value="{{ tool.response_path }}" placeholder="{{ t('data.items.0.title') }}" maxlength="300">
|
||||
<p class="field__hint">{{ t("Dotted; a number indexes a list. Leave empty for the whole document. A path that leads nowhere gives the whole document rather than nothing.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_chars">Most characters to keep</label>
|
||||
<label class="field__label" for="max_chars">{{ t("Most characters to keep") }}</label>
|
||||
<input class="input" id="max_chars" name="max_chars" value="{{ tool.max_chars }}"
|
||||
inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Spent out of the context window on every call. The rest is cut off, and
|
||||
the model is told so.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Spent out of the context window on every call. The rest is cut off, and the model is told so.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Guidance</h2>
|
||||
<h2 class="card__title">{{ t("Guidance") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<textarea class="textarea" name="guidance" rows="4"
|
||||
placeholder="- Check the weather rather than guessing at it."
|
||||
placeholder="{{ t('- Check the weather rather than guessing at it.') }}"
|
||||
>{{ tool.guidance }}</textarea>
|
||||
<p class="field__hint">
|
||||
Added to the system message whenever this tool is offered, and nowhere
|
||||
@@ -205,40 +188,33 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Availability</h2>
|
||||
<h2 class="card__title">{{ t("Availability") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if tool.enabled }}>
|
||||
<span>Enabled — offered in chats</span>
|
||||
<span>{{ t("Enabled — offered in chats") }}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allow_private" value="true"
|
||||
{{ 'checked' if tool.allow_private }}>
|
||||
<span>May reach private and loopback addresses</span>
|
||||
<span>{{ t("May reach private and loopback addresses") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
Leave the second unticked unless this tool points at something on your
|
||||
own network. It is what stops a tool being aimed at this server, a
|
||||
router, or a cloud metadata endpoint.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Leave the second unticked unless this tool points at something on your own network. It is what stops a tool being aimed at this server, a router, or a cloud metadata endpoint.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="true" {{ 'checked' if tool.public }}>
|
||||
<span>Available to everyone</span>
|
||||
<span>{{ t("Available to everyone") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Uncheck to restrict this tool to chosen groups. Administrators always
|
||||
have access.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Uncheck to restrict this tool to chosen groups. Administrators always have access.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Groups with access</span>
|
||||
<span class="field__label">{{ t("Groups with access") }}</span>
|
||||
{% if groups %}
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
@@ -249,7 +225,7 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field__hint">Ignored while the tool is available to everyone.</p>
|
||||
<p class="field__hint">{{ t("Ignored while the tool is available to everyone.") }}</p>
|
||||
{% else %}
|
||||
<p class="field__hint">
|
||||
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
||||
@@ -258,7 +234,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="position">Position</label>
|
||||
<label class="field__label" for="position">{{ t("Position") }}</label>
|
||||
<input class="input" id="position" name="position" value="{{ tool.position }}"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
@@ -272,23 +248,21 @@
|
||||
{% if not is_new %}
|
||||
<button class="btn btn--danger" type="submit" formnovalidate
|
||||
formaction="/admin/tools/{{ tool.id }}/delete"
|
||||
data-confirm-button="Delete the tool “{{ tool.name }}”? Chats that used it keep their transcripts.">
|
||||
Delete
|
||||
</button>
|
||||
data-confirm-button="Delete the tool “{{ tool.name }}”? Chats that used it keep their transcripts.">{{ t("Delete") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if not is_new %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Try it</h2>
|
||||
<h2 class="card__title">{{ t("Try it") }}</h2>
|
||||
<p class="field__hint">
|
||||
Calls the <em>saved</em> tool once, so what you see here is what a chat would
|
||||
Calls the <em>{{ t("saved") }}</em> tool once, so what you see here is what a chat would
|
||||
get. Nothing is sent to a model.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="arguments">Arguments</label>
|
||||
<label class="field__label" for="arguments">{{ t("Arguments") }}</label>
|
||||
<textarea class="textarea input--mono" id="arguments" name="arguments" rows="3"
|
||||
spellcheck="false">{"city": "Minas Tirith"}</textarea>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
HTTP calls a model can make while it answers. Each one is offered to models
|
||||
marked <strong>Custom tools</strong>, to people who have the permission, and —
|
||||
marked <strong>{{ t("Custom tools") }}</strong>, to people who have the permission, and —
|
||||
if it is restricted — only to the groups you choose. The model decides
|
||||
<em>when</em> to call it from the description you write, so write that as if
|
||||
<em>{{ t("when") }}</em> to call it from the description you write, so write that as if
|
||||
explaining to a colleague what the tool is for.
|
||||
</p>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<form class="filter-form" method="get" action="/admin/tools">
|
||||
<input type="hidden" name="filter" value="{{ active_filter }}">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search tools…" aria-label="Search tools">
|
||||
placeholder="{{ t('Search tools…') }}" aria-label="{{ t('Search tools') }}">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q or active_filter != "all" %}
|
||||
<a class="btn btn--ghost" href="/admin/tools">Clear</a>
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
{% if not tools %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">Nothing matches that filter.</p>
|
||||
<p class="empty__text">{{ t("Nothing matches that filter.") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
<div class="model-row__main">
|
||||
<div class="model-row__title">
|
||||
<a class="model-row__name" href="/admin/tools/{{ tool.id }}/edit">{{ tool.name }}</a>
|
||||
{% if not tool.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||
{% if not tool.public %}<span class="badge">restricted</span>{% endif %}
|
||||
{% if tool.allow_private %}<span class="badge">private network</span>{% endif %}
|
||||
{% if tool.last_error %}<span class="badge badge--danger">last call failed</span>{% endif %}
|
||||
{% if not tool.enabled %}<span class="badge badge--danger">{{ t("disabled") }}</span>{% endif %}
|
||||
{% if not tool.public %}<span class="badge">{{ t("restricted") }}</span>{% endif %}
|
||||
{% if tool.allow_private %}<span class="badge">{{ t("private network") }}</span>{% endif %}
|
||||
{% if tool.last_error %}<span class="badge badge--danger">{{ t("last call failed") }}</span>{% endif %}
|
||||
</div>
|
||||
<code class="model-row__id">
|
||||
{{ tool.slug }} · {{ tool.method }} {{ tool.url_template }}
|
||||
@@ -94,13 +94,13 @@
|
||||
{% if page > 1 %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">{{ t("Previous") }}</span>
|
||||
{% endif %}
|
||||
<span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
|
||||
{% if page < pages %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">{{ t("Next") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Running</h2>
|
||||
<h2 class="card__title">{{ t("Running") }}</h2>
|
||||
<dl class="mode-list">
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Version</strong></dt>
|
||||
<dt><strong>{{ t("Version") }}</strong></dt>
|
||||
<dd>
|
||||
{#
|
||||
`git describe`, not the version string: "1.0.0" exactly at a tag and
|
||||
@@ -42,16 +42,16 @@
|
||||
</div>
|
||||
{% if state.head %}
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Commit</strong></dt>
|
||||
<dt><strong>{{ t("Commit") }}</strong></dt>
|
||||
<dd>
|
||||
<code>{{ state.head.short }}</code> {{ state.head.subject }}
|
||||
{% if state.dirty %}<span class="badge">uncommitted changes</span>{% endif %}
|
||||
{% if state.dirty %}<span class="badge">{{ t("uncommitted changes") }}</span>{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if state.checkout %}
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Checkout</strong></dt>
|
||||
<dt><strong>{{ t("Checkout") }}</strong></dt>
|
||||
<dd><code>{{ state.checkout }}</code></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -69,10 +69,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if not state.is_git %}
|
||||
<p class="field__hint">
|
||||
This was not installed from a git checkout — a container image, or a wheel —
|
||||
so there is nothing here to compare or update. Pull a new image instead.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("This was not installed from a git checkout — a container image, or a wheel — so there is nothing here to compare or update. Pull a new image instead.") }}</p>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/updates/check" class="btn-row">
|
||||
<button class="btn" type="submit">
|
||||
@@ -86,8 +83,8 @@
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Available
|
||||
{% if state.no_releases %}<span class="badge">nothing released yet</span>
|
||||
{% elif state.up_to_date %}<span class="badge badge--leaf">up to date</span>
|
||||
{% if state.no_releases %}<span class="badge">{{ t("nothing released yet") }}</span>
|
||||
{% elif state.up_to_date %}<span class="badge badge--leaf">{{ t("up to date") }}</span>
|
||||
{% elif state.behind %}<span class="badge">{{ state.behind | length }} commit{{ '' if state.behind|length == 1 else 's' }} behind</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
@@ -100,9 +97,7 @@
|
||||
</p>
|
||||
|
||||
{% elif not state.available %}
|
||||
<p class="field__hint">
|
||||
Nothing known about the remote yet. Check it above.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Nothing known about the remote yet. Check it above.") }}</p>
|
||||
|
||||
{% elif state.up_to_date %}
|
||||
<p class="field__hint">
|
||||
@@ -149,11 +144,7 @@
|
||||
{% if state.dirty %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
This checkout has uncommitted changes, and updating discards them.
|
||||
Nothing here is meant to be edited in place, so this usually means somebody
|
||||
was debugging on the box.
|
||||
</span>
|
||||
<span>{{ t("This checkout has uncommitted changes, and updating discards them. Nothing here is meant to be edited in place, so this usually means somebody was debugging on the box.") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -166,18 +157,15 @@
|
||||
the worst moment to discover a thing has to be installed from a shell.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Applying an update</h2>
|
||||
<h2 class="card__title">{{ t("Applying an update") }}</h2>
|
||||
|
||||
{% if state.requested %}
|
||||
<div class="alert">
|
||||
{{ icon("clock", "icon--sm") }}
|
||||
<span>
|
||||
An update has been requested and is waiting for the helper to pick it up.
|
||||
The service restarts when it does.
|
||||
</span>
|
||||
<span>{{ t("An update has been requested and is waiting for the helper to pick it up. The service restarts when it does.") }}</span>
|
||||
</div>
|
||||
<form method="post" action="/admin/updates/cancel" class="btn-row">
|
||||
<button class="btn btn--sm" type="submit">Withdraw the request</button>
|
||||
<button class="btn btn--sm" type="submit">{{ t("Withdraw the request") }}</button>
|
||||
</form>
|
||||
|
||||
{% elif state.helper %}
|
||||
@@ -207,7 +195,7 @@
|
||||
no channel, so pressing it is never “deploy something else”.
|
||||
</p>
|
||||
<form method="post" action="/admin/updates/apply" class="btn-row"
|
||||
data-confirm="Update and restart? Replies being written are saved; open terminals are cut off.">
|
||||
data-confirm="{{ t('Update and restart? Replies being written are saved; open terminals are cut off.') }}">
|
||||
<button class="btn btn--primary" type="submit"
|
||||
{{ 'disabled' if state.up_to_date or state.no_releases }}>
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
@@ -216,19 +204,16 @@
|
||||
{% else %}Update to {{ state.available.label }} and restart{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Every restart ends every open terminal session — a command still running on
|
||||
the far side is cut off. A reply being written is saved with whatever it has.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Every restart ends every open terminal session — a command still running on the far side is cut off. A reply being written is saved with whatever it has.") }}</p>
|
||||
|
||||
{% else %}
|
||||
<div class="alert">
|
||||
{{ icon("shield", "icon--sm") }}
|
||||
<span>
|
||||
The update helper is <strong>not installed on this host</strong>, so there
|
||||
The update helper is <strong>{{ t("not installed on this host") }}</strong>, so there
|
||||
is no button. That is the honest default: the service runs as an
|
||||
unprivileged account and cannot restart itself, and a web application that
|
||||
<em>can</em> is one whose worst day is much worse. Run this instead:
|
||||
<em>{{ t("can") }}</em> is one whose worst day is much worse. Run this instead:
|
||||
<code>{{ command }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,49 +17,44 @@
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<h2 class="card__title">{{ t("Account") }}</h2>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ target.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="role">Role</label>
|
||||
<label class="field__label" for="role">{{ t("Role") }}</label>
|
||||
<select class="input" id="role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if target.role == role }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
An administrator bypasses every permission and every quota below.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("An administrator bypasses every permission and every quota below.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="active" value="true" {{ 'checked' if target.active }}>
|
||||
<span>Active</span>
|
||||
<span>{{ t("Active") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Turning this off signs them out everywhere at once, rather than waiting
|
||||
for a cookie to expire.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Turning this off signs them out everywhere at once, rather than waiting for a cookie to expire.") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- What they can actually do -------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">What this account can do</h2>
|
||||
<h2 class="card__title">{{ t("What this account can do") }}</h2>
|
||||
<p class="card__lede">
|
||||
Read-only, and deliberately: every switch here is set somewhere else — in the
|
||||
<a href="/admin/groups">baseline</a> or in a named group — and a control on
|
||||
this page would be a third place to change one thing. What it adds is the
|
||||
<em>source</em>, which is the question the grids could not answer without
|
||||
<em>{{ t("source") }}</em>, which is the question the grids could not answer without
|
||||
opening every group by eye.
|
||||
</p>
|
||||
|
||||
@@ -77,7 +72,7 @@
|
||||
from {{ state.source | join(", ") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="perm-row__desc faint">not granted</span>
|
||||
<span class="perm-row__desc faint">{{ t("not granted") }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -88,11 +83,8 @@
|
||||
|
||||
{# --- Membership ----------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Groups</h2>
|
||||
<p class="card__lede">
|
||||
Edited from the group's own page. One control per value, so a save here
|
||||
cannot undo a save there.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Groups") }}</h2>
|
||||
<p class="card__lede">{{ t("Edited from the group's own page. One control per value, so a save here cannot undo a save there.") }}</p>
|
||||
{% if target.groups %}
|
||||
<div class="btn-row">
|
||||
{% for group in target.groups %}
|
||||
@@ -100,21 +92,17 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">In no group. They get the baseline and nothing more.</p>
|
||||
<p class="muted text-sm">{{ t("In no group. They get the baseline and nothing more.") }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Quotas and usage ----------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">This month</h2>
|
||||
<p class="card__lede">
|
||||
Counted from the first of the month, UTC. Recorded for every reply including
|
||||
one that was stopped or failed — an endpoint charges for tokens it generated
|
||||
whether or not anybody wanted them.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("This month") }}</h2>
|
||||
<p class="card__lede">{{ t("Counted from the first of the month, UTC. Recorded for every reply including one that was stopped or failed — an endpoint charges for tokens it generated whether or not anybody wanted them.") }}</p>
|
||||
<dl class="mode-list">
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Tokens</strong></dt>
|
||||
<dt><strong>{{ t("Tokens") }}</strong></dt>
|
||||
<dd>
|
||||
{{ "{:,}".format(usage.tokens) }}
|
||||
{% if limits.monthly_tokens %} of {{ "{:,}".format(limits.monthly_tokens) }}{% endif %}
|
||||
@@ -125,11 +113,11 @@
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Replies</strong></dt>
|
||||
<dt><strong>{{ t("Replies") }}</strong></dt>
|
||||
<dd>{{ usage.replies }}</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Images</strong></dt>
|
||||
<dt><strong>{{ t("Images") }}</strong></dt>
|
||||
<dd>
|
||||
{{ usage.images }} this month, {{ usage.images_today }} today
|
||||
{% if limits.images_per_day %} (limit {{ limits.images_per_day }} a day){% endif %}
|
||||
@@ -137,9 +125,9 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="section-title">Limits in force</h3>
|
||||
<h3 class="section-title">{{ t("Limits in force") }}</h3>
|
||||
<p class="field__hint">
|
||||
Resolved across their groups by <strong>maximum</strong> — the union rule
|
||||
Resolved across their groups by <strong>{{ t("maximum") }}</strong> — the union rule
|
||||
applied to numbers, so a second group can only ever grant more. Zero means no
|
||||
limit and wins outright, because a group saying “unlimited” must not count
|
||||
for less than one saying “a million”.
|
||||
@@ -159,11 +147,8 @@
|
||||
|
||||
{# --- Models --------------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models they can use</h2>
|
||||
<p class="card__lede">
|
||||
Model access is separate from permissions: a permission says what somebody
|
||||
may do, this says what they may do it with.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Models they can use") }}</h2>
|
||||
<p class="card__lede">{{ t("Model access is separate from permissions: a permission says what somebody may do, this says what they may do it with.") }}</p>
|
||||
{% if models %}
|
||||
<div class="btn-row">
|
||||
{% for model in models %}
|
||||
@@ -171,22 +156,19 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">None. They cannot start a chat at all.</p>
|
||||
<p class="muted text-sm">{{ t("None. They cannot start a chat at all.") }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Dangerous ------------------------------------------------------------ #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Password and removal</h2>
|
||||
<h2 class="card__title">{{ t("Password and removal") }}</h2>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/password" class="btn-row">
|
||||
<input class="input" name="password" type="password" required
|
||||
placeholder="New password" aria-label="New password" style="flex: 1">
|
||||
<button class="btn" type="submit">Reset password</button>
|
||||
placeholder="{{ t('New password') }}" aria-label="{{ t('New password') }}" style="flex: 1">
|
||||
<button class="btn" type="submit">{{ t("Reset password") }}</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Signs them out everywhere. An administrator resetting a password usually
|
||||
means the account is compromised or the person has gone.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Signs them out everywhere. An administrator resetting a password usually means the account is compromised or the person has gone.") }}</p>
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||
data-confirm="Delete {{ target.email }}? Their chats, folders and library go with them."
|
||||
@@ -195,9 +177,6 @@
|
||||
{{ icon("trash", "icon--sm") }} Delete this account
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Their chats, folders and library go too, and every share naming them or
|
||||
naming anything of theirs.
|
||||
</p>
|
||||
<p class="field__hint">{{ t("Their chats, folders and library go too, and every share naming them or naming anything of theirs.") }}</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="filter-bar">
|
||||
<form class="filter-form" method="get" action="/admin/users">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email…" aria-label="Search users">
|
||||
placeholder="{{ t('Search by name or email…') }}" aria-label="{{ t('Search users') }}">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
@@ -32,9 +32,9 @@
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">
|
||||
{{ person.name }}
|
||||
{% if person.role == "admin" %}<span class="badge badge--leaf">admin</span>{% endif %}
|
||||
{% if person.role == "pending" %}<span class="badge">pending</span>{% endif %}
|
||||
{% if not person.active %}<span class="badge">disabled</span>{% endif %}
|
||||
{% if person.role == "admin" %}<span class="badge badge--leaf">{{ t("admin") }}</span>{% endif %}
|
||||
{% if person.role == "pending" %}<span class="badge">{{ t("pending") }}</span>{% endif %}
|
||||
{% if not person.active %}<span class="badge">{{ t("disabled") }}</span>{% endif %}
|
||||
</span>
|
||||
<span class="model-row__id">{{ person.email }}</span>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
{% endif %}
|
||||
{% set spent = usage[person.id] %}
|
||||
{% if spent.tokens %}
|
||||
<span class="badge" title="Tokens this month">{{ "{:,}".format(spent.tokens) }}</span>
|
||||
<span class="badge" title="{{ t('Tokens this month') }}">{{ "{:,}".format(spent.tokens) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
@@ -74,26 +74,24 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-8)">
|
||||
<h2 class="card__title">Add an account</h2>
|
||||
<p class="card__lede">
|
||||
Without going through registration — useful when sign-up is closed.
|
||||
</p>
|
||||
<h2 class="card__title">{{ t("Add an account") }}</h2>
|
||||
<p class="card__lede">{{ t("Without going through registration — useful when sign-up is closed.") }}</p>
|
||||
<form method="post" action="/admin/users" class="form-grid">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<label class="field__label" for="new-name">{{ t("Name") }}</label>
|
||||
<input class="input" id="new-name" name="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-email">Email</label>
|
||||
<label class="field__label" for="new-email">{{ t("Email") }}</label>
|
||||
<input class="input" id="new-email" name="email" type="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">Password</label>
|
||||
<label class="field__label" for="new-password">{{ t("Password") }}</label>
|
||||
<input class="input" id="new-password" name="password" type="password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-role">Role</label>
|
||||
<label class="field__label" for="new-role">{{ t("Role") }}</label>
|
||||
<select class="input" id="new-role" name="role">
|
||||
{% for role in roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -20,56 +20,53 @@
|
||||
action="{{ '/admin/images/workflows' if is_new else '/admin/images/workflows/' ~ workflow.id }}"
|
||||
class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">What it is</h2>
|
||||
<h2 class="card__title">{{ t("What it is") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" required maxlength="120"
|
||||
value="{{ workflow.name }}" placeholder="SDXL, photographic">
|
||||
<p class="field__hint">Shown to you, in the list.</p>
|
||||
value="{{ workflow.name }}" placeholder="{{ t('SDXL, photographic') }}">
|
||||
<p class="field__hint">{{ t("Shown to you, in the list.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="slug">Name the model uses</label>
|
||||
<label class="field__label" for="slug">{{ t("Name the model uses") }}</label>
|
||||
<input class="input input--mono" id="slug" name="slug" required maxlength="48"
|
||||
value="{{ workflow.slug }}" placeholder="sdxl-photo">
|
||||
<p class="field__hint">
|
||||
Lowercase letters, digits, hyphens and underscores. This is what the model
|
||||
writes when it picks this workflow.
|
||||
</p>
|
||||
value="{{ workflow.slug }}" placeholder="{{ t('sdxl-photo') }}">
|
||||
<p class="field__hint">{{ t("Lowercase letters, digits, hyphens and underscores. This is what the model writes when it picks this workflow.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<label class="field__label" for="description">{{ t("Description") }}</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="3"
|
||||
placeholder="Photographic and slow. Best for people, interiors and product shots at 1024px.">{{ workflow.description }}</textarea>
|
||||
placeholder="{{ t('Photographic and slow. Best for people, interiors and product shots at 1024px.') }}">{{ workflow.description }}</textarea>
|
||||
<p class="field__hint">
|
||||
The only thing the model has to choose with, so say what this is
|
||||
<em>for</em> rather than what it contains. It never sees the graph.
|
||||
<em>{{ t("for") }}</em> rather than what it contains. It never sees the graph.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if workflow.enabled }}>
|
||||
<span>Offer this workflow</span>
|
||||
<span>{{ t("Offer this workflow") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The workflow</h2>
|
||||
<h2 class="card__title">{{ t("The workflow") }}</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="workflow">ComfyUI API format</label>
|
||||
<label class="field__label" for="workflow">{{ t("ComfyUI API format") }}</label>
|
||||
<textarea class="textarea input--mono" id="workflow" name="workflow" rows="20"
|
||||
spellcheck="false">{{ workflow_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
Export this from ComfyUI with <strong>Export (API)</strong>, not Save — the
|
||||
Export this from ComfyUI with <strong>{{ t("Export (API)") }}</strong>, not Save — the
|
||||
two formats are different and only the API one can be submitted.
|
||||
</p>
|
||||
<p class="field__hint">
|
||||
Then put a placeholder where each value goes. A placeholder that is the
|
||||
<em>whole</em> value keeps its type, so
|
||||
<em>{{ t("whole") }}</em> value keeps its type, so
|
||||
<code>"steps": {{ '{{steps}}' }}</code> sends the number 20 rather than the
|
||||
text “20”; one inside a longer string is substituted as text, so
|
||||
<code>"{{ '{{prompt}}' }}, masterpiece"</code> works. Anything you leave out
|
||||
@@ -79,7 +76,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The placeholders</h2>
|
||||
<h2 class="card__title">{{ t("The placeholders") }}</h2>
|
||||
<p class="card__lede">
|
||||
Every hole a template may carry, what it fills, and what it resolves to
|
||||
right now. <code>{{ '{{prompt}}' }}</code> is required — without it every
|
||||
@@ -95,7 +92,7 @@
|
||||
<code>{{ '{{' ~ name ~ '}}' }}</code>
|
||||
<span>
|
||||
{{ what }}
|
||||
{% if name == "prompt" %}<strong>Required.</strong>{% endif %}
|
||||
{% if name == "prompt" %}<strong>{{ t("Required.") }}</strong>{% endif %}
|
||||
</span>
|
||||
<code class="faint">{{ current }}</code>
|
||||
</div>
|
||||
@@ -123,9 +120,7 @@
|
||||
{% if not is_new %}
|
||||
<button class="btn btn--danger" type="submit" formnovalidate
|
||||
formaction="/admin/images/workflows/{{ workflow.id }}/delete"
|
||||
data-confirm-button="Delete the workflow “{{ workflow.name }}”? Chats that used it fall back to the default.">
|
||||
Delete
|
||||
</button>
|
||||
data-confirm-button="Delete the workflow “{{ workflow.name }}”? Chats that used it fall back to the default.">{{ t("Delete") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<button class="picker__option" type="button" data-dir-open="{{ parent }}">
|
||||
{{ icon("chevron-left", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Up a level</span>
|
||||
<span class="picker__option-name">{{ t("Up a level") }}</span>
|
||||
<span class="picker__option-note mono">{{ parent }}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -70,9 +70,7 @@
|
||||
|
||||
{% if not entries %}
|
||||
<li>
|
||||
<p class="muted text-sm" style="padding: var(--sp-3)">
|
||||
Nothing here.
|
||||
</p>
|
||||
<p class="muted text-sm" style="padding: var(--sp-3)">{{ t("Nothing here.") }}</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -33,16 +33,14 @@
|
||||
hx-post="/api/agents/{{ profile.id }}/accept"
|
||||
hx-target="#check-result"
|
||||
hx-confirm="Accept this fingerprint and pin it? Every future connection will be checked against it."
|
||||
data-confirm-label="Accept"
|
||||
data-confirm-label="{{ t('Accept') }}"
|
||||
data-confirm-danger="{{ 'true' if offer.changed else 'false' }}">
|
||||
{{ icon("check", "icon--sm") }} Accept and pin
|
||||
</button>
|
||||
{% if profile.verified %}
|
||||
<button class="btn" type="button"
|
||||
hx-post="/api/agents/{{ profile.id }}/forget"
|
||||
hx-target="#check-result">
|
||||
Forget the old key
|
||||
</button>
|
||||
hx-target="#check-result">{{ t("Forget the old key") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +56,7 @@
|
||||
{% if forgotten %}
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>Key forgotten. Check again to see the new one.</span>
|
||||
<span>{{ t("Key forgotten. Check again to see the new one.") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -67,6 +65,6 @@
|
||||
{{ icon("check", "icon--sm") }}
|
||||
<span>Connected{% if found.system %} to {{ found.system }}{% endif %}.</span>
|
||||
</div>
|
||||
<p class="field__hint">What it said:</p>
|
||||
<p class="field__hint">{{ t("What it said:") }}</p>
|
||||
<pre class="tool-result__text">{{ found.output }}</pre>
|
||||
{% endif %}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
{% if not is_new %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Check it</h2>
|
||||
<h2 class="card__title">{{ t("Check it") }}</h2>
|
||||
<p class="field__hint">
|
||||
{% if profile.verified %}
|
||||
Confirms the host is still the one you accepted, then logs in and runs
|
||||
@@ -63,70 +63,66 @@
|
||||
action="{{ '/api/agents' if is_new else '/api/agents/' ~ profile.id }}">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The machine</h2>
|
||||
<h2 class="card__title">{{ t("The machine") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" id="name" name="name" value="{{ profile.name }}" required
|
||||
maxlength="120" placeholder="Project container">
|
||||
<p class="field__hint">What you will pick from when starting an agent chat.</p>
|
||||
maxlength="120" placeholder="{{ t('Project container') }}">
|
||||
<p class="field__hint">{{ t("What you will pick from when starting an agent chat.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="host">Host</label>
|
||||
<label class="field__label" for="host">{{ t("Host") }}</label>
|
||||
<input class="input input--mono" id="host" name="host" value="{{ profile.host }}" required
|
||||
maxlength="255" placeholder="127.0.0.1">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="port">Port</label>
|
||||
<label class="field__label" for="port">{{ t("Port") }}</label>
|
||||
<input class="input" id="port" name="port" value="{{ profile.port }}" inputmode="numeric">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">Log in as</label>
|
||||
<label class="field__label" for="username">{{ t("Log in as") }}</label>
|
||||
<input class="input input--mono" id="username" name="username" required
|
||||
value="{{ profile.username }}" maxlength="120" placeholder="root">
|
||||
value="{{ profile.username }}" maxlength="120" placeholder="{{ t('root') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_dir">Project directory</label>
|
||||
<label class="field__label" for="default_dir">{{ t("Project directory") }}</label>
|
||||
<input class="input input--mono" id="default_dir" name="default_dir"
|
||||
value="{{ profile.default_dir }}" maxlength="500" placeholder="/project">
|
||||
<p class="field__hint">
|
||||
Where a chat starts by default. Each chat records its own when it is
|
||||
created, so changing this later does not move a conversation already
|
||||
under way.
|
||||
</p>
|
||||
value="{{ profile.default_dir }}" maxlength="500" placeholder="{{ t('/project') }}">
|
||||
<p class="field__hint">{{ t("Where a chat starts by default. Each chat records its own when it is created, so changing this later does not move a conversation already under way.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="connect_timeout">Connect timeout (seconds)</label>
|
||||
<label class="field__label" for="connect_timeout">{{ t("Connect timeout (seconds)") }}</label>
|
||||
<input class="input" id="connect_timeout" name="connect_timeout"
|
||||
value="{{ profile.connect_timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">How it logs in</h2>
|
||||
<h2 class="card__title">{{ t("How it logs in") }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="auth" value="key"
|
||||
{{ 'checked' if profile.auth != 'password' }}>
|
||||
<span>A private key</span>
|
||||
<span>{{ t("A private key") }}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="auth" value="password"
|
||||
{{ 'checked' if profile.auth == 'password' }}>
|
||||
<span>A password</span>
|
||||
<span>{{ t("A password") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="private_key">Private key</label>
|
||||
<label class="field__label" for="private_key">{{ t("Private key") }}</label>
|
||||
<textarea class="textarea input--mono" id="private_key" name="private_key" rows="5"
|
||||
spellcheck="false"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
@@ -142,15 +138,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="key_passphrase">Key passphrase</label>
|
||||
<label class="field__label" for="key_passphrase">{{ t("Key passphrase") }}</label>
|
||||
<input class="input input--mono" id="key_passphrase" name="key_passphrase"
|
||||
type="password" autocomplete="off" placeholder="If the key has one">
|
||||
type="password" autocomplete="off" placeholder="{{ t('If the key has one') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<label class="field__label" for="password">{{ t("Password") }}</label>
|
||||
<input class="input input--mono" id="password" name="password" type="password"
|
||||
autocomplete="off" placeholder="No password set"
|
||||
autocomplete="off" placeholder="{{ t('No password set') }}"
|
||||
value="{{ unchanged if profile.password_encrypted else '' }}">
|
||||
<p class="field__hint">
|
||||
{% if profile.password_encrypted %}
|
||||
@@ -164,11 +160,11 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Availability</h2>
|
||||
<h2 class="card__title">{{ t("Availability") }}</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if profile.enabled }}>
|
||||
<span>Enabled — can be picked when starting an agent chat</span>
|
||||
<span>{{ t("Enabled — can be picked when starting an agent chat") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -181,9 +177,7 @@
|
||||
{% if not is_new %}
|
||||
<button class="btn btn--danger" type="submit" formnovalidate
|
||||
formaction="/api/agents/{{ profile.id }}/delete"
|
||||
data-confirm-button="Delete the connection “{{ profile.name }}”? Chats that used it keep their transcripts.">
|
||||
Delete
|
||||
</button>
|
||||
data-confirm-button="Delete the connection “{{ profile.name }}”? Chats that used it keep their transcripts.">{{ t("Delete") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
{% block agents_content %}
|
||||
<p class="admin-lede">
|
||||
Machines an <strong>Agent</strong> chat can work on. A model with one of these
|
||||
can read files, write files and run commands <em>there</em> — never here.
|
||||
Machines an <strong>{{ t("Agent") }}</strong> chat can work on. A model with one of these
|
||||
can read files, write files and run commands <em>{{ t("there") }}</em> — never here.
|
||||
Which of those it may do without asking you first is the chat's mode.
|
||||
</p>
|
||||
|
||||
@@ -33,11 +33,7 @@
|
||||
{% elif not enabled %}
|
||||
<div class="alert alert--error">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
Agent chats are switched off for this instance. You can still add
|
||||
connections here, but nothing will use them until an administrator turns
|
||||
them on.
|
||||
</span>
|
||||
<span>{{ t("Agent chats are switched off for this instance. You can still add connections here, but nothing will use them until an administrator turns them on.") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -48,10 +44,10 @@
|
||||
{% if not profiles %}
|
||||
<div class="empty">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<h2 class="empty__title">No connections yet</h2>
|
||||
<h2 class="empty__title">{{ t("No connections yet") }}</h2>
|
||||
<p class="empty__text">
|
||||
Add the host, the user to log in as, and a key or password. Then press
|
||||
<strong>Check</strong> — you will be shown its fingerprint to confirm before
|
||||
<strong>{{ t("Check") }}</strong> — you will be shown its fingerprint to confirm before
|
||||
anything is sent to it.
|
||||
</p>
|
||||
<a class="btn btn--primary" href="/agents/new">
|
||||
@@ -72,14 +68,14 @@
|
||||
<div class="model-row__title">
|
||||
<a class="model-row__name" href="/agents/{{ profile.id }}">{{ profile.name }}</a>
|
||||
{% if refused %}
|
||||
<span class="badge badge--danger">not allowed</span>
|
||||
<span class="badge badge--danger">{{ t("not allowed") }}</span>
|
||||
{% elif profile.verified %}
|
||||
<span class="badge badge--leaf">key confirmed</span>
|
||||
<span class="badge badge--leaf">{{ t("key confirmed") }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge--danger">not checked</span>
|
||||
<span class="badge badge--danger">{{ t("not checked") }}</span>
|
||||
{% endif %}
|
||||
{% if not profile.enabled %}<span class="badge">disabled</span>{% endif %}
|
||||
{% if profile.auth == "password" %}<span class="badge">password</span>{% endif %}
|
||||
{% if not profile.enabled %}<span class="badge">{{ t("disabled") }}</span>{% endif %}
|
||||
{% if profile.auth == "password" %}<span class="badge">{{ t("password") }}</span>{% endif %}
|
||||
</div>
|
||||
<code class="model-row__id">
|
||||
{{ profile.address }}{% if profile.default_dir %} · {{ profile.default_dir }}{% endif %}
|
||||
|
||||
@@ -23,18 +23,18 @@
|
||||
<input type="hidden" name="next" value="{{ next|default('/') }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<label class="field__label" for="email">{{ t("Email") }}</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" autofocus value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<label class="field__label" for="password">{{ t("Password") }}</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
||||
<button class="btn btn--primary btn--block" type="submit">{{ t("Sign in") }}</button>
|
||||
</form>
|
||||
|
||||
{% if allow_signup %}
|
||||
|
||||
@@ -10,11 +10,9 @@
|
||||
{{ mark(cls="brand-mark", uid="auth") }}
|
||||
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||
{% if first_run %}
|
||||
<p class="auth__subtitle">
|
||||
Nobody has claimed this instance yet. The first account becomes its administrator.
|
||||
</p>
|
||||
<p class="auth__subtitle">{{ t("Nobody has claimed this instance yet. The first account becomes its administrator.") }}</p>
|
||||
{% else %}
|
||||
<p class="auth__subtitle">Create your account.</p>
|
||||
<p class="auth__subtitle">{{ t("Create your account.") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -27,22 +25,22 @@
|
||||
|
||||
<form method="post" action="/auth/register" class="stack">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<label class="field__label" for="name">{{ t("Name") }}</label>
|
||||
<input class="input" type="text" id="name" name="name" required autofocus
|
||||
autocomplete="name" value="{{ name|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<label class="field__label" for="email">{{ t("Email") }}</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<label class="field__label" for="password">{{ t("Password") }}</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
<p class="field__hint">{{ t("At least 8 characters.") }}</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
in order to set both attributes when somebody switches -- one attribute to
|
||||
split rather than a JSON island to parse.
|
||||
#}
|
||||
<html lang="en" data-theme="{{ theme }}" data-base="{{ brand.theme(theme).base }}"
|
||||
<html lang="{{ language() }}" dir="{{ text_direction() }}" data-theme="{{ theme }}" data-base="{{ brand.theme(theme).base }}"
|
||||
data-themes="{{ brand.theme_list }}"{% if layout %} style="{{ layout }}"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -152,6 +152,9 @@
|
||||
what `app.js` turns into a sentence on the settings page.
|
||||
#}
|
||||
<script>
|
||||
/* The release this page was rendered by, for `app.js` to hold a waiting
|
||||
worker up against -- see "A release that arrived while you were reading". */
|
||||
window.lembasRelease = {{ version | tojson }};
|
||||
window.lembasWorker = { state: "unsupported" };
|
||||
if (!window.isSecureContext) {
|
||||
/* Reported separately from an outright failure: the fix is different. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user