Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e2326d41
|
||
|
|
b7bf7d728b
|
||
|
|
201281d616
|
@@ -16,6 +16,86 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
- Fixed: **choosing a reasoning effort could kill the reply outright**, with a
|
||||||
|
Jinja traceback where the answer should have been. Reasoning effort is sent
|
||||||
|
two ways, and the second — `chat_template_kwargs` — is rendered into the
|
||||||
|
model's own chat template, which does not ignore a value it has never heard
|
||||||
|
of: it raises, and the whole request fails. The catch is that the vocabulary
|
||||||
|
is **not the same for every model**. gpt-oss takes `low/medium/high`; Bonsai
|
||||||
|
takes `low/medium/xhigh` and refuses `high`; OpenAI has added `minimal`,
|
||||||
|
`xhigh` and `max` at various points. This application offered the same three
|
||||||
|
to everything, so on some models the top setting was one the model would
|
||||||
|
throw for.
|
||||||
|
- **A model now has its own list of the efforts it accepts**, on its page under
|
||||||
|
Models, and the composer's picker and `/effort` offer only those. Tick none
|
||||||
|
and the familiar three are used, which is right for nearly everything.
|
||||||
|
- **And it corrects itself.** If an endpoint refuses an effort anyway — a model
|
||||||
|
swapped underneath a name, a runtime upgraded — that reply is retried once
|
||||||
|
without it instead of being lost, and the model's list is narrowed so the
|
||||||
|
menu stops offering something that does not work. Where the endpoint says
|
||||||
|
what it *does* take, that is what gets stored.
|
||||||
|
- `/effort` now reads the levels from the picker rather than from a second copy
|
||||||
|
of the list kept in the browser, so the two can no longer disagree about what
|
||||||
|
a valid effort is.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
Two things a phone found that 1.1.0's phone pass had not.
|
||||||
|
|
||||||
|
- Fixed: **the administration area could not be navigated on a phone.** Admin
|
||||||
|
has a nav of its own rather than the chat sidebar, and 1.1.0 gave every
|
||||||
|
sidebar the drawer behaviour — starts closed, slides in — without giving that
|
||||||
|
one any of the drawer's furniture. So it sat off-screen with no button to open
|
||||||
|
it, no close, and nothing to tap beside it: every administration page was
|
||||||
|
reachable and then a dead end. It now opens, closes and dims the page like the
|
||||||
|
other one, and a test refuses any future sidebar that cannot be opened.
|
||||||
|
- Fixed: **the chat gave nearly a quarter of a phone screen to margins**, so
|
||||||
|
anything that could not wrap had to be scrolled to sideways. The thread's side
|
||||||
|
padding is halved, and the speaker's avatar moves above the turn instead of
|
||||||
|
sitting in a 44px column beside every line of it — a code block gained about
|
||||||
|
sixty pixels of readable width.
|
||||||
|
- Fixed: **the chat's title was squeezed to nothing.** The row's designated
|
||||||
|
shrinker is hidden below a tablet width, so on a phone the controls went rigid
|
||||||
|
and asked for 317 pixels of a 390 pixel bar; the heading was not truncated, it
|
||||||
|
simply stopped occupying space. The model picker gives now, and on a phone it
|
||||||
|
shows its avatar rather than its name — the name is one tap away and the
|
||||||
|
title is not.
|
||||||
|
- Tick boxes and the smaller buttons are big enough to hit on a phone. A
|
||||||
|
checkbox is drawn by the browser at about sixteen pixels whatever the type
|
||||||
|
around it, which made it the smallest target in the application by some way,
|
||||||
|
and the admin lists are mostly checkboxes.
|
||||||
|
- Fixed: **icon buttons could be squashed below their own size.** The sidebar
|
||||||
|
toggle measured eighteen pixels across on a phone, under half its target,
|
||||||
|
because a full row shrank the button rather than the text beside it.
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
One bug, and it is the one that made 1.1.0 look broken the moment you updated to
|
||||||
|
it. If you saw a stray ✕ beside the logo on a desktop, controls that looked
|
||||||
|
half-styled, or a page that would not scroll, this is why — and none of it was
|
||||||
|
in the code you were running; it was the code your browser had *not* fetched.
|
||||||
|
|
||||||
|
- Fixed: **updating showed you the new page drawn with the old stylesheet.**
|
||||||
|
Pages are always fetched fresh, while the CSS and JavaScript beside them come
|
||||||
|
from the cache the offline support keeps — and that cache was keyed on the
|
||||||
|
release while the files inside it were not. For as long as the previous
|
||||||
|
release's worker was still in charge, you got 1.1.0's markup over 1.0.x's
|
||||||
|
stylesheet: a close button meant for the phone drawer appeared on the desktop
|
||||||
|
with nothing to style or place it, and anything else the new layout depended
|
||||||
|
on was simply absent. Every asset now carries the release in its address, so
|
||||||
|
a new page cannot be handed an old stylesheet whatever the cache holds.
|
||||||
|
|
||||||
|
It is self-correcting: updating to this version is enough, and no cache needs
|
||||||
|
clearing.
|
||||||
|
|
||||||
|
- The sidebar header is two slots — the name, and a rail on the right for the
|
||||||
|
drawer's own controls — instead of a brand with a button appended to it. The
|
||||||
|
close button sits in that rail, at the top right where it belongs, and a
|
||||||
|
second control added later lands beside it rather than pushing the name
|
||||||
|
around.
|
||||||
|
|
||||||
## 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
Mostly about using this on a phone, where it turns out a good deal of it could
|
Mostly about using this on a phone, where it turns out a good deal of it could
|
||||||
|
|||||||
+35
-5
@@ -29,10 +29,19 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
SRC = REPO / "src"
|
sys.path.insert(0, str(REPO / "src"))
|
||||||
sys.path.insert(0, str(SRC))
|
|
||||||
|
|
||||||
STATIC = SRC / "lembas/web/static"
|
# Resolved from the package that actually got imported, not from where this
|
||||||
|
# file happens to sit. A copy of this script run from somewhere else silently
|
||||||
|
# pointed STATIC at a directory that did not exist, every asset URL was
|
||||||
|
# rewritten to a file:// path with nothing behind it, and the run measured an
|
||||||
|
# unstyled document -- reporting that every page in the application overflowed
|
||||||
|
# by thirty thousand pixels. The guard below only asked whether the URLs had
|
||||||
|
# been rewritten, which they had.
|
||||||
|
import lembas # noqa: E402
|
||||||
|
|
||||||
|
SRC = Path(lembas.__file__).resolve().parent.parent
|
||||||
|
STATIC = Path(lembas.__file__).resolve().parent / "web/static"
|
||||||
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
|
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
|
||||||
|
|
||||||
# Routes that are served by the app rather than mounted, so the rewrite has to
|
# Routes that are served by the app rather than mounted, so the rewrite has to
|
||||||
@@ -131,8 +140,17 @@ window.__measure = function () {
|
|||||||
tallCulprits: culprits('y'),
|
tallCulprits: culprits('y'),
|
||||||
wideCulprits: culprits('x'),
|
wideCulprits: culprits('x'),
|
||||||
/* The invariant: the application shell fills the window and the DOCUMENT
|
/* The invariant: the application shell fills the window and the DOCUMENT
|
||||||
never scrolls. A document taller than the window is the /settings bug. */
|
never scrolls *for the reader*. A document taller than the window is the
|
||||||
documentScrolls: de.scrollHeight > window.innerHeight + 1,
|
/settings bug -- but only when the reader can actually move it. `overflow:
|
||||||
|
hidden` blocks a wheel and a finger while still permitting an assignment
|
||||||
|
to scrollTop, so a page whose shell clips a tall descendant reports a
|
||||||
|
scrollHeight of thousands and scrolls for nobody. /admin/prompts does
|
||||||
|
exactly that, and reading the raw height called it a bug four times. */
|
||||||
|
documentScrolls:
|
||||||
|
de.scrollHeight > window.innerHeight + 1 &&
|
||||||
|
["visible", "auto", "scroll"].indexOf(
|
||||||
|
getComputedStyle(document.documentElement).overflowY
|
||||||
|
) !== -1,
|
||||||
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
|
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
|
||||||
smallTargets: small.slice(0, 40),
|
smallTargets: small.slice(0, 40),
|
||||||
smallCount: small.length,
|
smallCount: small.length,
|
||||||
@@ -219,6 +237,18 @@ def rewrite(html: str, client, assets: Path) -> str:
|
|||||||
f"{sorted(set(blocking))[:8]}"
|
f"{sorted(set(blocking))[:8]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# And that what they were rewritten *to* is really there. A rewrite that
|
||||||
|
# matches and produces a dead path is indistinguishable, from inside the
|
||||||
|
# browser, from no stylesheet at all -- and it is the failure that actually
|
||||||
|
# happened, twice.
|
||||||
|
missing = [
|
||||||
|
url
|
||||||
|
for url in re.findall(r'(?:href|src)="file://([^"?]+)"', html)
|
||||||
|
if not Path(url).exists()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
|
||||||
|
|
||||||
# The one-time notifications offer is a modal over the very page we came
|
# The one-time notifications offer is a modal over the very page we came
|
||||||
# to measure, and it is gated on a localStorage key. Set it in the head, so
|
# to measure, and it is gated on a localStorage key. Set it in the head, so
|
||||||
# it runs before the deferred script that reads it.
|
# it runs before the deferred script that reads it.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.2.0"
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ async def model_detail(
|
|||||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||||
"capabilities": PROTOCOL_CAPABILITIES,
|
"capabilities": PROTOCOL_CAPABILITIES,
|
||||||
"tool_capabilities": TOOL_CAPABILITIES,
|
"tool_capabilities": TOOL_CAPABILITIES,
|
||||||
|
# Every effort this application understands, so an administrator
|
||||||
|
# can tick the ones their model actually takes -- and the model's
|
||||||
|
# current answer, which is the common three until somebody says.
|
||||||
"efforts": chat_service.EFFORTS,
|
"efforts": chat_service.EFFORTS,
|
||||||
|
"model_efforts": chat_service.efforts_for(model),
|
||||||
# Rows predating the split have no tool_* keys at all. Showing them
|
# Rows predating the split have no tool_* keys at all. Showing them
|
||||||
# unticked would be a lie: tools.enabled_tools treats absent as on
|
# unticked would be a lie: tools.enabled_tools treats absent as on
|
||||||
# when `tools` is on, so that an upgrade does not silently take web
|
# when `tools` is on, so that an upgrade does not silently take web
|
||||||
@@ -238,6 +242,7 @@ async def update_model(
|
|||||||
position: str = Form(""),
|
position: str = Form(""),
|
||||||
context_length: str = Form(""),
|
context_length: str = Form(""),
|
||||||
default_effort: str = Form(""),
|
default_effort: str = Form(""),
|
||||||
|
reasoning_efforts: list[str] = Form(default=[]),
|
||||||
group_ids: list[str] = Form(default=[]),
|
group_ids: list[str] = Form(default=[]),
|
||||||
capability: list[str] = Form(default=[]),
|
capability: list[str] = Form(default=[]),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -260,9 +265,19 @@ async def update_model(
|
|||||||
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||||
# holds whatever sampling defaults an administrator has set and this form
|
# holds whatever sampling defaults an administrator has set and this form
|
||||||
# only carries one of them.
|
# only carries one of them.
|
||||||
|
# Which efforts this model takes at all. Submitted as a list of ticked
|
||||||
|
# values; empty means "nobody has said", and `chat.efforts_for` answers with
|
||||||
|
# the common three. Stored in the order `EFFORTS` declares rather than the
|
||||||
|
# order a browser happened to send.
|
||||||
|
chosen = [value for value in chat_service.EFFORTS if value in (reasoning_efforts or [])]
|
||||||
|
model.reasoning_efforts = chosen
|
||||||
|
|
||||||
params = dict(model.params_json or {})
|
params = dict(model.params_json or {})
|
||||||
wanted = default_effort.strip().lower()
|
wanted = default_effort.strip().lower()
|
||||||
if wanted in chat_service.EFFORTS:
|
# Checked against what this model takes, not against everything this
|
||||||
|
# application has heard of -- a default of `high` on a model whose template
|
||||||
|
# refuses it is a chat that fails on its first turn.
|
||||||
|
if wanted in chat_service.efforts_for(model):
|
||||||
params["reasoning_effort"] = wanted
|
params["reasoning_effort"] = wanted
|
||||||
else:
|
else:
|
||||||
params.pop("reasoning_effort", None)
|
params.pop("reasoning_effort", None)
|
||||||
|
|||||||
@@ -82,10 +82,12 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||||
# The three a reasoning model understands. From the service so the
|
# What *this* model takes, not the three every model used to be assumed
|
||||||
# command, the control and the request builder cannot disagree about
|
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
||||||
# what is a valid effort.
|
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||||
"efforts": chat_service.EFFORTS,
|
# raises inside the chat template and fails the reply. From the service
|
||||||
|
# so the command, the control and the request builder cannot disagree.
|
||||||
|
"efforts": chat_service.efforts_for(current) if current else chat_service.DEFAULT_EFFORTS,
|
||||||
# What the picker shows, and what `build_request` will send. One
|
# What the picker shows, and what `build_request` will send. One
|
||||||
# resolver so the two cannot disagree.
|
# resolver so the two cannot disagree.
|
||||||
"resolved_effort": chat_service.resolved_effort(chat) if chat else "",
|
"resolved_effort": chat_service.resolved_effort(chat) if chat else "",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||||
from lembas.db.types import JSONDict
|
from lembas.db.types import JSONDict, JSONList
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Import only for the annotation; at runtime SQLAlchemy resolves the
|
# Import only for the annotation; at runtime SQLAlchemy resolves the
|
||||||
@@ -137,6 +137,20 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
# ticked anything.
|
# ticked anything.
|
||||||
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Which reasoning efforts this model actually accepts. Empty means "nobody
|
||||||
|
# has said", and `services/chat.efforts_for` answers with the common set.
|
||||||
|
#
|
||||||
|
# It has to be per model, because the vocabulary is: gpt-oss takes
|
||||||
|
# low/medium/high, Bonsai takes low/medium/xhigh and *raises* on high, and
|
||||||
|
# OpenAI's own list has grown minimal, xhigh and max at different times. A
|
||||||
|
# single global tuple is a guess that is wrong for somebody.
|
||||||
|
#
|
||||||
|
# ⚠ A column and not a key in `capabilities_json`, for exactly the reason
|
||||||
|
# `context_length` is one: that dict is rebuilt wholesale from the submitted
|
||||||
|
# checkboxes on every save, so anything in it that is not a checkbox is
|
||||||
|
# destroyed the next time an administrator ticks anything.
|
||||||
|
reasoning_efforts: Mapped[list[str]] = mapped_column(JSONList, default=list)
|
||||||
|
|
||||||
connection: Mapped[Connection] = relationship(back_populates="models")
|
connection: Mapped[Connection] = relationship(back_populates="models")
|
||||||
groups: Mapped[list[Group]] = relationship(
|
groups: Mapped[list[Group]] = relationship(
|
||||||
"Group", secondary=model_groups, back_populates="models"
|
"Group", secondary=model_groups, back_populates="models"
|
||||||
|
|||||||
@@ -387,7 +387,15 @@ def build_request(
|
|||||||
):
|
):
|
||||||
body["tool_choice"] = {"type": "function", "function": {"name": force_tool}}
|
body["tool_choice"] = {"type": "function", "function": {"name": force_tool}}
|
||||||
|
|
||||||
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
|
# The model's own vocabulary, looked up here rather than passed in: every
|
||||||
|
# caller of `build_request` would otherwise have to remember, which is the
|
||||||
|
# trap `audio_service.template_flags` fell into.
|
||||||
|
chat_model = model_for(db, chat)
|
||||||
|
apply_effort(
|
||||||
|
body,
|
||||||
|
(chat.params_json or {}).get("reasoning_effort"),
|
||||||
|
efforts_for(chat_model) if chat_model is not None else None,
|
||||||
|
)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@@ -405,7 +413,42 @@ def build_request(
|
|||||||
# an effort on sends neither field and is byte-for-byte what it was. An endpoint
|
# an effort on sends neither field and is byte-for-byte what it was. An endpoint
|
||||||
# strict about unknown parameters will refuse the extra one -- but on a chat
|
# strict about unknown parameters will refuse the extra one -- but on a chat
|
||||||
# somebody deliberately set an effort on, not on every chat in the instance.
|
# somebody deliberately set an effort on, not on every chat in the instance.
|
||||||
EFFORTS = ("low", "medium", "high")
|
# Every reasoning effort this application understands, and the subset a model
|
||||||
|
# gets when nobody has said otherwise.
|
||||||
|
#
|
||||||
|
# 🚨 These are two different questions and conflating them is what broke a
|
||||||
|
# chat on Bonsai: `EFFORTS` was `("low", "medium", "high")` and was used both to
|
||||||
|
# validate what somebody chose *and* to decide what to offer, so a model whose
|
||||||
|
# vocabulary is low/medium/**xhigh** could not be given its own top setting,
|
||||||
|
# and the one it was given -- `high` -- made its chat template call
|
||||||
|
# `raise_exception` and took the whole reply with it.
|
||||||
|
#
|
||||||
|
# The known list is the union across providers, which have not agreed: OpenAI
|
||||||
|
# has added `minimal`, `xhigh` and `max` at different points; gpt-oss takes
|
||||||
|
# low/medium/high; Bonsai takes low/medium/xhigh and refuses high. `none` is
|
||||||
|
# deliberately absent -- this application already spells that `off`, and two
|
||||||
|
# spellings of off is the failure this codebase keeps cataloguing.
|
||||||
|
EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
# What a model is offered when its own list is empty. The three every reasoning
|
||||||
|
# model since the first one has understood.
|
||||||
|
DEFAULT_EFFORTS = ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def efforts_for(model) -> tuple[str, ...]:
|
||||||
|
"""The efforts this model accepts, in the order they should be offered.
|
||||||
|
|
||||||
|
A model's own list when an administrator has set one or the endpoint has
|
||||||
|
taught us one (see `generation._narrow_efforts`), and the common three
|
||||||
|
otherwise. Filtered against `EFFORTS` on the way out, so a value stored by
|
||||||
|
an older release -- or learned from an endpoint that advertised something
|
||||||
|
this application has never heard of -- cannot reach a request body.
|
||||||
|
"""
|
||||||
|
stored = list(getattr(model, "reasoning_efforts", None) or [])
|
||||||
|
chosen = [value for value in stored if value in EFFORTS]
|
||||||
|
if not chosen:
|
||||||
|
return DEFAULT_EFFORTS
|
||||||
|
return tuple(value for value in EFFORTS if value in chosen)
|
||||||
|
|
||||||
|
|
||||||
def resolved_effort(chat) -> str:
|
def resolved_effort(chat) -> str:
|
||||||
@@ -427,9 +470,19 @@ def resolved_effort(chat) -> str:
|
|||||||
return value if value in EFFORTS else ""
|
return value if value in EFFORTS else ""
|
||||||
|
|
||||||
|
|
||||||
def apply_effort(body: dict[str, Any], effort: str | None) -> None:
|
def apply_effort(
|
||||||
"""Put a chosen reasoning effort into a request body, in both forms."""
|
body: dict[str, Any], effort: str | None, supported: tuple[str, ...] | None = None
|
||||||
if not effort or effort not in EFFORTS:
|
) -> None:
|
||||||
|
"""Put a chosen reasoning effort into a request body, in both forms.
|
||||||
|
|
||||||
|
`supported` is the model's own vocabulary. An effort outside it is dropped
|
||||||
|
rather than sent, because the second form below is not advisory: it reaches
|
||||||
|
the model's Jinja chat template, and a template that does not know the value
|
||||||
|
raises rather than ignoring it -- which fails the whole request, not the
|
||||||
|
parameter.
|
||||||
|
"""
|
||||||
|
allowed = supported or DEFAULT_EFFORTS
|
||||||
|
if not effort or effort not in allowed:
|
||||||
return
|
return
|
||||||
body["reasoning_effort"] = effort
|
body["reasoning_effort"] = effort
|
||||||
kwargs = dict(body.get("chat_template_kwargs") or {})
|
kwargs = dict(body.get("chat_template_kwargs") or {})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
@@ -454,6 +455,122 @@ def _narrower(instance: float, quota: int) -> float:
|
|||||||
return float(min(instance, quota))
|
return float(min(instance, quota))
|
||||||
|
|
||||||
|
|
||||||
|
# --- A reasoning effort the model will not take ------------------------------
|
||||||
|
#
|
||||||
|
# `chat_template_kwargs.reasoning_effort` is not advisory. It reaches the
|
||||||
|
# model's Jinja chat template, and a template that does not know the value does
|
||||||
|
# not ignore it -- gpt-oss and Bonsai both call `raise_exception`, which fails
|
||||||
|
# the whole request. The reader sees their reply die with a Jinja traceback in
|
||||||
|
# it, having chosen a perfectly ordinary-looking option from a menu this
|
||||||
|
# application drew.
|
||||||
|
#
|
||||||
|
# So the value is checked against the model's own vocabulary before it is sent
|
||||||
|
# (`chat.apply_effort`), and this is the second line: when it is refused anyway
|
||||||
|
# -- an endpoint upgraded underneath us, a model whose list nobody has set --
|
||||||
|
# the reply is retried once without it rather than lost, and the model's list is
|
||||||
|
# narrowed so the menu stops offering something that does not work.
|
||||||
|
|
||||||
|
|
||||||
|
def _effort_was_refused(message: str) -> bool:
|
||||||
|
"""Whether this error is the chat template refusing the effort we sent.
|
||||||
|
|
||||||
|
Deliberately narrow. Anything that merely mentions reasoning would also
|
||||||
|
match a model politely declining to think, and retrying *that* silently
|
||||||
|
would hide a real failure behind a second request.
|
||||||
|
"""
|
||||||
|
lowered = message.lower()
|
||||||
|
return "effort" in lowered and ("unexpected" in lowered or "supported" in lowered)
|
||||||
|
|
||||||
|
|
||||||
|
def _advertised_efforts(message: str) -> list[str]:
|
||||||
|
"""The efforts an error message says it will take, if it says.
|
||||||
|
|
||||||
|
Bonsai's is "Unexpected reasoning effort high. Supported types are xhigh
|
||||||
|
(default), medium, and low." -- which is the answer, written out, in the
|
||||||
|
failure. Read only from the part after "supported", so the *rejected* value
|
||||||
|
named in the first sentence is not collected as a supported one.
|
||||||
|
|
||||||
|
Best-effort by design: it only ever narrows what is offered, an
|
||||||
|
administrator can set the list by hand, and anything unrecognised is
|
||||||
|
dropped by `efforts_for` on the way out.
|
||||||
|
"""
|
||||||
|
lowered = message.lower()
|
||||||
|
if "supported" not in lowered:
|
||||||
|
return []
|
||||||
|
tail = lowered.split("supported", 1)[1]
|
||||||
|
# Whole words. `"high" in "xhigh"` is true, so a substring test reads
|
||||||
|
# Bonsai's "Supported types are xhigh (default), medium, and low" as
|
||||||
|
# advertising `high` -- the very value it has just refused -- and the list
|
||||||
|
# would learn the opposite of what the endpoint said.
|
||||||
|
words = set(re.findall(r"[a-z]+", tail))
|
||||||
|
return [effort for effort in chat_service.EFFORTS if effort in words]
|
||||||
|
|
||||||
|
|
||||||
|
def _learn_refused_effort(model_id: str, refused: str, message: str) -> None:
|
||||||
|
"""Write what the endpoint just taught us onto the model.
|
||||||
|
|
||||||
|
Its own session: this runs from inside a generation, which outlives the
|
||||||
|
request's session, and the whole point is that it survives to the next turn.
|
||||||
|
"""
|
||||||
|
from lembas.db.models import Model
|
||||||
|
|
||||||
|
if not model_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with session_scope() as db:
|
||||||
|
models = list(db.scalars(select(Model).where(Model.model_id == model_id)))
|
||||||
|
for model in models:
|
||||||
|
advertised = _advertised_efforts(message)
|
||||||
|
current = list(model.reasoning_efforts or chat_service.DEFAULT_EFFORTS)
|
||||||
|
# What the endpoint advertised, when it did; otherwise simply
|
||||||
|
# the list it had, minus the one it has just refused.
|
||||||
|
wanted = advertised or [e for e in current if e != refused]
|
||||||
|
wanted = [e for e in wanted if e in chat_service.EFFORTS and e != refused]
|
||||||
|
if wanted and wanted != list(model.reasoning_efforts or []):
|
||||||
|
model.reasoning_efforts = wanted
|
||||||
|
log.info(
|
||||||
|
"model %s refused reasoning effort %r; efforts narrowed to %s",
|
||||||
|
model_id, refused, wanted,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - never let bookkeeping fail a reply
|
||||||
|
log.exception("could not record the refused effort for model %s", model_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_once(endpoint, payload, generation, model_id: str):
|
||||||
|
"""`stream_chat`, retried once without the reasoning effort if that is what
|
||||||
|
the endpoint objected to.
|
||||||
|
|
||||||
|
⚠ The retry is only safe because the template is rendered *before* any token
|
||||||
|
is produced, so a refusal arrives with nothing yet emitted. `sent` is the
|
||||||
|
guard that keeps it that way: once a single chunk has reached the caller,
|
||||||
|
the reply is under way and a second request would duplicate it.
|
||||||
|
"""
|
||||||
|
sent = False
|
||||||
|
try:
|
||||||
|
async for chunk in stream_chat(endpoint, payload):
|
||||||
|
sent = True
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
except LLMError as exc:
|
||||||
|
refused = str((payload.get("chat_template_kwargs") or {}).get("reasoning_effort") or "")
|
||||||
|
if sent or not refused or not _effort_was_refused(exc.message):
|
||||||
|
raise
|
||||||
|
log.info("retrying without reasoning effort %r: %s", refused, exc.message)
|
||||||
|
_learn_refused_effort(model_id, refused, exc.message)
|
||||||
|
|
||||||
|
retry = dict(payload)
|
||||||
|
retry.pop("reasoning_effort", None)
|
||||||
|
kwargs = dict(retry.get("chat_template_kwargs") or {})
|
||||||
|
kwargs.pop("reasoning_effort", None)
|
||||||
|
if kwargs:
|
||||||
|
retry["chat_template_kwargs"] = kwargs
|
||||||
|
else:
|
||||||
|
retry.pop("chat_template_kwargs", None)
|
||||||
|
|
||||||
|
async for chunk in stream_chat(endpoint, retry):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
async def _run(generation: Generation) -> None:
|
async def _run(generation: Generation) -> None:
|
||||||
"""Produce one reply, then persist it. Never raises into the task.
|
"""Produce one reply, then persist it. Never raises into the task.
|
||||||
|
|
||||||
@@ -643,7 +760,7 @@ async def _run(generation: Generation) -> None:
|
|||||||
# round thinks at all -- plenty of rounds do not.
|
# round thinks at all -- plenty of rounds do not.
|
||||||
round_thinking: tuple[float, float] | None = None
|
round_thinking: tuple[float, float] | None = None
|
||||||
|
|
||||||
async for chunk in stream_chat(endpoint, payload):
|
async for chunk in _stream_once(endpoint, payload, generation, model_id):
|
||||||
counts = chunk_usage(chunk)
|
counts = chunk_usage(chunk)
|
||||||
if counts is not None:
|
if counts is not None:
|
||||||
generation.reported_usage = True
|
generation.reported_usage = True
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ button, input, textarea, select {
|
|||||||
/* Square, and the same height as everything beside it. */
|
/* Square, and the same height as everything beside it. */
|
||||||
.btn--icon {
|
.btn--icon {
|
||||||
width: var(--control-h);
|
width: var(--control-h);
|
||||||
|
/* Square, and it stays square. Without this a flex row that runs out of room
|
||||||
|
shrinks it instead of its neighbours -- the sidebar toggle measured 18px
|
||||||
|
across on a 390px chat, less than half the target it is supposed to be,
|
||||||
|
while the row beside it kept every pixel it had asked for. A control's
|
||||||
|
size is not the give in a layout; text is. */
|
||||||
|
flex: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@@ -354,12 +360,28 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
.checkbox input {
|
.checkbox input {
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
width: 1rem;
|
width: var(--check-size);
|
||||||
height: 1rem;
|
height: var(--check-size);
|
||||||
flex: none;
|
flex: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Every tick box, not only the ones inside a `.checkbox` label -- the admin
|
||||||
|
lists put bare ones in a row and those were 16px square on a phone. */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
}
|
||||||
|
/* Except the ones that are deliberately 1px: a visually-hidden radio is the
|
||||||
|
state behind a label, and the label is the target. */
|
||||||
|
input.visually-hidden[type="radio"],
|
||||||
|
input.visually-hidden[type="checkbox"] {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Multi-column form layout, one definition. */
|
/* Multi-column form layout, one definition. */
|
||||||
.grid { display: grid; gap: var(--sp-4); }
|
.grid { display: grid; gap: var(--sp-4); }
|
||||||
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
||||||
@@ -446,13 +468,40 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
.sidebar[hidden] { display: none; }
|
.sidebar[hidden] { display: none; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
Two slots with a gap between them, and neither is positioned against the
|
||||||
|
other. The brand shrinks and truncates because its width is an instance
|
||||||
|
setting nobody here chose; the rail does not, because it is a whole number of
|
||||||
|
`--control-h` boxes and is the thing a hand is going for.
|
||||||
|
|
||||||
|
`gap` rather than `margin-left: auto` on the last child: auto-margin puts the
|
||||||
|
rail on the trailing edge only for as long as it happens to be last, and the
|
||||||
|
moment a second control is added it lands between the brand and the rail
|
||||||
|
instead of in it.
|
||||||
|
*/
|
||||||
.sidebar__header {
|
.sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
padding: 0 var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
.sidebar__brand-slot {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
/* On the trailing edge, whatever the writing direction, and sized by its
|
||||||
|
contents rather than by what is left over. */
|
||||||
|
.sidebar__actions-rail {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
.sidebar__brand {
|
.sidebar__brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -926,7 +975,48 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
/*
|
||||||
|
The controls on the right of the topbar.
|
||||||
|
|
||||||
|
`flex: none` on the group with `min-width: 0` inside it: the group keeps the
|
||||||
|
width its controls need, and the one child whose width is a *name* rather
|
||||||
|
than a control -- the model picker -- is the thing allowed to give. Without
|
||||||
|
the second half the group asked for 317px of a 390px bar and the chat's
|
||||||
|
title, which is `flex: 1`, was squeezed to exactly zero: a heading that had
|
||||||
|
not been shortened or truncated but had simply ceased to occupy space.
|
||||||
|
*/
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
/* Allowed to give, which it was not. `--topbar__where` used to be the
|
||||||
|
designated shrinker in this row, and it is `display: none` below 64rem --
|
||||||
|
so on a phone the group became rigid, asked for 317px of a 390px bar, and
|
||||||
|
the title (`flex: 1`) was squeezed to exactly zero: a heading that had not
|
||||||
|
been truncated but had ceased to occupy space.
|
||||||
|
|
||||||
|
Nothing inside it shrinks except the model picker: every button here is
|
||||||
|
`flex: none` because a control's size is not the give in a layout. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* A title identifies the page, so it gets a floor and truncates rather than
|
||||||
|
disappearing. */
|
||||||
|
.topbar__title { min-width: 4rem; }
|
||||||
|
|
||||||
|
/* The one control in this row whose width is somebody else's decision -- a
|
||||||
|
model's label is whatever an administrator called it -- so it is the one
|
||||||
|
that gives, and it gives by truncating its name rather than its avatar or
|
||||||
|
its chevron. */
|
||||||
|
.topbar__actions .picker { min-width: 0; }
|
||||||
|
.topbar__actions .picker__button { max-width: 100%; }
|
||||||
|
.topbar__actions .picker__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.picker__avatar, .picker__chevron { flex: none; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Which machine an agent chat runs on, and where.
|
Which machine an agent chat runs on, and where.
|
||||||
@@ -1216,6 +1306,22 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
/* The bar is the densest row in the application and the one with the least
|
||||||
|
room: a toggle, a title, a model, and up to four panel buttons. Tighter
|
||||||
|
padding and a smaller gap buy back about 24px, which is the difference
|
||||||
|
between a title that truncates and one there is no room for at all. */
|
||||||
|
.topbar {
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding-right: max(var(--sp-2), var(--safe-right));
|
||||||
|
padding-left: max(var(--sp-2), var(--safe-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The model's name costs about a hundred pixels and its avatar does not,
|
||||||
|
and the picker opens onto a list of full names the moment it is touched.
|
||||||
|
So on a phone the avatar carries the identity and the chat's own title --
|
||||||
|
which nothing else on the screen tells you -- gets the room back. */
|
||||||
|
.topbar__actions .picker__label { display: none; }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -1257,7 +1363,6 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
|
|
||||||
.sidebar__close {
|
.sidebar__close {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dismissible by tapping beside it. Without this the only way out is a
|
/* Dismissible by tapping beside it. Without this the only way out is a
|
||||||
|
|||||||
@@ -1726,3 +1726,52 @@
|
|||||||
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
|
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
|
||||||
.thread__intro > *:nth-child(2) { animation-delay: 60ms; }
|
.thread__intro > *:nth-child(2) { animation-delay: 60ms; }
|
||||||
.thread__intro > *:nth-child(3) { animation-delay: 120ms; }
|
.thread__intro > *:nth-child(3) { animation-delay: 120ms; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
--- A phone ----------------------------------------------------------------
|
||||||
|
|
||||||
|
The one width-aware block in this file, and the reason the blanket ban on
|
||||||
|
`@media` here was lifted: everything below is a *size*, and there is no
|
||||||
|
intrinsic-sizing trick that makes 24px of thread padding the right amount on
|
||||||
|
a 390px screen. The ban existed to stop the composer toolbar being "fixed"
|
||||||
|
with a breakpoint instead of by saying which child gives, and that guarantee
|
||||||
|
is asserted directly now (`tests/test_chat.py`) -- so this block may not touch
|
||||||
|
`.composer__toolbar` or `.composer__actions`, and a test refuses it if it
|
||||||
|
does.
|
||||||
|
|
||||||
|
What was wrong: a 390px screen spent 40px of its width on thread padding and
|
||||||
|
another 44 on the avatar gutter before a single word was drawn, which is
|
||||||
|
nearly a quarter of the screen given over to margin -- so anything that could
|
||||||
|
not wrap had to be scrolled to sideways.
|
||||||
|
*/
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
/* Half the horizontal padding. The vertical stays: it is what separates one
|
||||||
|
turn from the next, and turns are no closer together on a phone. */
|
||||||
|
.thread {
|
||||||
|
padding-left: var(--sp-3);
|
||||||
|
padding-right: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The avatar goes to the top of the turn rather than beside it, so the body
|
||||||
|
gets the whole width. The gutter is what identifies the speaker and it
|
||||||
|
still does; it simply stops costing 44px of every line. */
|
||||||
|
.msg {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.msg__gutter {
|
||||||
|
width: var(--control-h-sm);
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
}
|
||||||
|
.msg__meta { gap: var(--sp-2); }
|
||||||
|
|
||||||
|
/* A bubble against the edge of the screen wants less inside it. */
|
||||||
|
.msg--user .msg__body--plain { padding: var(--sp-2) var(--sp-3); }
|
||||||
|
|
||||||
|
/* The composer is the other thing pressed against both edges. */
|
||||||
|
.composer { padding-left: var(--sp-2); padding-right: var(--sp-2); }
|
||||||
|
|
||||||
|
/* A hint that runs to four lines on a phone is a hint nobody reads, and it
|
||||||
|
sits directly under the thing a thumb is reaching for. */
|
||||||
|
.composer__hint { font-size: var(--text-xs); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,9 @@
|
|||||||
Raising the token is the only version that reaches all of them, and it is
|
Raising the token is the only version that reaches all of them, and it is
|
||||||
what `--control-h` exists for. */
|
what `--control-h` exists for. */
|
||||||
--tap-min: 2.75rem;
|
--tap-min: 2.75rem;
|
||||||
|
/* A tick box, which does not take its size from `--control-h`: the browser
|
||||||
|
draws it and only `width`/`height` move it. */
|
||||||
|
--check-size: 1rem;
|
||||||
|
|
||||||
/* --- The window's own edges ---------------------------------------------
|
/* --- The window's own edges ---------------------------------------------
|
||||||
Installed on a phone, the page runs under the notch and the home
|
Installed on a phone, the page runs under the notch and the home
|
||||||
@@ -421,9 +424,16 @@
|
|||||||
@media (pointer: coarse), (max-width: 48rem) {
|
@media (pointer: coarse), (max-width: 48rem) {
|
||||||
:root {
|
:root {
|
||||||
--control-h: var(--tap-min);
|
--control-h: var(--tap-min);
|
||||||
--control-h-sm: 2.25rem;
|
/* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
|
||||||
|
action, not an unimportant one -- Edit, Enable and Use default are all
|
||||||
|
`.btn--sm`, and on a phone they are the whole interaction. */
|
||||||
|
--control-h-sm: 2.5rem;
|
||||||
--control-px: var(--sp-4);
|
--control-px: var(--sp-4);
|
||||||
--control-px-sm: var(--sp-3);
|
--control-px-sm: var(--sp-3);
|
||||||
|
/* A native checkbox is 13-16px whatever the surrounding type is, and no
|
||||||
|
amount of padding on its label changes the box itself. It is the
|
||||||
|
smallest target in the application on a phone by some margin. */
|
||||||
|
--check-size: 1.375rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 33 KiB |
@@ -271,8 +271,21 @@
|
|||||||
|
|
||||||
/* --- Reasoning effort ---------------------------------------------------
|
/* --- Reasoning effort ---------------------------------------------------
|
||||||
The command drives the same select the composer shows, so there is one
|
The command drives the same select the composer shows, so there is one
|
||||||
piece of state and the control updates itself when the command is used. */
|
piece of state and the control updates itself when the command is used.
|
||||||
var EFFORTS = ["low", "medium", "high"];
|
|
||||||
|
Which efforts exist is read off that select's own options rather than
|
||||||
|
kept here. It used to be a second copy of `["low","medium","high"]`, which
|
||||||
|
was wrong the moment the vocabulary became per model: a Bonsai takes
|
||||||
|
`xhigh` and no `high`, so the list the server rendered and the list this
|
||||||
|
file believed in disagreed -- and the one that decides what `/effort xhigh`
|
||||||
|
does was this one. The select is the table; nothing else should hold it. */
|
||||||
|
function efforts() {
|
||||||
|
var select = el("[data-effort]");
|
||||||
|
if (!select) return [];
|
||||||
|
return Array.prototype.map
|
||||||
|
.call(select.options, function (option) { return option.value; })
|
||||||
|
.filter(function (value) { return value !== "off"; });
|
||||||
|
}
|
||||||
|
|
||||||
function setEffort(rest) {
|
function setEffort(rest) {
|
||||||
var select = el("[data-effort]");
|
var select = el("[data-effort]");
|
||||||
@@ -283,12 +296,14 @@
|
|||||||
"error"
|
"error"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
var available = efforts();
|
||||||
|
var listed = available.join(", ");
|
||||||
var wanted = (rest || "").trim().toLowerCase();
|
var wanted = (rest || "").trim().toLowerCase();
|
||||||
if (!wanted) {
|
if (!wanted) {
|
||||||
return note(
|
return note(
|
||||||
EFFORTS.indexOf(select.value) === -1
|
available.indexOf(select.value) === -1
|
||||||
? "No effort is being sent. Try low, medium or high."
|
? "No effort is being sent. Try " + listed + "."
|
||||||
: "Effort is " + select.value + ". /effort low, medium, high, or off."
|
: "Effort is " + select.value + ". /effort " + listed + ", or off."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/* "off" is the option's real value, not an empty string: the new-chat form
|
/* "off" is the option's real value, not an empty string: the new-chat form
|
||||||
@@ -296,8 +311,11 @@
|
|||||||
sentinel and this has to match it. "default" and "none" still work,
|
sentinel and this has to match it. "default" and "none" still work,
|
||||||
because somebody's fingers will type them. */
|
because somebody's fingers will type them. */
|
||||||
if (wanted === "default" || wanted === "none") wanted = "off";
|
if (wanted === "default" || wanted === "none") wanted = "off";
|
||||||
else if (wanted !== "off" && EFFORTS.indexOf(wanted) === -1) {
|
else if (wanted !== "off" && available.indexOf(wanted) === -1) {
|
||||||
return note("“" + wanted + "” is not an effort. Try low, medium, high or off.", "error");
|
return note(
|
||||||
|
"“" + wanted + "” is not an effort this model takes. Try " + listed + " or off.",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
select.value = wanted;
|
select.value = wanted;
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ var SHELL = [
|
|||||||
"/static/img/apple-touch-icon-180.png",
|
"/static/img/apple-touch-icon-180.png",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* The URL a page will actually ask for.
|
||||||
|
|
||||||
|
Every `/static/` link carries `?v=<release>` -- see `templating.asset` -- and
|
||||||
|
`caches.match` compares the whole URL, query included. So precaching the bare
|
||||||
|
path would fill the cache with entries no page ever requests, and every asset
|
||||||
|
would go to the network on every load while looking perfectly cached.
|
||||||
|
|
||||||
|
`/offline` is a route rather than an asset and is left alone. */
|
||||||
|
function versioned(path) {
|
||||||
|
return path.indexOf("/static/") === 0 ? path + "?v=" + VERSION : path;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("install", function (event) {
|
self.addEventListener("install", function (event) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE).then(function (cache) {
|
caches.open(CACHE).then(function (cache) {
|
||||||
@@ -57,7 +69,8 @@ self.addEventListener("install", function (event) {
|
|||||||
// and the whole feature silently off, so each entry is added on its own.
|
// and the whole feature silently off, so each entry is added on its own.
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
SHELL.map(function (path) {
|
SHELL.map(function (path) {
|
||||||
return cache.add(new Request(path, { cache: "reload" })).catch(function () {});
|
return cache.add(new Request(versioned(path), { cache: "reload" }))
|
||||||
|
.catch(function () {});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,18 +6,28 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
{#
|
||||||
<div class="sidebar__header">
|
`id="sidebar"` and the drawer's furniture, because below the phone
|
||||||
|
breakpoint `.sidebar` is a fixed overlay that starts closed -- and this one
|
||||||
|
had neither an id for `data-toggle="#sidebar"` to find nor any control to
|
||||||
|
open it. The administration area was reachable on a phone and then
|
||||||
|
unnavigable once you arrived.
|
||||||
|
#}
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<header class="sidebar__header">
|
||||||
|
<div class="sidebar__brand-slot">
|
||||||
{{ brandlink(uid="admin") }}
|
{{ brandlink(uid="admin") }}
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/_sidebar_close.html" %}
|
||||||
|
</header>
|
||||||
|
|
||||||
<nav class="sidebar__scroll" aria-label="Administration">
|
<nav class="sidebar__scroll" aria-label="Administration">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
@@ -106,8 +116,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{% include "partials/_sidebar_scrim.html" %}
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
{% include "partials/_sidebar_toggle.html" %}
|
||||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
<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="Switch theme">
|
||||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||||
|
|||||||
@@ -104,11 +104,39 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Reasoning efforts this model accepts</span>
|
||||||
|
<div class="btn-row">
|
||||||
|
{% for value in efforts %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="reasoning_efforts" value="{{ value }}"
|
||||||
|
{{ 'checked' if value in model_efforts }}>
|
||||||
|
<span class="mono">{{ value }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="field__hint">
|
||||||
|
The vocabulary is <strong>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.
|
||||||
|
<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
|
||||||
|
retried without it and this list corrects itself — so this is worth
|
||||||
|
setting by hand only to save that one round trip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||||||
<select class="select" id="default-effort" name="default_effort">
|
<select class="select" id="default-effort" name="default_effort">
|
||||||
<option value="">None — send nothing</option>
|
<option value="">None — send nothing</option>
|
||||||
{% for value in efforts %}
|
{% for value in model_efforts %}
|
||||||
<option value="{{ value }}"
|
<option value="{{ value }}"
|
||||||
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||||||
{{ value }}
|
{{ value }}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
{% elif brand.icon_paths.favicon %}
|
{% elif brand.icon_paths.favicon %}
|
||||||
<link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}">
|
<link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
|
<link rel="icon" href="{{ asset('img/favicon.svg') }}" type="image/svg+xml">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{#
|
{#
|
||||||
@@ -57,15 +57,15 @@
|
|||||||
{% if brand.icon_paths['apple-touch'] %}
|
{% if brand.icon_paths['apple-touch'] %}
|
||||||
<link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}">
|
<link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon-180.png') }}">
|
<link rel="apple-touch-icon" href="{{ asset('img/apple-touch-icon-180.png') }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-title" content="{{ brand.name }}">
|
<meta name="apple-mobile-web-app-title" content="{{ brand.name }}">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/tokens.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
|
||||||
{#
|
{#
|
||||||
Last, so an administrator's rules win, and before {% block head %} so a page's
|
Last, so an administrator's rules win, and before {% block head %} so a page's
|
||||||
own stylesheet still comes after it. The query string is a hash of everything
|
own stylesheet still comes after it. The query string is a hash of everything
|
||||||
@@ -128,16 +128,16 @@
|
|||||||
|
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
<script src="{{ asset('vendor/htmx.min.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
<script src="{{ asset('vendor/htmx-ext-sse.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
<script src="{{ asset('vendor/alpine.min.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
<script src="{{ asset('js/app.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
|
<script src="{{ asset('js/ui.js') }}" defer></script>
|
||||||
{# commands.js before composer.js: the second reads the first's table to draw
|
{# commands.js before composer.js: the second reads the first's table to draw
|
||||||
the `/` menu, and both are deferred so the order here is the run order. #}
|
the `/` menu, and both are deferred so the order here is the run order. #}
|
||||||
<script src="{{ url_for('static', path='js/commands.js') }}" defer></script>
|
<script src="{{ asset('js/commands.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/composer.js') }}" defer></script>
|
<script src="{{ asset('js/composer.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script>
|
<script src="{{ asset('js/audio.js') }}" defer></script>
|
||||||
|
|
||||||
{#
|
{#
|
||||||
The version in the query string is what versions the worker's cache, so a
|
The version in the query string is what versions the worker's cache, so a
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
{% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %}
|
{% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
{% if terminal_enabled %}
|
{% if terminal_enabled %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/xterm.css') }}">
|
<link rel="stylesheet" href="{{ asset('vendor/xterm.css') }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -393,21 +393,21 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{# Unconditional: every chat has a transcript, and this is what keeps a block
|
{# Unconditional: every chat has a transcript, and this is what keeps a block
|
||||||
somebody opened open across the swaps that arrive twelve times a second. #}
|
somebody opened open across the swaps that arrive twelve times a second. #}
|
||||||
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
<script src="{{ asset('js/steps.js') }}" defer></script>
|
||||||
{% if not chat and (canvas_enabled or terminal_enabled) %}
|
{% if not chat and (canvas_enabled or terminal_enabled) %}
|
||||||
{# Only where there is no chat yet. It points both panels at a draft id for
|
{# Only where there is no chat yet. It points both panels at a draft id for
|
||||||
whatever the composer has selected, and does nothing at all once a chat
|
whatever the composer has selected, and does nothing at all once a chat
|
||||||
exists -- which is every other page this block renders on. #}
|
exists -- which is every other page this block renders on. #}
|
||||||
<script src="{{ url_for('static', path='js/draft.js') }}" defer></script>
|
<script src="{{ asset('js/draft.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if canvas_enabled %}
|
{% if canvas_enabled %}
|
||||||
<script src="{{ url_for('static', path='js/canvas.js') }}" defer></script>
|
<script src="{{ asset('js/canvas.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if terminal_enabled %}
|
{% if terminal_enabled %}
|
||||||
{# Only where it can be used. xterm is nearly three times everything else
|
{# Only where it can be used. xterm is nearly three times everything else
|
||||||
vendored, so a plain chat must never load it. #}
|
vendored, so a plain chat must never load it. #}
|
||||||
<script src="{{ url_for('static', path='vendor/xterm.js') }}" defer></script>
|
<script src="{{ asset('vendor/xterm.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='vendor/xterm-addon-fit.js') }}" defer></script>
|
<script src="{{ asset('vendor/xterm-addon-fit.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/terminal.js') }}" defer></script>
|
<script src="{{ asset('js/terminal.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
{% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %}
|
{% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{% block title %}Messages - {{ brand.name }}{% endblock %}
|
{% block title %}Messages - {{ brand.name }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
@@ -124,5 +124,5 @@
|
|||||||
DOM stub, which is the rule the working notes set out and the reason it does.
|
DOM stub, which is the rule the working notes set out and the reason it does.
|
||||||
#}
|
#}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
<script src="{{ asset('js/steps.js') }}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
The way out of the drawer, and the reason it is *inside* it.
|
||||||
|
|
||||||
|
Below the phone breakpoint the sidebar is a fixed overlay and the toggle that
|
||||||
|
opens it is in the topbar underneath -- so once open, the control for closing
|
||||||
|
it is behind it. Its own partial because there are two sidebars in this
|
||||||
|
application, the chat one and the admin one, and the second was given the
|
||||||
|
drawer behaviour without the drawer's furniture: at a phone width it was
|
||||||
|
hidden off-screen with no toggle and no close anywhere, which is an admin area
|
||||||
|
that simply could not be navigated on a phone.
|
||||||
|
|
||||||
|
Hidden above that breakpoint, where the sidebar is an ordinary column.
|
||||||
|
#}
|
||||||
|
<div class="sidebar__actions-rail">
|
||||||
|
<button class="btn btn--icon sidebar__close" type="button"
|
||||||
|
aria-label="Close sidebar" data-toggle="#sidebar">
|
||||||
|
{{ icon("x") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{#
|
||||||
|
The scrim behind an open drawer. It carries the same `data-toggle` as every
|
||||||
|
other control that closes it, so tapping beside the drawer goes through one
|
||||||
|
code path rather than a second written for touch.
|
||||||
|
|
||||||
|
Rendered always and shown by CSS: it exists only below the breakpoint and only
|
||||||
|
while the drawer is open, which is a question about width and state that the
|
||||||
|
server cannot answer and the stylesheet can.
|
||||||
|
#}
|
||||||
|
<div class="sidebar-scrim" data-toggle="#sidebar" aria-hidden="true"></div>
|
||||||
@@ -7,26 +7,29 @@
|
|||||||
nothing behind.
|
nothing behind.
|
||||||
#}
|
#}
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar__header">
|
|
||||||
{{ brandlink(uid="side") }}
|
|
||||||
{#
|
{#
|
||||||
The way out, and the reason it is *inside* the drawer.
|
The header is two slots, not a brand with something appended to it.
|
||||||
|
|
||||||
Below the phone breakpoint this whole element is a fixed overlay, and the
|
`__brand` holds the identity and is the only part allowed to shrink;
|
||||||
toggle that opens it lives in the topbar underneath -- so once it was
|
`__actions` is a fixed-width rail on the trailing edge that anything
|
||||||
open, the control for closing it was behind it. That was true on /chat,
|
belonging to the drawer itself hangs off. It is a rail rather than one
|
||||||
where at least a toggle existed; on the seven other pages that carry this
|
button because a second one -- pin the sidebar open, a search -- would
|
||||||
sidebar there was no such control at all, and no way back.
|
otherwise be appended to the brand again, and the alignment would be a
|
||||||
|
coincidence for the third time.
|
||||||
|
|
||||||
Hidden above that breakpoint, where the sidebar is an ordinary column and
|
This is the standing rule about rows applied to a row that got it wrong:
|
||||||
the topbar's toggle is perfectly visible.
|
the two parts have a known width (a rail of `--control-h` boxes) and an
|
||||||
|
unknown one (a name somebody chose), so the unknown one is the one that
|
||||||
|
gives, and the rail is `flex: none`.
|
||||||
#}
|
#}
|
||||||
<button class="btn btn--icon sidebar__close" type="button"
|
<header class="sidebar__header">
|
||||||
aria-label="Close sidebar" data-toggle="#sidebar">
|
<div class="sidebar__brand-slot">
|
||||||
{{ icon("x") }}
|
{{ brandlink(uid="side") }}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "partials/_sidebar_close.html" %}
|
||||||
|
</header>
|
||||||
|
|
||||||
{% include "partials/_sidebar_actions.html" %}
|
{% include "partials/_sidebar_actions.html" %}
|
||||||
|
|
||||||
{# Every 10s, refresh the unread dots and announce anything that finished
|
{# Every 10s, refresh the unread dots and announce anything that finished
|
||||||
@@ -103,13 +106,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#
|
{% include "partials/_sidebar_scrim.html" %}
|
||||||
The scrim behind the open drawer. It carries the same `data-toggle` as every
|
|
||||||
other control that closes it, so tapping beside the drawer closes it through
|
|
||||||
exactly one code path rather than a second one written for touch.
|
|
||||||
|
|
||||||
Rendered always and shown by CSS: it exists only below the breakpoint and only
|
|
||||||
while the drawer is open, which is a question about width and state that the
|
|
||||||
server cannot answer and the stylesheet can.
|
|
||||||
#}
|
|
||||||
<div class="sidebar-scrim" data-toggle="#sidebar" aria-hidden="true"></div>
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
{% block title %}Your settings - {{ brand.name }}{% endblock %}
|
{% block title %}Your settings - {{ brand.name }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|||||||
@@ -61,6 +61,34 @@ templates.env.filters["tokens"] = highlight_tokens
|
|||||||
templates.env.globals["tool_label"] = tool_labels.label_for
|
templates.env.globals["tool_label"] = tool_labels.label_for
|
||||||
templates.env.globals["tool_icon"] = tool_labels.icon_for
|
templates.env.globals["tool_icon"] = tool_labels.icon_for
|
||||||
|
|
||||||
|
def asset(path: str) -> str:
|
||||||
|
"""A static asset's URL, with the release stamped into it.
|
||||||
|
|
||||||
|
🚨 This is not cache politeness, it is what stops a release drawing itself
|
||||||
|
from two versions at once.
|
||||||
|
|
||||||
|
The service worker caches `/static/...` under a cache named for the
|
||||||
|
release, and a *page* is fetched network-first while its assets come from
|
||||||
|
that cache. So the moment the worker stops taking over open tabs the
|
||||||
|
instant it installs -- which it must, or it swaps the stylesheets under
|
||||||
|
somebody mid-reply -- the new HTML and the old CSS are served together and
|
||||||
|
the interface is subtly wrong until the worker is replaced. That shipped in
|
||||||
|
1.1.0: a close button intended for a phone drawer appeared, unstyled, on
|
||||||
|
every desktop, because the markup knew about it and the stylesheet did not.
|
||||||
|
|
||||||
|
A version in the URL settles it without anybody having to be careful: the
|
||||||
|
new HTML asks for a URL the old cache has never heard of, so it goes to the
|
||||||
|
network. The two can no longer disagree, whichever worker is in charge.
|
||||||
|
|
||||||
|
Not a hash of the file: `__version__` is the one thing that already moves
|
||||||
|
with every release, and a hash would mean reading every asset on every
|
||||||
|
render or a build step, and there is deliberately no build step here.
|
||||||
|
"""
|
||||||
|
return f"/static/{path.lstrip('/')}?v={__version__}"
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.globals["asset"] = asset
|
||||||
|
|
||||||
# A finished reply as the sequence of steps it was. A global for exactly the
|
# A finished reply as the sequence of steps it was. A global for exactly the
|
||||||
# reason the two above are, and it is why turning the bubble into a sequence
|
# reason the two above are, and it is why turning the bubble into a sequence
|
||||||
# needed no change in `pages.py`, `post_message`, `regenerate` or the `done`
|
# needed no change in `pages.py`, `post_message`, `regenerate` or the `done`
|
||||||
|
|||||||
@@ -366,3 +366,93 @@ def test_the_picker_never_says_default(client: TestClient, db, registered):
|
|||||||
assert "Effort: default" not in html
|
assert "Effort: default" not in html
|
||||||
assert "Effort: off" in html
|
assert "Effort: off" in html
|
||||||
assert '<option value="medium" selected>' in html.replace("\n", "").replace(" ", "")
|
assert '<option value="medium" selected>' in html.replace("\n", "").replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- A vocabulary that is not the same for every model -----------------------
|
||||||
|
#
|
||||||
|
# Reported from a real instance, on a model called Bonsai:
|
||||||
|
#
|
||||||
|
# Jinja Exception: Unexpected reasoning effort high. Supported types are
|
||||||
|
# xhigh (default), medium, and low.
|
||||||
|
#
|
||||||
|
# `chat_template_kwargs.reasoning_effort` is rendered into the model's own chat
|
||||||
|
# template, and a template that does not know the value calls `raise_exception`
|
||||||
|
# rather than ignoring it -- so the whole reply died, from an option this
|
||||||
|
# application had drawn in a menu.
|
||||||
|
BONSAI_ERROR = (
|
||||||
|
"Jinja Exception: Unexpected reasoning effort high. "
|
||||||
|
"Supported types are xhigh (default), medium, and low."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModel:
|
||||||
|
def __init__(self, efforts=None):
|
||||||
|
self.reasoning_efforts = efforts or []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_model_that_has_said_nothing_gets_the_common_three():
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
assert chat_service.efforts_for(_FakeModel()) == ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_model_can_take_xhigh_and_not_high():
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
bonsai = _FakeModel(["xhigh", "medium", "low"])
|
||||||
|
assert chat_service.efforts_for(bonsai) == ("low", "medium", "xhigh")
|
||||||
|
assert "high" not in chat_service.efforts_for(bonsai)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_effort_the_model_refuses_is_never_sent():
|
||||||
|
"""The check that stops the crash happening at all."""
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
supported = chat_service.efforts_for(_FakeModel(["xhigh", "medium", "low"]))
|
||||||
|
body: dict = {}
|
||||||
|
chat_service.apply_effort(body, "high", supported)
|
||||||
|
assert body == {}
|
||||||
|
|
||||||
|
chat_service.apply_effort(body, "xhigh", supported)
|
||||||
|
assert body["reasoning_effort"] == "xhigh"
|
||||||
|
assert body["chat_template_kwargs"]["reasoning_effort"] == "xhigh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_this_application_never_heard_of_cannot_reach_a_request():
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
assert chat_service.efforts_for(_FakeModel(["ludicrous"])) == ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_refusal_is_recognised_and_the_supported_list_read_out_of_it():
|
||||||
|
from lembas.services import generation
|
||||||
|
|
||||||
|
assert generation._effort_was_refused(BONSAI_ERROR)
|
||||||
|
assert generation._advertised_efforts(BONSAI_ERROR) == ["low", "medium", "xhigh"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rejected_value_is_not_collected_as_a_supported_one():
|
||||||
|
"""The message names the refused effort first and the supported ones after,
|
||||||
|
so anything reading the whole string would learn `high` from a sentence
|
||||||
|
saying `high` is the problem."""
|
||||||
|
from lembas.services import generation
|
||||||
|
|
||||||
|
assert "high" not in generation._advertised_efforts(BONSAI_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ordinary_failure_is_not_retried_as_an_effort_problem():
|
||||||
|
"""Retrying a genuine failure would hide it behind a second request."""
|
||||||
|
from lembas.services import generation
|
||||||
|
|
||||||
|
for message in (
|
||||||
|
"Connection refused.",
|
||||||
|
"The model is still loading.",
|
||||||
|
"context length exceeded",
|
||||||
|
):
|
||||||
|
assert not generation._effort_was_refused(message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_model_with_no_advertisement_simply_loses_the_refused_value():
|
||||||
|
from lembas.services import generation
|
||||||
|
|
||||||
|
assert generation._advertised_efforts("Unexpected reasoning effort high.") == []
|
||||||
|
|||||||
@@ -264,3 +264,50 @@ def test_the_page_asks_for_the_whole_screen_and_then_pays_for_it(client, registe
|
|||||||
app = (STATIC_DIR / "css" / "app.css").read_text()
|
app = (STATIC_DIR / "css" / "app.css").read_text()
|
||||||
assert "var(--safe-top)" in app
|
assert "var(--safe-top)" in app
|
||||||
assert "var(--safe-bottom)" in app
|
assert "var(--safe-bottom)" in app
|
||||||
|
|
||||||
|
|
||||||
|
# --- A release cannot be drawn with the previous release's stylesheet --------
|
||||||
|
def test_every_static_asset_carries_the_release(client: TestClient, registered):
|
||||||
|
"""The bug this is here to stop shipped in 1.1.0.
|
||||||
|
|
||||||
|
The worker caches `/static/...` under a cache named for the release, and a
|
||||||
|
page is fetched network-first while its assets come from that cache -- so
|
||||||
|
once the worker stopped claiming open tabs the instant it installed (which
|
||||||
|
it had to, or it swaps stylesheets under somebody mid-reply), new HTML and
|
||||||
|
old CSS were served together. What that looked like was a close button
|
||||||
|
meant for a phone drawer appearing, unstyled, on every desktop.
|
||||||
|
|
||||||
|
A version in the URL settles it: the new HTML asks for something the old
|
||||||
|
cache has never heard of.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
for path in ("/chat", "/settings"):
|
||||||
|
page = client.get(path).text
|
||||||
|
bare = re.findall(r'(?:href|src)="(/static/[^"?]+)"', page)
|
||||||
|
assert not bare, f"{path} loads unversioned assets: {bare[:5]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_template_reaches_past_the_helper(client: TestClient):
|
||||||
|
"""`url_for('static', ...)` produces a URL with no version in it, so one
|
||||||
|
left behind is one asset that can still come from the wrong release."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import lembas
|
||||||
|
|
||||||
|
root = Path(lembas.__file__).parent / "web/templates"
|
||||||
|
offenders = [
|
||||||
|
str(p.relative_to(root))
|
||||||
|
for p in root.rglob("*.html")
|
||||||
|
if "url_for('static'" in p.read_text(encoding="utf-8")
|
||||||
|
]
|
||||||
|
assert not offenders, f"still using url_for for static assets: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_worker_precaches_what_a_page_will_ask_for():
|
||||||
|
"""`caches.match` compares the whole URL. Precaching the bare path fills the
|
||||||
|
cache with entries nothing requests, and every asset then goes to the
|
||||||
|
network on every load while looking perfectly cached."""
|
||||||
|
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||||
|
assert 'path + "?v=" + VERSION' in source
|
||||||
|
assert "versioned(path)" in source
|
||||||
|
|||||||
@@ -95,9 +95,12 @@ def test_inert_is_never_left_behind_on_a_widened_window():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_drawer_is_dismissible_without_finding_a_button():
|
def test_the_drawer_is_dismissible_without_finding_a_button():
|
||||||
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8")
|
"""The scrim is a partial because there are two sidebars, so the markup is
|
||||||
assert 'class="sidebar-scrim"' in sidebar
|
asserted where it is defined and its *inclusion* is asserted per sidebar by
|
||||||
assert 'data-toggle="#sidebar"' in sidebar
|
`test_every_sidebar_carries_the_way_out_and_the_scrim`."""
|
||||||
|
scrim = (TEMPLATES / "partials/_sidebar_scrim.html").read_text(encoding="utf-8")
|
||||||
|
assert 'class="sidebar-scrim"' in scrim
|
||||||
|
assert 'data-toggle="#sidebar"' in scrim
|
||||||
assert ".sidebar-scrim" in APP_CSS
|
assert ".sidebar-scrim" in APP_CSS
|
||||||
|
|
||||||
|
|
||||||
@@ -113,3 +116,67 @@ def test_the_toggle_is_a_real_target(client: TestClient, registered):
|
|||||||
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
||||||
assert "--tap-min: 2.75rem" in tokens
|
assert "--tap-min: 2.75rem" in tokens
|
||||||
assert "--control-h: var(--tap-min)" in tokens
|
assert "--control-h: var(--tap-min)" in tokens
|
||||||
|
|
||||||
|
|
||||||
|
# --- Any sidebar, not only the one this was written for ----------------------
|
||||||
|
def _sidebar_templates() -> list[str]:
|
||||||
|
"""Every template that renders a sidebar of its own, found rather than
|
||||||
|
listed -- the admin one was missed precisely because it was not on a list."""
|
||||||
|
return [
|
||||||
|
str(p.relative_to(TEMPLATES))
|
||||||
|
for p in TEMPLATES.rglob("*.html")
|
||||||
|
if '<aside class="sidebar"' in p.read_text(encoding="utf-8")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sidebar_is_one_the_toggle_can_find():
|
||||||
|
"""`data-toggle="#sidebar"` resolves by id, and below the phone breakpoint
|
||||||
|
`.sidebar` is a fixed overlay that starts closed. A sidebar without that id
|
||||||
|
is one nothing can open: the admin area shipped that way in 1.1.0 and 1.1.1
|
||||||
|
-- reachable on a phone, and unnavigable the moment you arrived."""
|
||||||
|
without = [
|
||||||
|
name
|
||||||
|
for name in _sidebar_templates()
|
||||||
|
if '<aside class="sidebar" id="sidebar"' not in (TEMPLATES / name).read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert not without, f"sidebar with no id, so nothing can open it: {without}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sidebar_carries_the_way_out_and_the_scrim():
|
||||||
|
missing = []
|
||||||
|
for name in _sidebar_templates():
|
||||||
|
text = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
if "partials/_sidebar_close.html" not in text:
|
||||||
|
missing.append(f"{name}: no close button")
|
||||||
|
if "partials/_sidebar_scrim.html" not in text:
|
||||||
|
missing.append(f"{name}: no scrim")
|
||||||
|
assert not missing, missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_area_can_be_navigated_on_a_phone(client: TestClient, registered):
|
||||||
|
"""The whole of administration is in that nav and nowhere else."""
|
||||||
|
page = client.get("/admin/models").text
|
||||||
|
assert '<aside class="sidebar" id="sidebar"' in page
|
||||||
|
assert 'data-toggle="#sidebar"' in page
|
||||||
|
assert "sidebar-scrim" in page
|
||||||
|
|
||||||
|
|
||||||
|
# --- Controls do not shrink below their own size -----------------------------
|
||||||
|
def test_an_icon_button_keeps_its_size_in_a_tight_row():
|
||||||
|
"""`.btn--icon` sets a width and, without `flex: none`, a row that runs out
|
||||||
|
of room shrinks it instead of the text beside it -- the sidebar toggle
|
||||||
|
measured 18px across on a 390px chat, well under half its target."""
|
||||||
|
rule = APP_CSS.split(".btn--icon {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "flex: none" in rule
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_topbar_can_give_somewhere(client: TestClient, registered):
|
||||||
|
"""`.topbar__where` was the designated shrinker in that row and it is
|
||||||
|
`display: none` below 64rem, so on a phone the group went rigid and the
|
||||||
|
title -- which is `flex: 1` -- was squeezed to exactly zero width."""
|
||||||
|
actions = APP_CSS.split(".topbar__actions {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "flex: 0 1 auto" in actions
|
||||||
|
assert "min-width: 0" in actions
|
||||||
|
assert "min-width" in APP_CSS.split(".topbar__title {", 1)[1].split("}", 1)[0]
|
||||||
|
|||||||
+6
-1
@@ -324,7 +324,12 @@ def test_no_page_loads_a_script_that_the_base_template_already_loads():
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
templates = Path(__file__).resolve().parents[1] / "src/lembas/web/templates"
|
templates = Path(__file__).resolve().parents[1] / "src/lembas/web/templates"
|
||||||
pattern = re.compile(r"path='js/([a-z_]+\.js)'")
|
# Both spellings, because the way a static URL is written has changed once
|
||||||
|
# already: `url_for('static', path='js/x.js')` became `asset('js/x.js')`
|
||||||
|
# when assets started carrying the release. The assertion below that the set
|
||||||
|
# is non-empty is what turned that rename into a loud failure rather than a
|
||||||
|
# sweep that silently stopped sweeping -- keep it.
|
||||||
|
pattern = re.compile(r"(?:path=|asset\()'js/([a-z_]+\.js)'")
|
||||||
always = set(pattern.findall((templates / "base.html").read_text()))
|
always = set(pattern.findall((templates / "base.html").read_text()))
|
||||||
assert always, "base.html stopped loading any script; this test is now blind"
|
assert always, "base.html stopped loading any script; this test is now blind"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user