From e65ea90fe650cddfc0a84532c541c1bca1c53fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 29 Sep 2026 13:07:16 +0000 Subject: [PATCH] A temporary chat that keeps the model it was started on The new-chat screen keeps its preselections in the query string, and the three ways off it each rebuilt the URL with only their own key: the Temporary button went to /chat?temporary=1, the model picker to /chat?model=, and /temp to /chat?temporary=1. So each undid the other, and a temporary chat could only be started on the default model. The folder from "New chat here" and the agent kind were dropped the same way. chat_index now builds both URLs from one set of carried values and changes only its own key; /temp keeps location.search when run on /chat. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ src/lembas/__init__.py | 2 +- src/lembas/api/pages.py | 21 +++++++ src/lembas/web/static/js/commands.js | 11 +++- .../web/templates/chat/_model_picker.html | 6 +- src/lembas/web/templates/chat/index.html | 5 +- tests/test_commands_js.py | 9 +++ tests/test_temporary_chat.py | 55 +++++++++++++++++++ 8 files changed, 112 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f5245..d045423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ for 1.0.0 have something to be assembled from. ## Unreleased +## 1.9.1 + +- **A temporary chat can be started on any model.** On the new-chat screen, + turning on Temporary switched the model back to the default, and choosing a + model switched Temporary off, so a temporary chat could only ever be started + on the default model. The Temporary button, the model menu and `/temp` now + keep each other's choice, and also keep the folder a chat was started in + ("New chat here") and whether it is an agent chat. + ## 1.9.0 The model menu says which model is loaded, and a new chat now matches the diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 74b1c05..e555d9c 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__ = "1.9.0" +__version__ = "1.9.1" diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 100adde..568125a 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -2,6 +2,7 @@ from __future__ import annotations +from urllib.parse import urlencode from zoneinfo import available_timezones from fastapi import APIRouter, HTTPException, Request, Response, status @@ -727,6 +728,24 @@ async def chat_index( if preselected is None and context["models"]: preselected = context["models"][0] + # Every preselection lives in the URL, so every link that changes one of + # them has to carry the rest. The temporary toggle used to link to a bare + # `/chat?temporary=1` and the model picker to a bare `/chat?model=`, so + # each undid the other: temporary chats could only ever be started on the + # default model. The model goes last in the picker's URL because ui.js + # appends the chosen id to it. + carried = { + "model": model if model and preselected and preselected.model_id == model else "", + "temporary": "1" if temporary else "", + "kind": kind if kind in KINDS and kind != KIND_CHAT else "", + "folder": starting_folder.id if starting_folder is not None else "", + } + + def new_chat_url(**changes: str) -> str: + query = urlencode({k: v for k, v in {**carried, **changes}.items() if v}) + return f"/chat?{query}" if query else "/chat" + + without_model = new_chat_url(model="") return render( request, "chat/index.html", @@ -749,6 +768,8 @@ async def chat_index( else chat_service.DEFAULT_EFFORTS ), "starting_temporary": temporary, + "temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"), + "model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=", "starting_kind": kind if kind in KINDS else KIND_CHAT, "starting_folder": starting_folder, "suggestions": suggestions_service.visible(db), diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js index 41aee98..71e546e 100644 --- a/src/lembas/web/static/js/commands.js +++ b/src/lembas/web/static/js/commands.js @@ -222,7 +222,16 @@ { name: "temp", summary: "Start a temporary chat, gone after a day", - run: function () { window.location = "/chat?temporary=1"; } + run: function () { + /* On the new-chat screen the model, folder and kind already chosen are + in the query string. Add the flag to them rather than starting over, + or the chat is made on the default model. */ + var query = new URLSearchParams( + window.location.pathname === "/chat" ? window.location.search : "" + ); + query.set("temporary", "1"); + window.location = "/chat?" + query.toString(); + } }, { name: "stop", diff --git a/src/lembas/web/templates/chat/_model_picker.html b/src/lembas/web/templates/chat/_model_picker.html index 2ad6fab..1777427 100644 --- a/src/lembas/web/templates/chat/_model_picker.html +++ b/src/lembas/web/templates/chat/_model_picker.html @@ -91,7 +91,9 @@ {% else %} {# No chat yet: selecting navigates so the whole composer re-renders with the - right vision warning and the right hidden model_id. #} - + right vision warning and the right hidden model_id. The URL carries the + other preselections -- temporary, folder, kind -- and ends in `model=`, + which ui.js completes. #} + {% endif %} diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 2e8452d..fd185de 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -65,11 +65,12 @@
{# A link, not a script: the flag lives in the URL, so it survives a - reload and can be bookmarked. + reload and can be bookmarked. The URL is built in `chat_index` so it + keeps the chosen model, folder and kind. #} {% if not chat and can.get("chat.create") %} {{ icon("clock") }} diff --git a/tests/test_commands_js.py b/tests/test_commands_js.py index b9ea00a..ed2261f 100644 --- a/tests/test_commands_js.py +++ b/tests/test_commands_js.py @@ -58,3 +58,12 @@ def test_send_from_anywhere_never_means_stop(): stops.""" window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600] assert 'composerAction === "send"' in window + + +def test_temp_keeps_what_the_new_chat_screen_already_chose(): + """`/temp` went to a bare `/chat?temporary=1`, so on a new-chat screen with + a model picked it quietly swapped back to the default model.""" + window = SOURCE[SOURCE.index('name: "temp"') :][:600] + assert 'window.location = "/chat?temporary=1"' not in window + assert "window.location.search" in window + assert 'query.set("temporary", "1")' in window diff --git a/tests/test_temporary_chat.py b/tests/test_temporary_chat.py index 08270f5..cb21cf1 100644 --- a/tests/test_temporary_chat.py +++ b/tests/test_temporary_chat.py @@ -2,6 +2,8 @@ from __future__ import annotations +import html +import re from datetime import UTC, datetime, timedelta from fastapi.testclient import TestClient @@ -100,6 +102,59 @@ def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered assert 'name="temporary"' in client.get("/chat?temporary=1").text +def _temporary_link(page: str) -> str: + match = re.search(r'href="([^"]*)"\s+aria-label="Temporary chat"', page) + assert match, "no temporary toggle on the page" + return html.unescape(match.group(1)) + + +def _picker_url(page: str) -> str: + match = re.search(r'data-picker-navigate="([^"]*)"', page) + assert match, "no navigating model picker on the page" + return html.unescape(match.group(1)) + + +def test_the_temporary_toggle_keeps_the_chosen_model(client: TestClient, db, registered): + """Temporary chats could only be started on the default model: the toggle + linked to a bare `/chat?temporary=1`, so the model picked a moment before + was dropped.""" + connection = _connection(db) + db.add(Model(connection_id=connection.id, model_id="other-model")) + db.commit() + + on = _temporary_link(client.get("/chat?model=other-model").text) + assert "model=other-model" in on and "temporary=1" in on + assert 'name="model_id" value="other-model"' in client.get(on).text + + off = _temporary_link(client.get(on).text) + assert "model=other-model" in off and "temporary" not in off + + +def test_choosing_a_model_keeps_the_temporary_flag(client: TestClient, db, registered): + """The other half: the picker navigated to a bare `/chat?model=`, so + picking a model after the toggle quietly made the chat an ordinary one.""" + connection = _connection(db) + db.add(Model(connection_id=connection.id, model_id="other-model")) + db.commit() + + url = _picker_url(client.get("/chat?temporary=1").text) + assert url.endswith("model=") + page = client.get(url + "other-model").text + assert 'name="temporary"' in page + assert 'name="model_id" value="other-model"' in page + assert _picker_url(client.get("/chat").text) == "/chat?model=" + + +def test_both_links_keep_the_folder(client: TestClient, db, registered): + _connection(db) + client.post("/api/folders", data={"name": "Quests"}) + folder = db.scalar(select(Folder)) + + page = client.get(f"/chat?folder={folder.id}").text + assert f"folder={folder.id}" in _temporary_link(page) + assert f"folder={folder.id}" in _picker_url(page) + + def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered): _connection(db) client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})