diff --git a/README.md b/README.md index 917bdec..5de7073 100644 --- a/README.md +++ b/README.md @@ -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 a file in the project directory. The reference stays in the sentence you are writing and the contents come with it -- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/model`, - `/title`, `/terminal`, `/theme`. `/help` lists them and the keyboard - shortcuts beside them. A message that merely starts with a slash is still - sent as written +- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/effort high`, + `/model`, `/title`, `/terminal`, `/theme`. The list appears as you type and + filters as you go; `/help` shows all of them with the keyboard shortcuts + 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 inside it - **Web search** — offered to the model as a tool it calls when a question needs diff --git a/pyproject.toml b/pyproject.toml index 6e02a98..25f7d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lembas" -version = "0.6.0" +version = "0.6.1" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" readme = "README.md" requires-python = ">=3.11" diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 017429c..d5e23b9 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py index 4b15a85..3ab158f 100644 --- a/src/lembas/api/admin_models.py +++ b/src/lembas/api/admin_models.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session as DBSession from lembas.api.deps import AdminUser, Db, RequiredUser 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.llm.openai_client import MAX_CONTEXT from lembas.web.templating import render @@ -167,6 +168,7 @@ async def model_detail( "groups": list(db.scalars(select(Group).order_by(Group.name))), "capabilities": PROTOCOL_CAPABILITIES, "tool_capabilities": TOOL_CAPABILITIES, + "efforts": chat_service.EFFORTS, # Rows predating the split have no tool_* keys at all. Showing them # 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 @@ -226,6 +228,7 @@ async def update_model( public: bool = Form(False), position: str = Form(""), context_length: str = Form(""), + default_effort: str = Form(""), group_ids: list[str] = Form(default=[]), capability: list[str] = Form(default=[]), ) -> Response: @@ -245,6 +248,17 @@ async def update_model( model.pinned = pinned 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 # list IS the complete new state -- rebuild rather than merge. model.capabilities_json = {name: (name in capability) for name in CAPABILITIES} diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 000dacf..29f38ba 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -22,6 +22,7 @@ from lembas.db.models import ( ROLE_USER, Chat, Message, + Model, User, ) from lembas.db.session import session_scope @@ -92,6 +93,21 @@ def _new_chat( if chosen is None: 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) chat = Chat( user_id=user.id, @@ -102,6 +118,7 @@ def _new_chat( kind=KIND_AGENT if profile is not None else KIND_CHAT, 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 "", + params_json=defaults, ) # 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 @@ -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()}), } + # 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() return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 247ac9e..7038006 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -58,6 +58,10 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: 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), **audio_service.template_flags(db, user), } diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index 4b8a5d8..e2f2bd6 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -305,9 +305,38 @@ def build_request( } if tools: body["tools"] = tools + + apply_effort(body, (chat.params_json or {}).get("reasoning_effort")) 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: """The model a new chat should start with, as (model_id, connection_id). diff --git a/src/lembas/services/markdown.py b/src/lembas/services/markdown.py index 2fb8546..4a561ba 100644 --- a/src/lembas/services/markdown.py +++ b/src/lembas/services/markdown.py @@ -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'@\1', escaped) + + def escape_text(text: str) -> str: """Escape a plain-text run for insertion as HTML element content. diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index af5d06c..fa9ceec 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -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; } +/* + 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 single control rather than two things that happen to be adjacent. */ .model-select { @@ -1057,7 +1094,29 @@ body.is-resizing .terminal__screen { pointer-events: none; } .dialog__results .picker__list { max-height: none; } .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); } +.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__option { diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 700e744..812dc36 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -243,6 +243,18 @@ 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 --------------------------------------------------------------- What a reply cost, under the bubble. Quiet by default: it is reference, not something to read every time. @@ -697,20 +709,76 @@ 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%; min-width: 0; - border: 0; - background: none; - resize: none; + margin: 0; padding: var(--sp-2) var(--sp-2) var(--sp-1); + border: 0; + font-family: var(--font-body); font-size: var(--text-base); line-height: var(--leading-normal); + letter-spacing: normal; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: normal; max-height: 20rem; +} + +.composer__input { + position: relative; + display: block; + background: none; + resize: none; color: var(--ink); + overflow-y: auto; } .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 than scrolls: on a narrow window the context controls drop to their own row 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-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 { margin: var(--sp-2) 0 0; font-size: var(--text-xs); @@ -807,6 +851,20 @@ 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 { margin: 0; padding: var(--sp-2) var(--sp-3) var(--sp-1); @@ -1016,6 +1074,11 @@ 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 adjacent. Radios underneath, because the choice is permanent and mutually diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 49b4c56..55da89b 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -641,6 +641,7 @@ if (!input) return; input.value = suggestion.dataset.suggestion; autosize(input); + if (window.lembas.paintComposer) window.lembas.paintComposer(); var form = input.closest("form"); /* requestSubmit, not submit(): it fires the submit event, which is what htmx is listening for. Same call the Enter key makes. */ diff --git a/src/lembas/web/static/js/audio.js b/src/lembas/web/static/js/audio.js index 036849f..d24adf5 100644 --- a/src/lembas/web/static/js/audio.js +++ b/src/lembas/web/static/js/audio.js @@ -48,6 +48,7 @@ var existing = input.value.trim(); input.value = existing ? existing + " " + text : text; if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); + if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer(); input.focus(); input.selectionStart = input.selectionEnd = input.value.length; } diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js index cbf9794..f8e44e1 100644 --- a/src/lembas/web/static/js/commands.js +++ b/src/lembas/web/static/js/commands.js @@ -69,25 +69,14 @@ name: "compact", summary: "Summarise the earlier turns so they stop costing context", when: function () { return !!chat() && !!el("#thread .msg"); }, - run: function () { - 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; - post("/api/chats/" + chat() + "/compact") - .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"); - }); - }); - } + run: compact + }, + { + name: "effort", + summary: "How hard a reasoning model should think", + argument: "low | medium | high", + when: function () { return !!chat() && !!el("[data-effort]"); }, + run: function (rest) { setEffort(rest); } }, { name: "mode", @@ -174,6 +163,102 @@ 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 = + '' + + "Summarising the earlier messages…"; + 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) { var panel = el(selector); if (panel && window.lembas.setPanel) { diff --git a/src/lembas/web/static/js/composer.js b/src/lembas/web/static/js/composer.js index 9875d37..6e84669 100644 --- a/src/lembas/web/static/js/composer.js +++ b/src/lembas/web/static/js/composer.js @@ -27,6 +27,7 @@ var menu = null; var list = null; + var footer = null; var open = false; var kind = ""; var pending = null; @@ -105,6 +106,11 @@ menu.hidden = true; list = document.createElement("div"); 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); menu.addEventListener("mousedown", function (event) { @@ -121,6 +127,15 @@ function show() { build(); 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; open = true; } @@ -161,7 +176,9 @@ fetch(url, { credentials: "same-origin" }) .then(function (response) { return response.text(); }) .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; show(); highlight(0); @@ -172,6 +189,14 @@ function refresh() { var input = composer(); 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); if (!token) return hide(); @@ -203,9 +228,30 @@ 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 (complete) { + replaceToken(option.dataset.command); + 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, ""); 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 --------------------------------------------------------------- */ document.addEventListener( "keydown", @@ -274,7 +373,7 @@ item chosen. */ event.preventDefault(); event.stopPropagation(); - choose(all[active]); + choose(all[active], event.key === "Tab"); return; } } @@ -298,9 +397,31 @@ ); 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) { if (!event.target.closest(".composer-menu") && !event.target.closest("[data-composer-input]")) hide(); @@ -323,4 +444,8 @@ window.lembas = window.lembas || {}; 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; })(); diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js index 74edc7a..d48ed7b 100644 --- a/src/lembas/web/static/js/terminal.js +++ b/src/lembas/web/static/js/terminal.js @@ -373,6 +373,7 @@ if (!input) return; 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.paintComposer) window.lembas.paintComposer(); /* 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 of thing that gets a feature switched off for good. */ diff --git a/src/lembas/web/templates/admin/model_detail.html b/src/lembas/web/templates/admin/model_detail.html index 2e56eb3..77f6e7d 100644 --- a/src/lembas/web/templates/admin/model_detail.html +++ b/src/lembas/web/templates/admin/model_detail.html @@ -104,6 +104,32 @@

+
+ + +

+ Where new chats on this model start. Anyone can change it per chat with + /effort, and the control only appears on a + model marked Reasoning above. +
+ Sent two ways at once, because there is no one field that works: OpenAI + and vLLM read reasoning_effort, while + llama.cpp drops it silently and reads only + chat_template_kwargs — 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. +

+
+
+ {# + 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. + #} +
+ + +
@@ -160,7 +176,8 @@
{% elif chat and chat.kind == "agent" %} -
- - {{ icon("bolt", "icon--sm") }} - {{ agent_profile.name if agent_profile else "connection missing" }} - {{ chat.project_dir }} - + {# Only the mode. The connection and the directory moved to the topbar: + they cannot change -- update_chat refuses both with a 409 -- so they + are facts about the chat rather than controls on the message, and + they were taking a slot in a row that has work to do. - {# Its own form: nesting one inside the composer's form is invalid - HTML, and the browser drops the inner one. #} + Its own form: nesting one inside the composer's form is invalid HTML + and the browser drops the inner one. #} +
+ + {% for value in efforts %} + + {% endfor %} + + {% endif %} +
{% if can_dictate %} {# Recording is started and stopped by the same button; audio.js swaps @@ -254,6 +294,10 @@
{% endif %} + {% if chat %} +
+ {% endif %}

Enter to send, Shift+Enter for a new line. diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html index 8cb5f5c..6f68c3c 100644 --- a/src/lembas/web/templates/chat/_message.html +++ b/src/lembas/web/templates/chat/_message.html @@ -187,7 +187,10 @@

{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.

{% endif %} {% elif message.content %} -
{{ message.content }}
+ {# `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. #} +
{{ message.content|tokens|safe }}
{% endif %} {# An attachment-only turn has no text; rendering the bubble anyway would leave an empty box under the file. #} diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 8ed458e..cd93e30 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -35,6 +35,21 @@ {% endif %} + {# 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" %} + + {{ icon("bolt", "icon--sm") }} + + {{ agent_profile.name if agent_profile else "connection missing" }} + + {{ chat.project_dir }} + + {% endif %} +
{# A link, not a script: the flag lives in the URL, so it survives a @@ -118,12 +133,11 @@ {% endif %} {% 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. #}