The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,10 +49,15 @@ runtime. Clone it, `pip install -e .`, run it.
|
|||||||
- **`@` to name something** — a document from your library, or in an agent chat
|
- **`@` to name something** — a document from your library, or in an agent chat
|
||||||
a file in the project directory. The reference stays in the sentence you are
|
a file in the project directory. The reference stays in the sentence you are
|
||||||
writing and the contents come with it
|
writing and the contents come with it
|
||||||
- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/model`,
|
- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/effort high`,
|
||||||
`/title`, `/terminal`, `/theme`. `/help` lists them and the keyboard
|
`/model`, `/title`, `/terminal`, `/theme`. The list appears as you type and
|
||||||
shortcuts beside them. A message that merely starts with a slash is still
|
filters as you go; `/help` shows all of them with the keyboard shortcuts
|
||||||
sent as written
|
beside them. A message that merely starts with a slash is still sent as
|
||||||
|
written, and both `@` and a recognised command are marked in the box as you
|
||||||
|
type so you can see what will happen before you press Enter
|
||||||
|
- **Reasoning effort** — `/effort low`, `medium` or `high` on a model marked as
|
||||||
|
reasoning, with a per-model default in the admin area. Sent two ways at once,
|
||||||
|
because there is no single field every endpoint reads
|
||||||
- **Folders** — arbitrarily nested, delete a folder without losing the chats
|
- **Folders** — arbitrarily nested, delete a folder without losing the chats
|
||||||
inside it
|
inside it
|
||||||
- **Web search** — offered to the model as a tool it calls when a question needs
|
- **Web search** — offered to the model as a tool it calls when a question needs
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lembas"
|
name = "lembas"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -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__ = "0.6.0"
|
__version__ = "0.6.1"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
|
|
||||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||||
from lembas.db.models import Connection, Group, Model
|
from lembas.db.models import Connection, Group, Model
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import settings_store, uploads
|
from lembas.services import settings_store, uploads
|
||||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
from lembas.services.llm.openai_client import MAX_CONTEXT
|
||||||
from lembas.web.templating import render
|
from lembas.web.templating import render
|
||||||
@@ -167,6 +168,7 @@ 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,
|
||||||
|
"efforts": chat_service.EFFORTS,
|
||||||
# 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
|
||||||
@@ -226,6 +228,7 @@ async def update_model(
|
|||||||
public: bool = Form(False),
|
public: bool = Form(False),
|
||||||
position: str = Form(""),
|
position: str = Form(""),
|
||||||
context_length: str = Form(""),
|
context_length: str = Form(""),
|
||||||
|
default_effort: str = Form(""),
|
||||||
group_ids: list[str] = Form(default=[]),
|
group_ids: list[str] = Form(default=[]),
|
||||||
capability: list[str] = Form(default=[]),
|
capability: list[str] = Form(default=[]),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -245,6 +248,17 @@ async def update_model(
|
|||||||
model.pinned = pinned
|
model.pinned = pinned
|
||||||
model.public = public
|
model.public = public
|
||||||
|
|
||||||
|
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||||
|
# holds whatever sampling defaults an administrator has set and this form
|
||||||
|
# only carries one of them.
|
||||||
|
params = dict(model.params_json or {})
|
||||||
|
wanted = default_effort.strip().lower()
|
||||||
|
if wanted in chat_service.EFFORTS:
|
||||||
|
params["reasoning_effort"] = wanted
|
||||||
|
else:
|
||||||
|
params.pop("reasoning_effort", None)
|
||||||
|
model.params_json = params
|
||||||
|
|
||||||
# Absent checkboxes are simply missing from a form post, so the submitted
|
# Absent checkboxes are simply missing from a form post, so the submitted
|
||||||
# list IS the complete new state -- rebuild rather than merge.
|
# list IS the complete new state -- rebuild rather than merge.
|
||||||
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
|
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from lembas.db.models import (
|
|||||||
ROLE_USER,
|
ROLE_USER,
|
||||||
Chat,
|
Chat,
|
||||||
Message,
|
Message,
|
||||||
|
Model,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from lembas.db.session import session_scope
|
from lembas.db.session import session_scope
|
||||||
@@ -92,6 +93,21 @@ def _new_chat(
|
|||||||
if chosen is None:
|
if chosen is None:
|
||||||
chosen = chat_service.default_model(db, user)
|
chosen = chat_service.default_model(db, user)
|
||||||
|
|
||||||
|
# `Model.params_json` has said "default sampling params applied to new chats
|
||||||
|
# using this model" since it was added and has been applied nowhere. It is
|
||||||
|
# empty on every existing row, so honouring it now changes nothing until an
|
||||||
|
# administrator sets something -- and it is what makes a per-model default
|
||||||
|
# reasoning effort possible without a second column meaning the same thing.
|
||||||
|
defaults: dict = {}
|
||||||
|
if chosen is not None:
|
||||||
|
model = db.scalar(
|
||||||
|
select(Model).where(
|
||||||
|
Model.model_id == chosen[0], Model.connection_id == chosen[1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if model is not None:
|
||||||
|
defaults = dict(model.params_json or {})
|
||||||
|
|
||||||
profile = _agent_target(db, user, kind, ssh_profile_id)
|
profile = _agent_target(db, user, kind, ssh_profile_id)
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -102,6 +118,7 @@ def _new_chat(
|
|||||||
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
||||||
ssh_profile_id=profile.id if profile is not None else None,
|
ssh_profile_id=profile.id if profile is not None else None,
|
||||||
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
|
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
|
||||||
|
params_json=defaults,
|
||||||
)
|
)
|
||||||
# Ignored rather than refused when it is not a mode, matching how every
|
# Ignored rather than refused when it is not a mode, matching how every
|
||||||
# other bad value here collapses: somebody who mistypes should get a chat
|
# other bad value here collapses: somebody who mistypes should get a chat
|
||||||
@@ -1020,6 +1037,20 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
**_clean_params(**{k: str(v) for k, v in submitted_params.items()}),
|
**_clean_params(**{k: str(v) for k, v in submitted_params.items()}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Not a number, so it cannot go through _PARAM_RANGES. Empty means clear it,
|
||||||
|
# the same as every other parameter here; anything that is not one of the
|
||||||
|
# three is ignored rather than refused, so a typo does not cost a message.
|
||||||
|
if "reasoning_effort" in form:
|
||||||
|
if not allowed.get("chat.params"):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN, "You may not change sampling parameters."
|
||||||
|
)
|
||||||
|
wanted = str(form["reasoning_effort"]).strip().lower()
|
||||||
|
if not wanted:
|
||||||
|
chat.params_json = {**(chat.params_json or {}), "reasoning_effort": None}
|
||||||
|
elif wanted in chat_service.EFFORTS:
|
||||||
|
chat.params_json = {**(chat.params_json or {}), "reasoning_effort": wanted}
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ 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
|
||||||
|
# command, the control and the request builder cannot disagree about
|
||||||
|
# what is a valid effort.
|
||||||
|
"efforts": chat_service.EFFORTS,
|
||||||
**_agent_context(db, user, chat),
|
**_agent_context(db, user, chat),
|
||||||
**audio_service.template_flags(db, user),
|
**audio_service.template_flags(db, user),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,9 +305,38 @@ def build_request(
|
|||||||
}
|
}
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = tools
|
body["tools"] = tools
|
||||||
|
|
||||||
|
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Reasoning effort, and why it goes out twice.
|
||||||
|
#
|
||||||
|
# There is no one field that works. OpenAI and vLLM read a plain
|
||||||
|
# `reasoning_effort`. llama.cpp reads it too and, per its own documentation,
|
||||||
|
# "other values (e.g. 'low', 'max') have no effect" -- its maintainer is blunter
|
||||||
|
# still: "llama-server cannot support reasoning_effort at all", and the field
|
||||||
|
# "simply gets dropped without error or logging". What *does* reach a gpt-oss
|
||||||
|
# behind llama.cpp is `chat_template_kwargs`, which it accepts per request.
|
||||||
|
#
|
||||||
|
# So both are sent, and only when an effort has actually been chosen. That
|
||||||
|
# second half is what keeps this from being a regression: a chat nobody has set
|
||||||
|
# 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
|
||||||
|
# somebody deliberately set an effort on, not on every chat in the instance.
|
||||||
|
EFFORTS = ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_effort(body: dict[str, Any], effort: str | None) -> None:
|
||||||
|
"""Put a chosen reasoning effort into a request body, in both forms."""
|
||||||
|
if not effort or effort not in EFFORTS:
|
||||||
|
return
|
||||||
|
body["reasoning_effort"] = effort
|
||||||
|
kwargs = dict(body.get("chat_template_kwargs") or {})
|
||||||
|
kwargs["reasoning_effort"] = effort
|
||||||
|
body["chat_template_kwargs"] = kwargs
|
||||||
|
|
||||||
|
|
||||||
def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
||||||
"""The model a new chat should start with, as (model_id, connection_id).
|
"""The model a new chat should start with, as (model_id, connection_id).
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,37 @@ def render_markdown(text: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A mention is `@` followed by a run of non-space, claimed only at the start of
|
||||||
|
# the text or after whitespace. That last part is the whole rule: without it
|
||||||
|
# every email address in a message becomes a highlighted file reference, which
|
||||||
|
# is both wrong and ugly. It matches what composer.js recognises while typing,
|
||||||
|
# and the two must stay in step or the box and the transcript disagree.
|
||||||
|
_MENTION = re.compile(r"(?:(?<=\s)|^)@([^\s@]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_tokens(text: str) -> str:
|
||||||
|
"""A user's own message, escaped, with `@mentions` marked.
|
||||||
|
|
||||||
|
User turns have no render step at all -- the template prints the column and
|
||||||
|
relies on `white-space: pre-wrap` -- so this is it, and it must escape
|
||||||
|
before it injects or it is an XSS hole in the one place a person controls
|
||||||
|
the bytes exactly.
|
||||||
|
|
||||||
|
Only mentions. A `/command` never survives to a message: commands are
|
||||||
|
intercepted in the composer and never posted, so anything beginning with a
|
||||||
|
slash in a transcript is text somebody meant as text, and marking it as a
|
||||||
|
command would be marking it as something it is not.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
escaped = html.escape(text, quote=False)
|
||||||
|
# Applied to the *escaped* string, so the span is the only markup that can
|
||||||
|
# exist. `@` and the path characters are untouched by html.escape, and a
|
||||||
|
# `&` it produced contains no whitespace -- which is why the pattern is
|
||||||
|
# anchored on whitespace rather than on a character class.
|
||||||
|
return _MENTION.sub(r'<span class="tok-mention">@\1</span>', escaped)
|
||||||
|
|
||||||
|
|
||||||
def escape_text(text: str) -> str:
|
def escape_text(text: str) -> str:
|
||||||
"""Escape a plain-text run for insertion as HTML element content.
|
"""Escape a plain-text run for insertion as HTML element content.
|
||||||
|
|
||||||
|
|||||||
@@ -676,6 +676,43 @@ body.is-resizing .terminal__screen { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
Which machine an agent chat runs on, and where.
|
||||||
|
|
||||||
|
Its own flex item between the title and the actions, and it shrinks before
|
||||||
|
the title does: `flex: 0 1 auto` with `min-width: 0` means a long path gives
|
||||||
|
up its characters first, which is right -- a truncated title is a chat you
|
||||||
|
cannot identify, a truncated path is still a path you recognise. Below 64rem
|
||||||
|
it goes entirely; the terminal button beside it already says which machine.
|
||||||
|
*/
|
||||||
|
.topbar__where {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 24rem;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.topbar__where-name { flex: none; }
|
||||||
|
.topbar__where-dir {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 64rem) {
|
||||||
|
.topbar__where { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* The model picker: an avatar and a select sharing one frame, so it reads as a
|
/* The model picker: an avatar and a select sharing one frame, so it reads as a
|
||||||
single control rather than two things that happen to be adjacent. */
|
single control rather than two things that happen to be adjacent. */
|
||||||
.model-select {
|
.model-select {
|
||||||
@@ -1057,7 +1094,29 @@ body.is-resizing .terminal__screen { pointer-events: none; }
|
|||||||
.dialog__results .picker__list { max-height: none; }
|
.dialog__results .picker__list { max-height: none; }
|
||||||
|
|
||||||
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
|
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||||
|
/*
|
||||||
|
Small controls, declared here because this is the file every page loads.
|
||||||
|
|
||||||
|
`.select--sm` was used in three places and defined in none: chat.css had a
|
||||||
|
copy, it was deleted as a duplicate, and the replacement never landed. A
|
||||||
|
select with only `.select` takes `width: 100%` and `--control-h` -- so in a
|
||||||
|
flex row every one of them asked for the whole width, all of them shrank
|
||||||
|
together until each was a few characters wide, and each stood half a rem
|
||||||
|
taller than the buttons beside it. That is what "the connection switch needs
|
||||||
|
to be wider" was.
|
||||||
|
|
||||||
|
`width: auto` is the important half: a small select should be as wide as its
|
||||||
|
longest option, not as wide as it can get away with.
|
||||||
|
*/
|
||||||
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
|
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
|
||||||
|
.select--sm {
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
padding: 0 var(--sp-6) 0 var(--control-px-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
width: auto;
|
||||||
|
max-width: 14rem;
|
||||||
|
background-position: right 0.75rem center, right 0.5rem center;
|
||||||
|
}
|
||||||
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
|
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
|
||||||
|
|
||||||
.picker__option {
|
.picker__option {
|
||||||
|
|||||||
@@ -243,6 +243,18 @@
|
|||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Something is happening to the whole thread rather than to one message --
|
||||||
|
compaction, which makes a model call and can take a while. Shaped like a
|
||||||
|
turn so it sits in the column rather than floating over it. */
|
||||||
|
.thread__working {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Metrics ---------------------------------------------------------------
|
/* --- Metrics ---------------------------------------------------------------
|
||||||
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
||||||
something to read every time.
|
something to read every time.
|
||||||
@@ -697,20 +709,76 @@
|
|||||||
box-shadow: var(--ring);
|
box-shadow: var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer__input {
|
/*
|
||||||
|
The text, and the mirror behind it.
|
||||||
|
|
||||||
|
Every property that affects where a character lands is declared once, on both
|
||||||
|
-- font, size, line height, padding, wrapping. A single difference and the
|
||||||
|
rectangles slide off the words they are marking. `font: inherit` on the
|
||||||
|
textarea is not enough: a textarea's default font is not the page's.
|
||||||
|
*/
|
||||||
|
.composer__field { position: relative; }
|
||||||
|
|
||||||
|
.composer__input,
|
||||||
|
.composer__mirror {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 0;
|
margin: 0;
|
||||||
background: none;
|
|
||||||
resize: none;
|
|
||||||
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
||||||
|
border: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
|
letter-spacing: normal;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__input {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
resize: none;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.composer__input:focus { outline: none; }
|
.composer__input:focus { outline: none; }
|
||||||
|
|
||||||
|
/* Behind, and contributing nothing but background rectangles: its own text is
|
||||||
|
transparent, so the glyphs on screen are always the textarea's. That is what
|
||||||
|
makes a pixel of drift invisible rather than a doubled letter. */
|
||||||
|
.composer__mirror {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.composer__mirror .tok-mention,
|
||||||
|
.composer__mirror .tok-command {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
/* Bled sideways so the rectangle does not sit hard against the next word,
|
||||||
|
and the negative margin keeps the text metrics identical. */
|
||||||
|
padding: 0 2px;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
.composer__mirror .tok-mention { background: var(--accent-soft); }
|
||||||
|
.composer__mirror .tok-command { background: var(--leaf-soft); }
|
||||||
|
|
||||||
|
/* The same two in a sent message, where they are text rather than a backdrop. */
|
||||||
|
.tok-mention {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 2px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Everything that acts on the message, on one line under it. It wraps rather
|
/* Everything that acts on the message, on one line under it. It wraps rather
|
||||||
than scrolls: on a narrow window the context controls drop to their own row
|
than scrolls: on a narrow window the context controls drop to their own row
|
||||||
and attach/send stay where the thumb expects them. */
|
and attach/send stay where the thumb expects them. */
|
||||||
@@ -740,30 +808,6 @@
|
|||||||
.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; }
|
.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; }
|
||||||
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* The connection and directory of a chat already under way. Not a control:
|
|
||||||
update_chat refuses to change either, so showing them as one is honest. */
|
|
||||||
.composer__where {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 20rem;
|
|
||||||
padding: 0 var(--sp-2);
|
|
||||||
height: var(--control-h-sm);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--surface-hover);
|
|
||||||
color: var(--ink-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.composer__where-dir {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer__hint {
|
.composer__hint {
|
||||||
margin: var(--sp-2) 0 0;
|
margin: var(--sp-2) 0 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -807,6 +851,20 @@
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* What the keys do, pinned to the bottom of the menu. */
|
||||||
|
.composer-menu__keys {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: var(--sp-1) var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.picker__group {
|
.picker__group {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--sp-2) var(--sp-3) var(--sp-1);
|
padding: var(--sp-2) var(--sp-3) var(--sp-1);
|
||||||
@@ -1016,6 +1074,11 @@
|
|||||||
exactly the drift --control-h exists to prevent.
|
exactly the drift --control-h exists to prevent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* The connection. Wide enough to read a machine's name without truncating it
|
||||||
|
to three characters, which is what it did while `.select--sm` did not exist.
|
||||||
|
A floor rather than a fixed width, so a long name still grows. */
|
||||||
|
.composer__connection { min-width: 10rem; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Chat or Agent: one control with two halves, not two buttons that happen to be
|
Chat or Agent: one control with two halves, not two buttons that happen to be
|
||||||
adjacent. Radios underneath, because the choice is permanent and mutually
|
adjacent. Radios underneath, because the choice is permanent and mutually
|
||||||
|
|||||||
@@ -641,6 +641,7 @@
|
|||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.value = suggestion.dataset.suggestion;
|
input.value = suggestion.dataset.suggestion;
|
||||||
autosize(input);
|
autosize(input);
|
||||||
|
if (window.lembas.paintComposer) window.lembas.paintComposer();
|
||||||
var form = input.closest("form");
|
var form = input.closest("form");
|
||||||
/* requestSubmit, not submit(): it fires the submit event, which is what
|
/* requestSubmit, not submit(): it fires the submit event, which is what
|
||||||
htmx is listening for. Same call the Enter key makes. */
|
htmx is listening for. Same call the Enter key makes. */
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
var existing = input.value.trim();
|
var existing = input.value.trim();
|
||||||
input.value = existing ? existing + " " + text : text;
|
input.value = existing ? existing + " " + text : text;
|
||||||
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||||
|
if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
|
||||||
input.focus();
|
input.focus();
|
||||||
input.selectionStart = input.selectionEnd = input.value.length;
|
input.selectionStart = input.selectionEnd = input.value.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,25 +69,14 @@
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
summary: "Summarise the earlier turns so they stop costing context",
|
summary: "Summarise the earlier turns so they stop costing context",
|
||||||
when: function () { return !!chat() && !!el("#thread .msg"); },
|
when: function () { return !!chat() && !!el("#thread .msg"); },
|
||||||
run: function () {
|
run: compact
|
||||||
window.lembas.confirm(
|
},
|
||||||
"Summarise everything before the last reply? The messages stay in the " +
|
{
|
||||||
"transcript; they just stop being sent to the model.",
|
name: "effort",
|
||||||
{ title: "Compact this chat", label: "Compact" }
|
summary: "How hard a reasoning model should think",
|
||||||
).then(function (yes) {
|
argument: "low | medium | high",
|
||||||
if (!yes) return;
|
when: function () { return !!chat() && !!el("[data-effort]"); },
|
||||||
post("/api/chats/" + chat() + "/compact")
|
run: function (rest) { setEffort(rest); }
|
||||||
.then(function (r) { return r.ok ? r.text() : Promise.reject(r); })
|
|
||||||
.then(function (html) {
|
|
||||||
el("#thread").innerHTML = html;
|
|
||||||
if (window.htmx) window.htmx.process(el("#thread"));
|
|
||||||
})
|
|
||||||
.catch(function (r) {
|
|
||||||
if (r && r.json) r.json().then(function (body) { note(body.detail, "error"); });
|
|
||||||
else note("Could not compact this chat.", "error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@@ -174,6 +163,102 @@
|
|||||||
return function () { window.location = url; };
|
return function () { window.location = url; };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Reasoning effort ---------------------------------------------------
|
||||||
|
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. */
|
||||||
|
var EFFORTS = ["low", "medium", "high"];
|
||||||
|
|
||||||
|
function setEffort(rest) {
|
||||||
|
var select = el("[data-effort]");
|
||||||
|
if (!select) {
|
||||||
|
return note("This model is not marked as a reasoning model.", "error");
|
||||||
|
}
|
||||||
|
var wanted = (rest || "").trim().toLowerCase();
|
||||||
|
if (!wanted) {
|
||||||
|
return note(
|
||||||
|
select.value
|
||||||
|
? "Effort is " + select.value + ". /effort low, medium, high, or default."
|
||||||
|
: "Effort is whatever the model does by default. Try low, medium or high."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wanted === "default" || wanted === "none") wanted = "";
|
||||||
|
else if (EFFORTS.indexOf(wanted) === -1) {
|
||||||
|
return note("“" + wanted + "” is not an effort. Try low, medium or high.", "error");
|
||||||
|
}
|
||||||
|
select.value = wanted;
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
note(
|
||||||
|
wanted
|
||||||
|
? "Effort set to " + wanted + "."
|
||||||
|
: "Effort cleared; the model decides."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Compacting --------------------------------------------------------
|
||||||
|
One implementation, reached from the command and from the overflow menu
|
||||||
|
alike. The menu used to do its own hx-post, which meant two code paths and
|
||||||
|
a spinner on neither -- so after confirming, the menu closed and nothing
|
||||||
|
visible happened for however long the summarising model took.
|
||||||
|
|
||||||
|
The `Generation.status` channel that says "Summarising earlier messages…"
|
||||||
|
during *automatic* compaction cannot be borrowed: it lives inside the
|
||||||
|
streaming bubble, and this endpoint refuses to run while any message is
|
||||||
|
unfinished, so there is no bubble to put it in. */
|
||||||
|
var compacting = false;
|
||||||
|
|
||||||
|
function compact() {
|
||||||
|
if (compacting) return note("Already summarising.");
|
||||||
|
var thread = el("#thread");
|
||||||
|
if (!thread) return;
|
||||||
|
|
||||||
|
window.lembas.confirm(
|
||||||
|
"Summarise everything before the last reply? The messages stay in the " +
|
||||||
|
"transcript; they just stop being sent to the model.",
|
||||||
|
{ title: "Compact this chat", label: "Compact" }
|
||||||
|
).then(function (yes) {
|
||||||
|
if (!yes) return;
|
||||||
|
|
||||||
|
compacting = true;
|
||||||
|
var working = document.createElement("div");
|
||||||
|
working.className = "thread__working";
|
||||||
|
// The same words the automatic path uses, so the two do not look like
|
||||||
|
// different features.
|
||||||
|
working.innerHTML =
|
||||||
|
'<span class="dots"><i></i><i></i><i></i></span>' +
|
||||||
|
"<span>Summarising the earlier messages…</span>";
|
||||||
|
thread.appendChild(working);
|
||||||
|
if (window.lembas.scrollThread) window.lembas.scrollThread(true);
|
||||||
|
|
||||||
|
function done() {
|
||||||
|
compacting = false;
|
||||||
|
working.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/api/chats/" + chat() + "/compact")
|
||||||
|
.then(function (r) { return r.ok ? r.text() : Promise.reject(r); })
|
||||||
|
.then(function (html) {
|
||||||
|
compacting = false;
|
||||||
|
// The response replaces the thread wholesale, placeholder included.
|
||||||
|
thread.innerHTML = html;
|
||||||
|
if (window.htmx) window.htmx.process(thread);
|
||||||
|
if (window.lembas.scrollThread) window.lembas.scrollThread(true);
|
||||||
|
})
|
||||||
|
.catch(function (r) {
|
||||||
|
done();
|
||||||
|
/* The endpoint's four 409s are written for a person to read -- "Wait
|
||||||
|
for the current reply to finish, then compact." -- and until now
|
||||||
|
reached nobody at all. */
|
||||||
|
if (r && r.json) {
|
||||||
|
r.json()
|
||||||
|
.then(function (body) { note(body.detail || "Could not compact.", "error"); })
|
||||||
|
.catch(function () { note("Could not compact this chat.", "error"); });
|
||||||
|
} else {
|
||||||
|
note("Could not compact this chat.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(selector, group) {
|
function toggle(selector, group) {
|
||||||
var panel = el(selector);
|
var panel = el(selector);
|
||||||
if (panel && window.lembas.setPanel) {
|
if (panel && window.lembas.setPanel) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
var menu = null;
|
var menu = null;
|
||||||
var list = null;
|
var list = null;
|
||||||
|
var footer = null;
|
||||||
var open = false;
|
var open = false;
|
||||||
var kind = "";
|
var kind = "";
|
||||||
var pending = null;
|
var pending = null;
|
||||||
@@ -105,6 +106,11 @@
|
|||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
list = document.createElement("div");
|
list = document.createElement("div");
|
||||||
menu.appendChild(list);
|
menu.appendChild(list);
|
||||||
|
/* What the keys do, in the one place somebody is looking when they need to
|
||||||
|
know. Cheaper than a help sheet nobody opens. */
|
||||||
|
footer = document.createElement("div");
|
||||||
|
footer.className = "composer-menu__keys";
|
||||||
|
menu.appendChild(footer);
|
||||||
host.insertBefore(menu, host.firstChild);
|
host.insertBefore(menu, host.firstChild);
|
||||||
|
|
||||||
menu.addEventListener("mousedown", function (event) {
|
menu.addEventListener("mousedown", function (event) {
|
||||||
@@ -121,6 +127,15 @@
|
|||||||
function show() {
|
function show() {
|
||||||
build();
|
build();
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
if (footer) {
|
||||||
|
// textContent: nothing here is markup, and one day somebody will want to
|
||||||
|
// put a filename in it.
|
||||||
|
footer.textContent = options().length
|
||||||
|
? (kind === "/"
|
||||||
|
? "↑↓ choose · Tab complete · Enter run · Esc dismiss"
|
||||||
|
: "↑↓ choose · Enter attach · Esc dismiss")
|
||||||
|
: "Esc dismiss";
|
||||||
|
}
|
||||||
menu.hidden = false;
|
menu.hidden = false;
|
||||||
open = true;
|
open = true;
|
||||||
}
|
}
|
||||||
@@ -161,7 +176,9 @@
|
|||||||
fetch(url, { credentials: "same-origin" })
|
fetch(url, { credentials: "same-origin" })
|
||||||
.then(function (response) { return response.text(); })
|
.then(function (response) { return response.text(); })
|
||||||
.then(function (html) {
|
.then(function (html) {
|
||||||
if (kind !== "@") return;
|
/* The reply is a round trip late: the caret may have moved off the
|
||||||
|
mention, or onto a slash, by the time it lands. */
|
||||||
|
if (kind !== "@" || !list) return;
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
show();
|
show();
|
||||||
highlight(0);
|
highlight(0);
|
||||||
@@ -172,6 +189,14 @@
|
|||||||
function refresh() {
|
function refresh() {
|
||||||
var input = composer();
|
var input = composer();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
|
/* Built here, not lazily inside `show()`. It used to be, and everything
|
||||||
|
below writes to `list` *before* calling `show()` -- so the first slash or
|
||||||
|
at-sign ever typed threw on a null `list` and took the whole handler with
|
||||||
|
it. The menu never appeared, in any browser, for the entire life of the
|
||||||
|
feature. Anything that touches `list` builds first. */
|
||||||
|
build();
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
var token = tokenAt(input);
|
var token = tokenAt(input);
|
||||||
if (!token) return hide();
|
if (!token) return hide();
|
||||||
|
|
||||||
@@ -203,9 +228,30 @@
|
|||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function choose(option) {
|
/*
|
||||||
|
`complete` is Tab, `!complete` is Enter, and the difference matters for a
|
||||||
|
command that takes an argument: Tab writes `/mode ` and leaves the caret
|
||||||
|
after it so the value can be typed, Enter runs what is there. Picking with
|
||||||
|
the mouse runs, because there is nowhere else for a click to mean.
|
||||||
|
*/
|
||||||
|
function choose(option, complete) {
|
||||||
if (option.dataset.command) {
|
if (option.dataset.command) {
|
||||||
|
if (complete) {
|
||||||
|
replaceToken(option.dataset.command);
|
||||||
hide();
|
hide();
|
||||||
|
/* Straight back, because `/mode ` is now a token with a space in it and
|
||||||
|
the menu would otherwise stay open over the argument being typed. */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var input = composer();
|
||||||
|
hide();
|
||||||
|
/* Cleared before running. A command is the whole message, never part of
|
||||||
|
one, so leaving `/help` sitting in the box after running it means the
|
||||||
|
next Enter runs it again. */
|
||||||
|
if (input) {
|
||||||
|
input.value = "";
|
||||||
|
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||||
|
}
|
||||||
runCommand(option.dataset.command, "");
|
runCommand(option.dataset.command, "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -245,6 +291,59 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Marking the tokens as they are typed --------------------------------
|
||||||
|
Backgrounds only. The mirror's text is transparent and exists purely to
|
||||||
|
put a rectangle in the right place; the visible glyphs are still the
|
||||||
|
textarea's own. Everything here is set as textContent or built with
|
||||||
|
createElement -- what is in the box is the one string a person controls
|
||||||
|
exactly, and it must never become markup. */
|
||||||
|
function mirrorEl() {
|
||||||
|
return document.querySelector("[data-composer-mirror]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same rule the server applies to a sent message: `@` at the start or
|
||||||
|
after whitespace, so an email address is not a file reference. */
|
||||||
|
var MENTION = /(^|\s)(@[^\s@]+)/g;
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
var mirror = mirrorEl();
|
||||||
|
var input = composer();
|
||||||
|
if (!mirror || !input) return;
|
||||||
|
|
||||||
|
var value = input.value;
|
||||||
|
mirror.replaceChildren();
|
||||||
|
|
||||||
|
/* A command is marked only when it resolves. `/thoughts on this` is not a
|
||||||
|
command and must not look like one before it is sent -- that ambiguity
|
||||||
|
is the whole reason `//` exists. */
|
||||||
|
var rest = value;
|
||||||
|
var command = commandIn(value);
|
||||||
|
if (command) {
|
||||||
|
var head = "/" + command.name;
|
||||||
|
mirror.appendChild(token(head, "tok-command"));
|
||||||
|
rest = value.slice(head.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = 0;
|
||||||
|
var match;
|
||||||
|
MENTION.lastIndex = 0;
|
||||||
|
while ((match = MENTION.exec(rest)) !== null) {
|
||||||
|
mirror.appendChild(document.createTextNode(rest.slice(last, match.index) + match[1]));
|
||||||
|
mirror.appendChild(token(match[2], "tok-mention"));
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
// A trailing newline collapses in a div; the space keeps the last line.
|
||||||
|
mirror.appendChild(document.createTextNode(rest.slice(last) + "\n"));
|
||||||
|
mirror.scrollTop = input.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(text, cls) {
|
||||||
|
var span = document.createElement("span");
|
||||||
|
span.className = cls;
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Keys --------------------------------------------------------------- */
|
/* --- Keys --------------------------------------------------------------- */
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"keydown",
|
"keydown",
|
||||||
@@ -274,7 +373,7 @@
|
|||||||
item chosen. */
|
item chosen. */
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
choose(all[active]);
|
choose(all[active], event.key === "Tab");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,9 +397,31 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
document.addEventListener("input", function (event) {
|
document.addEventListener("input", function (event) {
|
||||||
if (event.target.closest("[data-composer-input]")) refresh();
|
if (!event.target.closest("[data-composer-input]")) return;
|
||||||
|
refresh();
|
||||||
|
paint();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* The textarea scrolls once it hits data-max-height, and the mirror has to
|
||||||
|
go with it or the rectangles drift off the words. */
|
||||||
|
document.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
function (event) {
|
||||||
|
var input = event.target;
|
||||||
|
if (!input.closest || !input.closest("[data-composer-input]")) return;
|
||||||
|
var mirror = mirrorEl();
|
||||||
|
if (mirror) mirror.scrollTop = input.scrollTop;
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Sending, resetting, dictating and pasting all change the value without an
|
||||||
|
`input` event that reaches here, so the mirror is repainted after any htmx
|
||||||
|
swap and once at load. */
|
||||||
|
document.addEventListener("DOMContentLoaded", paint);
|
||||||
|
document.addEventListener("htmx:afterSwap", paint);
|
||||||
|
document.addEventListener("htmx:afterSettle", paint);
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
document.addEventListener("click", function (event) {
|
||||||
if (!event.target.closest(".composer-menu") &&
|
if (!event.target.closest(".composer-menu") &&
|
||||||
!event.target.closest("[data-composer-input]")) hide();
|
!event.target.closest("[data-composer-input]")) hide();
|
||||||
@@ -323,4 +444,8 @@
|
|||||||
|
|
||||||
window.lembas = window.lembas || {};
|
window.lembas = window.lembas || {};
|
||||||
window.lembas.closeComposerMenu = hide;
|
window.lembas.closeComposerMenu = hide;
|
||||||
|
/* Anything that writes into the composer from outside -- dictation, the
|
||||||
|
terminal's Send, a slash command that clears it -- calls this so the
|
||||||
|
rectangles keep up. */
|
||||||
|
window.lembas.paintComposer = paint;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -373,6 +373,7 @@
|
|||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + text : text;
|
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + text : text;
|
||||||
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||||
|
if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
|
||||||
/* Auto-send never steals focus: it fires while somebody is typing in the
|
/* Auto-send never steals focus: it fires while somebody is typing in the
|
||||||
terminal, and yanking the caret out of a shell mid-command is the sort
|
terminal, and yanking the caret out of a shell mid-command is the sort
|
||||||
of thing that gets a feature switched off for good. */
|
of thing that gets a feature switched off for good. */
|
||||||
|
|||||||
@@ -104,6 +104,32 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||||||
|
<select class="select" id="default-effort" name="default_effort">
|
||||||
|
<option value="">Whatever the model does</option>
|
||||||
|
{% for value in efforts %}
|
||||||
|
<option value="{{ value }}"
|
||||||
|
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||||||
|
{{ value }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="field__hint">
|
||||||
|
Where new chats on this model start. Anyone can change it per chat with
|
||||||
|
<span class="mono">/effort</span>, and the control only appears on a
|
||||||
|
model marked <strong>Reasoning</strong> above.
|
||||||
|
<br>
|
||||||
|
Sent two ways at once, because there is no one field that works: OpenAI
|
||||||
|
and vLLM read <span class="mono">reasoning_effort</span>, while
|
||||||
|
llama.cpp drops it silently and reads only
|
||||||
|
<span class="mono">chat_template_kwargs</span> — which is the route by
|
||||||
|
which it reaches gpt-oss. Both go out, and only on a chat that has an
|
||||||
|
effort set, so an endpoint strict about unknown parameters is untouched
|
||||||
|
until somebody chooses one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="description">Description</label>
|
<label class="field__label" for="description">Description</label>
|
||||||
<textarea class="textarea" id="description" name="description" rows="2"
|
<textarea class="textarea" id="description" name="description" rows="2"
|
||||||
|
|||||||
@@ -67,10 +67,26 @@
|
|||||||
<input type="hidden" name="temporary" value="true">
|
<input type="hidden" name="temporary" value="true">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
The text, with a mirror behind it.
|
||||||
|
|
||||||
|
A textarea cannot style its own contents, so the mirror holds the same
|
||||||
|
text with every character transparent and contributes nothing but a
|
||||||
|
rounded rectangle behind each recognised token. The real text stays in
|
||||||
|
the textarea, where it is native and selectable -- the other way round,
|
||||||
|
showing the mirror's text and hiding the textarea's, means any style
|
||||||
|
drift renders as doubled or blurred glyphs instead of a rectangle a
|
||||||
|
pixel out of place.
|
||||||
|
|
||||||
|
composer.js fills it. Without JavaScript there is simply no mirror.
|
||||||
|
#}
|
||||||
|
<div class="composer__field">
|
||||||
|
<div class="composer__mirror" data-composer-mirror aria-hidden="true"></div>
|
||||||
<textarea class="composer__input" name="content" rows="1"
|
<textarea class="composer__input" name="content" rows="1"
|
||||||
data-autosize data-max-height="320" data-composer-input
|
data-autosize data-max-height="320" data-composer-input
|
||||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="composer__toolbar">
|
<div class="composer__toolbar">
|
||||||
<div class="composer__tools">
|
<div class="composer__tools">
|
||||||
@@ -160,7 +176,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="composer__agent" data-agent-extra hidden>
|
<span class="composer__agent" data-agent-extra hidden>
|
||||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
<select class="select select--sm composer__connection"
|
||||||
|
name="ssh_profile_id" aria-label="Connection">
|
||||||
{% for profile in agent_profiles %}
|
{% for profile in agent_profiles %}
|
||||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||||
{{ 'disabled' if not profile.verified }}>
|
{{ 'disabled' if not profile.verified }}>
|
||||||
@@ -192,15 +209,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif chat and chat.kind == "agent" %}
|
{% elif chat and chat.kind == "agent" %}
|
||||||
<div class="composer__context">
|
{# Only the mode. The connection and the directory moved to the topbar:
|
||||||
<span class="composer__where" title="{{ chat.project_dir }}">
|
they cannot change -- update_chat refuses both with a 409 -- so they
|
||||||
{{ icon("bolt", "icon--sm") }}
|
are facts about the chat rather than controls on the message, and
|
||||||
<span>{{ agent_profile.name if agent_profile else "connection missing" }}</span>
|
they were taking a slot in a row that has work to do.
|
||||||
<span class="composer__where-dir">{{ chat.project_dir }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{# Its own form: nesting one inside the composer's form is invalid
|
Its own form: nesting one inside the composer's form is invalid HTML
|
||||||
HTML, and the browser drops the inner one. #}
|
and the browser drops the inner one. #}
|
||||||
|
<div class="composer__context">
|
||||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
||||||
form="agent-mode-form">
|
form="agent-mode-form">
|
||||||
{% for value, label, hint in agent_modes %}
|
{% for value, label, hint in agent_modes %}
|
||||||
@@ -211,6 +227,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
How hard a reasoning model should think. Outside the agent branch
|
||||||
|
above, because it applies to any chat.
|
||||||
|
|
||||||
|
Only on a model an administrator has marked as **reasoning**: that
|
||||||
|
flag has existed since the beginning with no reader at all, and
|
||||||
|
offering the control everywhere would be offering a setting that does
|
||||||
|
nothing almost everywhere. Its own form, for the reason the mode has
|
||||||
|
one -- a form cannot nest inside another.
|
||||||
|
#}
|
||||||
|
{% if chat and current_model and current_model.capabilities_json.get("reasoning") %}
|
||||||
|
<select class="select select--sm" name="reasoning_effort" data-effort
|
||||||
|
aria-label="Reasoning effort" title="How hard this model should think"
|
||||||
|
form="chat-params-form">
|
||||||
|
<option value="">Effort: default</option>
|
||||||
|
{% for value in efforts %}
|
||||||
|
<option value="{{ value }}"
|
||||||
|
{{ 'selected' if chat.params_json.get('reasoning_effort') == value }}>
|
||||||
|
Effort: {{ value }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="composer__actions">
|
<div class="composer__actions">
|
||||||
{% if can_dictate %}
|
{% if can_dictate %}
|
||||||
{# Recording is started and stopped by the same button; audio.js swaps
|
{# Recording is started and stopped by the same button; audio.js swaps
|
||||||
@@ -254,6 +294,10 @@
|
|||||||
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
hx-trigger="change"></form>
|
hx-trigger="change"></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if chat %}
|
||||||
|
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
|
hx-trigger="change"></form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="composer__hint">
|
<p class="composer__hint">
|
||||||
Enter to send, Shift+Enter for a new line.
|
Enter to send, Shift+Enter for a new line.
|
||||||
|
|||||||
@@ -187,7 +187,10 @@
|
|||||||
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
|
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif message.content %}
|
{% elif message.content %}
|
||||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
{# `tokens` escapes and then marks up: @mentions read as references
|
||||||
|
rather than as punctuation. It must stay `pre-wrap` -- the newlines
|
||||||
|
are still carried by CSS, not by markup. #}
|
||||||
|
<div class="msg__body msg__body--plain">{{ message.content|tokens|safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# An attachment-only turn has no text; rendering the bubble anyway would
|
{# An attachment-only turn has no text; rendering the bubble anyway would
|
||||||
leave an empty box under the file. #}
|
leave an empty box under the file. #}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{# Where this chat runs. Beside the title because it describes the chat
|
||||||
|
and cannot be changed -- update_chat refuses the connection and the
|
||||||
|
directory with a 409 -- so it is of a kind with the Temporary badge
|
||||||
|
rather than with the controls on the right. The mode is the one thing
|
||||||
|
here that moves, and it stays down by the message box. #}
|
||||||
|
{% if chat and chat.kind == "agent" %}
|
||||||
|
<span class="topbar__where" title="{{ chat.project_dir }}">
|
||||||
|
{{ icon("bolt", "icon--sm") }}
|
||||||
|
<span class="topbar__where-name">
|
||||||
|
{{ agent_profile.name if agent_profile else "connection missing" }}
|
||||||
|
</span>
|
||||||
|
<span class="topbar__where-dir">{{ chat.project_dir }}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="topbar__actions">
|
<div class="topbar__actions">
|
||||||
{#
|
{#
|
||||||
A link, not a script: the flag lives in the URL, so it survives a
|
A link, not a script: the flag lives in the URL, so it survives a
|
||||||
@@ -118,12 +133,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
|
{# Runs the same code as /compact rather than posting itself. Two
|
||||||
|
implementations meant a spinner on neither and the endpoint's
|
||||||
|
error messages reaching nobody. #}
|
||||||
<button class="picker__option" type="button" role="menuitem"
|
<button class="picker__option" type="button" role="menuitem"
|
||||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
onclick="window.lembasCommands && window.lembasCommands.run('compact')">
|
||||||
hx-target="#thread" hx-swap="innerHTML"
|
|
||||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
|
||||||
data-confirm-title="Compact this chat"
|
|
||||||
data-confirm-label="Compact">
|
|
||||||
{{ icon("archive", "icon--sm") }}
|
{{ icon("archive", "icon--sm") }}
|
||||||
<span class="picker__option-body">
|
<span class="picker__option-body">
|
||||||
<span class="picker__option-name">Compact</span>
|
<span class="picker__option-name">Compact</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from lembas import __version__
|
|||||||
from lembas.config import settings
|
from lembas.config import settings
|
||||||
from lembas.db.models import User
|
from lembas.db.models import User
|
||||||
from lembas.services import metrics as metrics_service
|
from lembas.services import metrics as metrics_service
|
||||||
|
from lembas.services.markdown import highlight_tokens
|
||||||
from lembas.services.reasoning import format_duration
|
from lembas.services.reasoning import format_duration
|
||||||
|
|
||||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||||
@@ -44,6 +45,12 @@ def stable_hue(value: str) -> int:
|
|||||||
|
|
||||||
templates.env.filters["stable_hue"] = stable_hue
|
templates.env.filters["stable_hue"] = stable_hue
|
||||||
|
|
||||||
|
# A user's own message: escaped here and marked up, so `@mentions` read as
|
||||||
|
# references rather than as punctuation. A filter rather than a context value
|
||||||
|
# because the message templates are included from four different handlers and
|
||||||
|
# every one of them would otherwise have to remember to pass it.
|
||||||
|
templates.env.filters["tokens"] = highlight_tokens
|
||||||
|
|
||||||
|
|
||||||
def resolve_theme(user: User | None) -> str:
|
def resolve_theme(user: User | None) -> str:
|
||||||
"""Theme to render with on the server.
|
"""Theme to render with on the server.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Reasoning effort: what goes out, and what does not.
|
||||||
|
|
||||||
|
The second half matters as much as the first. There is no field that works
|
||||||
|
everywhere -- OpenAI and vLLM read `reasoning_effort`, llama.cpp drops it
|
||||||
|
silently and reads only `chat_template_kwargs` -- so both are sent. That is only
|
||||||
|
safe because neither is sent at all until somebody chooses an effort, which is
|
||||||
|
what keeps an endpoint strict about unknown parameters working exactly as it
|
||||||
|
did.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import Chat, Connection, Model, User
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
|
||||||
|
def _model(db, **capabilities) -> Model:
|
||||||
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
model = Model(
|
||||||
|
connection_id=connection.id,
|
||||||
|
model_id="m",
|
||||||
|
capabilities_json={"reasoning": True, **capabilities},
|
||||||
|
)
|
||||||
|
db.add(model)
|
||||||
|
db.commit()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _chat(db, effort: str | None = None) -> Chat:
|
||||||
|
model = _model(db)
|
||||||
|
user = db.scalars(select(User)).first()
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user.id,
|
||||||
|
model_id=model.model_id,
|
||||||
|
connection_id=model.connection_id,
|
||||||
|
params_json={"reasoning_effort": effort} if effort is not None else {},
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
# --- What reaches the endpoint -----------------------------------------------
|
||||||
|
def test_an_effort_goes_out_in_both_forms(client: TestClient, db, registered):
|
||||||
|
"""One value, two fields. Neither endpoint family reads the other's."""
|
||||||
|
chat = _chat(db, "high")
|
||||||
|
|
||||||
|
body = chat_service.build_request(db, chat)
|
||||||
|
|
||||||
|
assert body["reasoning_effort"] == "high"
|
||||||
|
assert body["chat_template_kwargs"] == {"reasoning_effort": "high"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_effort_means_neither_field(client: TestClient, db, registered):
|
||||||
|
"""The whole safety of sending both. A chat nobody has set an effort on is
|
||||||
|
byte-for-byte the request it was before this existed, so a provider that
|
||||||
|
refuses unknown parameters is untouched until somebody opts in."""
|
||||||
|
chat = _chat(db)
|
||||||
|
|
||||||
|
body = chat_service.build_request(db, chat)
|
||||||
|
|
||||||
|
assert "reasoning_effort" not in body
|
||||||
|
assert "chat_template_kwargs" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cleared_effort_means_neither_field(client: TestClient, db, registered):
|
||||||
|
"""Cleared is stored as None, like every other parameter here."""
|
||||||
|
chat = _chat(db, None)
|
||||||
|
|
||||||
|
body = chat_service.build_request(db, chat)
|
||||||
|
|
||||||
|
assert "reasoning_effort" not in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("junk", ["sudo", "HIGH ", "maximum", "1"])
|
||||||
|
def test_a_value_that_is_not_an_effort_is_not_sent(client: TestClient, db, registered, junk):
|
||||||
|
"""Never trusted from the row: it could predate a change to the list."""
|
||||||
|
chat = _chat(db, junk)
|
||||||
|
|
||||||
|
assert "reasoning_effort" not in chat_service.build_request(db, chat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_chat_template_kwargs_are_kept(client: TestClient, db, registered):
|
||||||
|
"""Merged rather than replaced, so a future caller setting something else
|
||||||
|
there does not lose it."""
|
||||||
|
body: dict = {"chat_template_kwargs": {"enable_thinking": True}}
|
||||||
|
|
||||||
|
chat_service.apply_effort(body, "low")
|
||||||
|
|
||||||
|
assert body["chat_template_kwargs"] == {"enable_thinking": True, "reasoning_effort": "low"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Setting it ---------------------------------------------------------------
|
||||||
|
def test_patching_the_effort_stores_it(client: TestClient, db, registered):
|
||||||
|
chat = _chat(db)
|
||||||
|
|
||||||
|
assert client.patch(
|
||||||
|
f"/api/chats/{chat.id}", data={"reasoning_effort": "medium"}
|
||||||
|
).status_code == 204
|
||||||
|
|
||||||
|
db.refresh(chat)
|
||||||
|
assert chat.params_json["reasoning_effort"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_effort_clears_it(client: TestClient, db, registered):
|
||||||
|
chat = _chat(db, "high")
|
||||||
|
|
||||||
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": ""})
|
||||||
|
|
||||||
|
db.refresh(chat)
|
||||||
|
assert chat.params_json["reasoning_effort"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_effort_leaves_the_old_one(client: TestClient, db, registered):
|
||||||
|
"""Ignored, not refused: a typo should not cost the setting you had."""
|
||||||
|
chat = _chat(db, "low")
|
||||||
|
|
||||||
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "extreme"})
|
||||||
|
|
||||||
|
db.refresh(chat)
|
||||||
|
assert chat.params_json["reasoning_effort"] == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_control_only_appears_on_a_reasoning_model(client: TestClient, db, registered):
|
||||||
|
"""The flag has existed with no reader since the beginning; this is its
|
||||||
|
first job. Offering the control everywhere would offer a setting that does
|
||||||
|
nothing almost everywhere."""
|
||||||
|
chat = _chat(db)
|
||||||
|
assert "data-effort" in client.get(f"/chat/{chat.id}").text
|
||||||
|
|
||||||
|
model = db.scalars(select(Model)).one()
|
||||||
|
model.capabilities_json = {"reasoning": False}
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert "data-effort" not in client.get(f"/chat/{chat.id}").text
|
||||||
|
|
||||||
|
|
||||||
|
# --- The per-model default ----------------------------------------------------
|
||||||
|
def test_a_new_chat_starts_from_the_models_defaults(client: TestClient, db, registered):
|
||||||
|
"""`Model.params_json` has claimed to do this since it was added and did it
|
||||||
|
nowhere. It is empty on every existing row, so honouring it changes nothing
|
||||||
|
until an administrator sets something."""
|
||||||
|
model = _model(db)
|
||||||
|
model.params_json = {"reasoning_effort": "high"}
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
||||||
|
|
||||||
|
chat = db.scalars(select(Chat)).one()
|
||||||
|
assert chat.params_json["reasoning_effort"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_model_with_no_defaults_starts_a_plain_chat(client: TestClient, db, registered):
|
||||||
|
_model(db)
|
||||||
|
|
||||||
|
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
||||||
|
|
||||||
|
assert db.scalars(select(Chat)).one().params_json == {}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Marking `@mentions` in a message somebody wrote.
|
||||||
|
|
||||||
|
This is the one render path where a person controls the bytes exactly, and
|
||||||
|
until now there was no render path at all -- the template printed the column
|
||||||
|
and let `white-space: pre-wrap` carry the newlines. So the first half of every
|
||||||
|
test here is that escaping still happens, and happens *before* anything is
|
||||||
|
injected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from lembas.services.markdown import highlight_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mention_is_marked():
|
||||||
|
assert highlight_tokens("look at @src/main.py") == (
|
||||||
|
'look at <span class="tok-mention">@src/main.py</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mention_at_the_start_is_marked():
|
||||||
|
assert highlight_tokens("@README.md is wrong") == (
|
||||||
|
'<span class="tok-mention">@README.md</span> is wrong'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_email_address_is_not_a_mention():
|
||||||
|
"""The whole reason the pattern is anchored on whitespace. Without it every
|
||||||
|
address in a message becomes a highlighted file reference."""
|
||||||
|
assert "tok-mention" not in highlight_tokens("write to frodo@shire.test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bare_at_is_left_alone():
|
||||||
|
assert "tok-mention" not in highlight_tokens("dinner @ 8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_several_mentions_are_all_marked():
|
||||||
|
marked = highlight_tokens("@a.py and @b.py")
|
||||||
|
|
||||||
|
assert marked.count("tok-mention") == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"hostile",
|
||||||
|
[
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"@<script>alert(1)</script>",
|
||||||
|
"</span><img src=x onerror=alert(1)>",
|
||||||
|
"@a\"><script>alert(1)</script>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_markup_is_escaped_before_anything_is_injected(hostile):
|
||||||
|
"""The order is the security property. Injecting first and escaping after
|
||||||
|
would escape our own span; escaping first means the span is the only markup
|
||||||
|
that can exist in the output."""
|
||||||
|
out = highlight_tokens(hostile)
|
||||||
|
|
||||||
|
assert "<script" not in out
|
||||||
|
assert "<img" not in out
|
||||||
|
# The only tags in the result are ours.
|
||||||
|
assert out.replace('<span class="tok-mention">', "").replace("</span>", "").count("<") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ampersand_survives_as_an_entity():
|
||||||
|
assert highlight_tokens("a & b") == "a & b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_newlines_are_untouched():
|
||||||
|
"""They are carried by `white-space: pre-wrap`, not by markup. Turning them
|
||||||
|
into <br> here would double up with the CSS."""
|
||||||
|
assert highlight_tokens("one\ntwo") == "one\ntwo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_is_empty():
|
||||||
|
assert highlight_tokens("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Through the page ---------------------------------------------------------
|
||||||
|
def _model(db):
|
||||||
|
"""A model, without which index.html renders the "no models available"
|
||||||
|
screen *instead of* the thread -- so an assertion about message markup
|
||||||
|
would be made against a page that has no messages on it."""
|
||||||
|
from lembas.db.models import Connection, Model
|
||||||
|
|
||||||
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sent_mention_is_marked_in_the_transcript(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
from lembas.db.models import ROLE_USER, Chat, Message
|
||||||
|
|
||||||
|
_model(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
db.add(Message(chat_id=chat.id, role=ROLE_USER, content="check @src/main.py please"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get(f"/chat/{chat_id}").text
|
||||||
|
|
||||||
|
assert '<span class="tok-mention">@src/main.py</span>' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_copying_a_message_still_yields_what_was_typed(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""The hidden copy source stays raw. Copy must give back the text, not the
|
||||||
|
markup wrapped round it."""
|
||||||
|
from lembas.db.models import ROLE_USER, Chat, Message
|
||||||
|
|
||||||
|
_model(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
message = Message(chat_id=chat.id, role=ROLE_USER, content="check @src/main.py")
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get(f"/chat/{chat_id}").text
|
||||||
|
source = body[body.index(f'id="msg-body-{message.id}"') :][:200]
|
||||||
|
|
||||||
|
assert "tok-mention" not in source
|
||||||
|
assert "@src/main.py" in source
|
||||||
Reference in New Issue
Block a user