b8e77453111a737de5639bd0d28ce135286ac433
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b8e7745311 |
An instance that can be somebody else's
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the Middle-earth strings as data; themes as token sets; and a stylesheet for what none of that reaches. All four are on one page, in one settings group. The snapshot is a Jinja global over a process-level cache, because render() has no session and four render paths never reach it at all -- the sign-in page, the error pages, the offline page and the SSE fragments. A context value would have had to be threaded through every one and would still have missed those. It being a global is also what lets mark() branch on an uploaded logo without any of its six call sites learning about branding; the macro that renders the sidebar link is called brandlink now, because a macro imported as `brand` shadows the global for the whole template and took out every page at once. Defaults in code and overrides in the database, as the prompt fragments do, with one difference stated in the module: an empty fragment means off, an empty flavour string means the shipped wording. And blanked rather than dropped -- settings_store.update merges, so an omitted key leaves what was stored last time and "I typed the default back in" would store something different from "I changed nothing". A custom theme sets a handful of tokens and inherits the rest, and the inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as [data-theme="shire"], so a custom light theme lands on parchment rather than four light colours on near-black. Values are validated on read rather than on save, because a theme written straight into the settings table still has to produce a stylesheet that parses -- a `}` in a value ends the rule and silently breaks every rule after it. The soft variants are derived from the accent, or a changed accent leaves focus rings in the old hue and reads as half-working. /branding.css is a route, not an inline block: an external stylesheet has no HTML context to escape from. The link carries a content hash, so a save is not left to the browser's cache, and it is deliberately outside the service worker's precache list, which is versioned by the release. The instance name moved off /admin/general rather than being duplicated there. An upgrade keeps it: the general row is read as a seed exactly while the branding row has never mentioned the name, which is `key in row` and not `row[key] is truthy` -- the two read alike would resurrect the old name underneath a cleared one. The theme list stops being a hard-coded pair in five places. Every failure mode in that area is silent, so it is driven under a DOM stub as well as tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a5fa982ae3 |
A ceiling for a chat, and a nudge for an agent that stops early
MAX_ROUNDS = 1 was wrong, and wrong in a way worth writing down. The loop already ends the moment a round comes back with no tool calls -- that is the model saying it has what it needs, and it is the termination condition every agentic harness uses. A round limit was never a schedule; it exists to catch the case where the model never says so. One is low enough to stop being a ceiling and start being a schedule: it overrode the model's judgement on every single turn. And it broke something concrete. Several built-ins are two-step pairs -- knowledge_get and notes_get read a document "by the id a search returned" -- so one round left the library searchable and not readable. That is not an edge case, it is the library working at half depth, and I understated it as "cannot search the web and then read a result" when the change went in. It is a setting now, under General, default 5, with 0 meaning no ceiling. The loop and the harness both read settings_store.chat_rounds, so the model is never told a budget that is not its own; tools.MAX_ROUNDS is the fallback for callers with no session and a test pins the two equal. core.rounds goes back to naming the number, and vanishes entirely when there is no ceiling rather than promising zero rounds. The other half of "let it decide how long to go": an agent reply that ends while its plan still has open tasks is asked once to carry on. Only against a plan, because that is the one thing there is to be objectively wrong about -- a model with no plan that says it has finished is believed, and arguing with it would be guessing. At most twice in a row, with the count reset the moment it calls a tool again, so the bound is on consecutive stops rather than on stops in total. Never in Plan mode and never past plan_submit, which ends the turn on purpose. Giving up is recorded as an event rather than left silent. The model's own words go back with the nudge, which turned up a real bug on the way: ReasoningSplitter holds back a few characters against a <think> tag split across chunks, so round_text at the end of a round was missing its tail. That text is echoed as an assistant turn for tool rounds too, so a model has been occasionally asked to continue from a transcript where it trailed off mid-sentence. Flushed per round now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
17f3fa1946 |
Compaction: a button, and automatically when the window fills
A long conversation eventually just stops working. Compaction summarises the earlier turns and sends the summary in their place. The messages are kept. They stay in the transcript behind a collapsed divider and simply stop being part of the request, which is what makes the button safe to press and automatic compaction safe to have at all: a summary that came out badly is a bad turn, not a lost conversation. Stored on the Chat, not as a synthetic Message. A synthetic row needs a role -- `system` breaks the one-system-message rule the moment build_messages emits it beside the harness, and user/assistant makes it a turn people can edit, regenerate from and copy, indistinguishable from a real one in all four places a bubble is rendered. Worse, "editing rewinds, it does not branch" would silently delete it and leave no marker that compaction had happened at all. The summary goes out as a user turn and an assistant turn, not one. A leading assistant breaks templates requiring the first non-system message to be user; a lone leading user produces user, user whenever the kept history starts on a user turn -- which it always does, because the cutoff lands on a finished reply. compacted_through_id is a plain id rather than a foreign key: migrations.py compiles only the column type, so a REFERENCES clause would exist on a fresh database and not on an upgraded one, and a constraint half the fleet has is worse than none. cutoff_message validates it on every read instead, and a rewind past the boundary clears it. Compacting again summarises only the delta, with the previous summary supplied to be subsumed. Re-summarising the whole chat each time grows quadratically and eventually exceeds the window it is protecting. Automatically at the top of _run, not in post_message: that route's contract is to return immediately and leave the slow part to a resumable connection, and it also means build_request is called once, after compaction, with no second assembly path. The trigger is the last reply's recorded usage plus an estimate of the new turn -- retrospective because true prompt_tokens are only knowable after a response, plus the delta because otherwise fifty thousand characters pasted into the composer overflow a window that read 90% last turn. It never fires when the context length is unknown. It does fire on estimated counts, which is safe here precisely because nothing is lost. _maybe_compact never raises: a failure logs and sends the uncompacted request. A `status` event says "Summarising earlier messages…" in the meantime, because a silent multi-second pause before the first token is what a hang looks like. The wording is three fragments under Admin - Prompts. Clearing task.compact turns compaction off entirely. Also adds compaction.moment(): SQLite does not store the offset, so a row loaded from disk is naive while one in the session's identity map keeps its tzinfo, and comparing the two raises. Every comparison here is between exactly those. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a8b7b5fc14 |
Fix the settings tabs, and space a form from what follows it
**The Audio tab rendered nothing.** The tabs are radios plus sibling selectors, and the CSS named every tab twice -- once to highlight its label, once to show its panel. A tab added without also adding those two rules gets a label that selects nothing, which is not something anyone catches in review; it looks like a blank page. Replaced with rules that derive what they can. The active label is `input:checked + .tabs__tab`, which needs to know nothing at all. The panel is matched by position -- CSS cannot compare a radio's id with a panel's data-tab -- so the Nth radio shows the Nth panel. Both lists render in the same order and a conditional tab drops out of both at once, so they cannot drift. There is a test asserting the two orders match, including with Audio absent. **A card following a form sat flush against Save.** The "Try it" panel on the search page read as another field of the settings form. The gap belongs to the form rather than to its action row: the action row is always its form's last child, so a bottom margin there has nothing to push away from. Adds `.form-actions` and a bottom margin on a form that is a direct child of an admin page. Also says plainly in the dictation settings that a server hosting one model ignores the model field, so `whisper-1` there is a label rather than a selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7b67568f2c |
Fix bulk actions, create chats lazily, rework the UI
Seven reported problems.
**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.
**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.
**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.
**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.
**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.
**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.
**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.
Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.
197 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ba2fb1e13d |
Add registration toggle and password change; genericise deploy
Two things the running instance needed. **Registration toggle.** Admin -> General, backed by a new settings table group rather than the environment. LEMBAS_ALLOW_SIGNUP now seeds only the initial value: once an administrator saves the setting, the stored value wins. The alternative -- environment always winning -- means a toggle in the UI silently reverts on the next restart, which is worse than not offering one. Closing registration also removes the "Create one" link from the sign-in page, so the link never leads somewhere that refuses. **Password change**, on the user settings page. Changing a password revokes every other session and immediately re-issues a cookie for the current one: if the reason for the change is that somebody else knows the password, leaving their session alive defeats the point, but signing the user out of the tab they are standing in is merely rude. **deploy/ is now host-agnostic.** This repository is public, so the unit and vhost became templates with __PREFIX__ / __SITE_HOST__ / __APP_PORT__ substituted at install time, and every path, hostname and port moved to environment variables. REPO_URL defaults to the checkout's own origin so a fork deploys itself. Machine-specific values belong in private notes, not here -- CLAUDE.md now says so. 83 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |