Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65ea90fe6
|
@@ -16,6 +16,15 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.9.0
|
||||||
|
|
||||||
The model menu says which model is loaded, and a new chat now matches the
|
The model menu says which model is loaded, and a new chat now matches the
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.9.0"
|
__version__ = "1.9.1"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
from zoneinfo import available_timezones
|
from zoneinfo import available_timezones
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
@@ -727,6 +728,24 @@ async def chat_index(
|
|||||||
if preselected is None and context["models"]:
|
if preselected is None and context["models"]:
|
||||||
preselected = context["models"][0]
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"chat/index.html",
|
"chat/index.html",
|
||||||
@@ -749,6 +768,8 @@ async def chat_index(
|
|||||||
else chat_service.DEFAULT_EFFORTS
|
else chat_service.DEFAULT_EFFORTS
|
||||||
),
|
),
|
||||||
"starting_temporary": temporary,
|
"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_kind": kind if kind in KINDS else KIND_CHAT,
|
||||||
"starting_folder": starting_folder,
|
"starting_folder": starting_folder,
|
||||||
"suggestions": suggestions_service.visible(db),
|
"suggestions": suggestions_service.visible(db),
|
||||||
|
|||||||
@@ -222,7 +222,16 @@
|
|||||||
{
|
{
|
||||||
name: "temp",
|
name: "temp",
|
||||||
summary: "Start a temporary chat, gone after a day",
|
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",
|
name: "stop",
|
||||||
|
|||||||
@@ -91,7 +91,9 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# No chat yet: selecting navigates so the whole composer re-renders with the
|
{# 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
|
||||||
<span hidden data-picker-navigate="/chat?model="></span>
|
other preselections -- temporary, folder, kind -- and ends in `model=`,
|
||||||
|
which ui.js completes. #}
|
||||||
|
<span hidden data-picker-navigate="{{ model_navigate_url }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,11 +65,12 @@
|
|||||||
<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
|
||||||
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") %}
|
{% if not chat and can.get("chat.create") %}
|
||||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
||||||
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
|
href="{{ temporary_toggle_url }}"
|
||||||
aria-label="{{ t('Temporary chat') }}"
|
aria-label="{{ t('Temporary chat') }}"
|
||||||
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
|
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
|
||||||
{{ icon("clock") }}
|
{{ icon("clock") }}
|
||||||
|
|||||||
@@ -58,3 +58,12 @@ def test_send_from_anywhere_never_means_stop():
|
|||||||
stops."""
|
stops."""
|
||||||
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
|
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
|
||||||
assert 'composerAction === "send"' in window
|
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
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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
|
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):
|
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
|
||||||
_connection(db)
|
_connection(db)
|
||||||
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
|
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
|
||||||
|
|||||||
Reference in New Issue
Block a user