Names that fit the chat, and a way to change one
Two things about titles were wrong. Every chat spent a second completion on its name, including an agent chat whose opening words are already a title -- somebody starting one states an objective, not a topic. An agent chat now takes `fallback_title` from its first prompt and makes no request at all; an ordinary chat, which opens with a question whose *answer* is what makes a title worth asking for, is unchanged. And renaming existed only as the `/title` slash command, which set the heading and left the sidebar row showing the old name until the next reload -- a rename that looks half-applied is one people do twice. There are pencil buttons on the heading and on every sidebar row now, both PATCHing the route that was already there, and `update_chat` answers a rename with the out-of-band pair the `done` frame has always sent, so one response moves both. Only on a rename: sending it for every PATCH would overwrite the heading from an unrelated save. `/title` sets both spans itself, being a bare fetch rather than htmx. The dialog is the `data-prompt` mechanism the folder work added, which is why the heading keeps a button rather than becoming an inline field: it sits in a flex row beside the badges and the connection chip, and swapping it for a text box moves all of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1463,12 +1463,14 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
allowed = permissions.resolve(db, user)
|
allowed = permissions.resolve(db, user)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
||||||
|
renamed = False
|
||||||
if "title" in form:
|
if "title" in form:
|
||||||
cleaned = str(form["title"]).strip()[:300]
|
cleaned = str(form["title"]).strip()[:300]
|
||||||
if cleaned:
|
if cleaned:
|
||||||
chat.title = cleaned
|
chat.title = cleaned
|
||||||
# An explicit rename must not be overwritten by auto-titling later.
|
# An explicit rename must not be overwritten by auto-titling later.
|
||||||
chat.title_generated = True
|
chat.title_generated = True
|
||||||
|
renamed = True
|
||||||
|
|
||||||
if "folder_id" in form:
|
if "folder_id" in form:
|
||||||
chat.folder_id = str(form["folder_id"]) or None
|
chat.folder_id = str(form["folder_id"]) or None
|
||||||
@@ -1576,6 +1578,16 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
chat.params_json = {**(chat.params_json or {}), "reasoning_effort": seeded}
|
chat.params_json = {**(chat.params_json or {}), "reasoning_effort": seeded}
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
if renamed:
|
||||||
|
# The two out-of-band spans the `done` frame already uses, so one
|
||||||
|
# response updates the heading *and* the sidebar row. Renaming used to
|
||||||
|
# be the `/title` command alone, which set the heading and left the
|
||||||
|
# sidebar showing the old name until the next reload -- a rename that
|
||||||
|
# looks half-applied is one people do twice.
|
||||||
|
return HTMLResponse(
|
||||||
|
templates.get_template("chat/_title_oob.html").render({"chat": chat})
|
||||||
|
)
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,13 @@ async def _run(generation: Generation) -> None:
|
|||||||
)
|
)
|
||||||
question = _question_from(payload)
|
question = _question_from(payload)
|
||||||
needs_title = not chat.title_generated
|
needs_title = not chat.title_generated
|
||||||
|
# An agent chat is titled from its opening words and never costs a
|
||||||
|
# model call for it. That prompt is a good title already -- somebody
|
||||||
|
# starting one states an objective, not a topic -- while an ordinary
|
||||||
|
# chat opens with a question, whose answer is what makes a title
|
||||||
|
# worth asking for. Read here with the rest, because titling happens
|
||||||
|
# after this session has closed.
|
||||||
|
title_from_prompt = chat.kind == KIND_AGENT
|
||||||
# Read here, with the rest, because titling happens after this
|
# Read here, with the rest, because titling happens after this
|
||||||
# session has closed and must not open another one.
|
# session has closed and must not open another one.
|
||||||
title_prompt = prompts_service.resolve(db, "task.title")
|
title_prompt = prompts_service.resolve(db, "task.title")
|
||||||
@@ -714,7 +721,7 @@ async def _run(generation: Generation) -> None:
|
|||||||
# a chat title is never worth surfacing an error for.
|
# a chat title is never worth surfacing an error for.
|
||||||
title = ""
|
title = ""
|
||||||
if needs_title and question:
|
if needs_title and question:
|
||||||
if generation.error or endpoint is None:
|
if title_from_prompt or generation.error or endpoint is None:
|
||||||
title = chat_service.fallback_title(question)
|
title = chat_service.fallback_title(question)
|
||||||
else:
|
else:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
|
|||||||
@@ -115,9 +115,16 @@
|
|||||||
body.append("title", wanted);
|
body.append("title", wanted);
|
||||||
fetch("/api/chats/" + chat(), { method: "PATCH", body: body, credentials: "same-origin" })
|
fetch("/api/chats/" + chat(), { method: "PATCH", body: body, credentials: "same-origin" })
|
||||||
.then(function () {
|
.then(function () {
|
||||||
var heading = el("#chat-title");
|
/* Both places the title appears. The heading alone left the sidebar
|
||||||
// textContent, never innerHTML: this is text somebody typed.
|
row showing the old name until the next reload, which reads as a
|
||||||
if (heading) heading.textContent = wanted;
|
rename that half worked -- and is the reason the route now hands
|
||||||
|
back the out-of-band pair for every other caller. This one is a
|
||||||
|
bare fetch rather than htmx, so it sets them itself.
|
||||||
|
|
||||||
|
textContent, never innerHTML: this is text somebody typed. */
|
||||||
|
[el("#chat-title"), el("#chat-link-label-" + chat())].forEach(function (node) {
|
||||||
|
if (node) node.textContent = wanted;
|
||||||
|
});
|
||||||
note("Renamed.");
|
note("Renamed.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,23 @@
|
|||||||
|
|
||||||
<h1 class="topbar__title">
|
<h1 class="topbar__title">
|
||||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||||
|
{#
|
||||||
|
Rename, where the name is. A themed dialog through `data-prompt`
|
||||||
|
rather than an inline field: the heading is in a flex row beside the
|
||||||
|
badges and the connection chip, and swapping it for a text box moves
|
||||||
|
all of them. The response is the same out-of-band pair the `done`
|
||||||
|
frame sends, so the sidebar row follows without a second request.
|
||||||
|
#}
|
||||||
|
{% if chat %}
|
||||||
|
<button class="btn btn--icon btn--sm topbar__rename" type="button"
|
||||||
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
|
data-prompt="What should this chat be called?"
|
||||||
|
data-prompt-title="Rename chat" data-prompt-field="title"
|
||||||
|
data-prompt-value="{{ chat.title }}"
|
||||||
|
aria-label="Rename chat" title="Rename chat">
|
||||||
|
{{ icon("pencil", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{# Beside the title because it describes the chat rather than acting on
|
{# Beside the title because it describes the chat rather than acting on
|
||||||
it. The way out of it is Keep, in the overflow menu. #}
|
it. The way out of it is Keep, in the overflow menu. #}
|
||||||
{% if chat and chat.temporary %}
|
{% if chat and chat.temporary %}
|
||||||
|
|||||||
@@ -16,6 +16,17 @@
|
|||||||
{{ '' if chat_item.unread else 'hidden' }} title="New reply"></span>
|
{{ '' if chat_item.unread else 'hidden' }} title="New reply"></span>
|
||||||
</a>
|
</a>
|
||||||
<span class="nav-item__actions">
|
<span class="nav-item__actions">
|
||||||
|
{# Works from any page carrying the sidebar, not only from inside the chat.
|
||||||
|
The response carries both out-of-band spans, so the heading follows if
|
||||||
|
this happens to be the open chat. #}
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
hx-patch="/api/chats/{{ chat_item.id }}" hx-swap="none"
|
||||||
|
data-prompt="What should this chat be called?"
|
||||||
|
data-prompt-title="Rename chat" data-prompt-field="title"
|
||||||
|
data-prompt-value="{{ chat_item.title }}"
|
||||||
|
aria-label="Rename chat" title="Rename chat">
|
||||||
|
{{ icon("pencil", "icon--sm") }}
|
||||||
|
</button>
|
||||||
<button class="btn btn--icon btn--sm" type="button"
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""How a chat gets its name, and how it gets a different one."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, Chat, Connection, Message, Model, User
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import generation as generation_service
|
||||||
|
from lembas.services.crypto import encrypt
|
||||||
|
|
||||||
|
|
||||||
|
def _add_connection(db) -> Connection:
|
||||||
|
connection = Connection(
|
||||||
|
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||||
|
db.commit()
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_awaiting_a_reply(db, user_id, *, kind="chat", question="Rebuild the search index"):
|
||||||
|
connection = db.scalar(select(Connection))
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user_id, model_id="test-model", connection_id=connection.id, kind=kind
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
db.add(Message(chat_id=chat.id, role="user", content=question, complete=True))
|
||||||
|
db.commit()
|
||||||
|
assistant = Message(chat_id=chat.id, role=ROLE_ASSISTANT, content="", complete=False)
|
||||||
|
db.add(assistant)
|
||||||
|
db.commit()
|
||||||
|
return chat.id, assistant.id
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_stream(text: str):
|
||||||
|
async def stream_chat(_endpoint, _payload):
|
||||||
|
yield {"choices": [{"delta": {"content": text}}]}
|
||||||
|
|
||||||
|
return stream_chat
|
||||||
|
|
||||||
|
|
||||||
|
# --- Where a title comes from ---------------------------------------------------
|
||||||
|
async def test_an_agent_chat_is_named_from_its_first_prompt(db, user_id, monkeypatch):
|
||||||
|
"""Somebody starting one states an objective, not a topic, so the opening
|
||||||
|
words are already a title. No second completion is spent on it."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id, message_id = _chat_awaiting_a_reply(db, user_id, kind=KIND_AGENT)
|
||||||
|
monkeypatch.setattr(generation_service, "stream_chat", _stub_stream("Done."))
|
||||||
|
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
async def _never(*args, **kwargs):
|
||||||
|
asked.append(args)
|
||||||
|
return "From the model"
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_service, "generate_title", _never)
|
||||||
|
|
||||||
|
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||||
|
await generation_service._run(generation)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Chat, chat_id).title == "Rebuild the search index"
|
||||||
|
assert asked == [], "an agent chat must not spend a completion on its name"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_ordinary_chat_still_asks_a_model(db, user_id, monkeypatch):
|
||||||
|
"""The opening of an ordinary chat is a question, and its answer is what
|
||||||
|
makes a title worth asking for."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id, message_id = _chat_awaiting_a_reply(db, user_id)
|
||||||
|
monkeypatch.setattr(generation_service, "stream_chat", _stub_stream("Like so."))
|
||||||
|
|
||||||
|
async def _titled(*args, **kwargs):
|
||||||
|
return "Search indexing, explained"
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_service, "generate_title", _titled)
|
||||||
|
|
||||||
|
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||||
|
await generation_service._run(generation)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Chat, chat_id).title == "Search indexing, explained"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_agent_title_is_trimmed_on_a_word_boundary(db, user_id, monkeypatch):
|
||||||
|
_add_connection(db)
|
||||||
|
long = (
|
||||||
|
"Rebuild the search index and then reindex every document in the "
|
||||||
|
"knowledge base before the deploy"
|
||||||
|
)
|
||||||
|
chat_id, message_id = _chat_awaiting_a_reply(db, user_id, kind=KIND_AGENT, question=long)
|
||||||
|
monkeypatch.setattr(generation_service, "stream_chat", _stub_stream("Done."))
|
||||||
|
|
||||||
|
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||||
|
await generation_service._run(generation)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
title = db.get(Chat, chat_id).title
|
||||||
|
assert title == chat_service.fallback_title(long)
|
||||||
|
assert len(title) <= chat_service.MAX_TITLE_LENGTH + 1
|
||||||
|
assert title.endswith("…")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_agent_chat_is_named_only_once(db, user_id, monkeypatch):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id, message_id = _chat_awaiting_a_reply(db, user_id, kind=KIND_AGENT)
|
||||||
|
monkeypatch.setattr(generation_service, "stream_chat", _stub_stream("Done."))
|
||||||
|
|
||||||
|
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||||
|
await generation_service._run(generation)
|
||||||
|
db.expire_all()
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
assert chat.title_generated is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Renaming ---------------------------------------------------------------------
|
||||||
|
def test_a_rename_answers_with_both_places_the_title_appears(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""One response, two out-of-band spans. The heading alone left the sidebar
|
||||||
|
row showing the old name until the next reload, which reads as a rename
|
||||||
|
that half worked."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
|
||||||
|
response = client.patch(f"/api/chats/{chat_id}", data={"title": "Orthanc"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="chat-title"' in response.text
|
||||||
|
assert f'id="chat-link-label-{chat_id}"' in response.text
|
||||||
|
assert "Orthanc" in response.text
|
||||||
|
assert db.get(Chat, chat_id).title == "Orthanc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rename_stops_the_chat_being_auto_titled(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
client.patch(f"/api/chats/{chat_id}", data={"title": "Orthanc"})
|
||||||
|
assert db.get(Chat, chat_id).title_generated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_title_is_escaped_on_the_way_back(client: TestClient, db, registered, make_chat):
|
||||||
|
"""It is text somebody typed, and it lands in two spans on a page that can
|
||||||
|
open a shell."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/chats/{chat_id}", data={"title": "<img src=x onerror=alert(1)>"}
|
||||||
|
)
|
||||||
|
assert "<img src=x" not in response.text
|
||||||
|
assert "<img" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blank_rename_changes_nothing_and_says_nothing(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""A chat with no name is one nobody can find in the sidebar."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
db.get(Chat, chat_id).title = "Orthanc"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.patch(f"/api/chats/{chat_id}", data={"title": " "})
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert db.get(Chat, chat_id).title == "Orthanc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_patch_that_is_not_a_rename_still_answers_204(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""The out-of-band pair is only right when the title actually moved. Sending
|
||||||
|
it for every PATCH would overwrite the heading from an unrelated save."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
assert client.patch(f"/api/chats/{chat_id}", data={"agent_mode": "auto"}).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_renaming_someone_elses_chat_is_a_404(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
other = User(name="Sam", email="sam@shire.test", password_hash="x")
|
||||||
|
db.add(other)
|
||||||
|
db.commit()
|
||||||
|
chat = Chat(user_id=other.id, model_id="test-model", title="Theirs")
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert client.patch(f"/api/chats/{chat.id}", data={"title": "Mine"}).status_code == 404
|
||||||
|
assert db.get(Chat, chat.id).title == "Theirs"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Where the button is ------------------------------------------------------------
|
||||||
|
def test_the_rename_button_is_on_the_heading_and_the_row(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
page = client.get(f"/chat/{chat_id}").text
|
||||||
|
|
||||||
|
assert page.count(f'hx-patch="/api/chats/{chat_id}"') >= 2
|
||||||
|
assert 'data-prompt-field="title"' in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rename_button_posts_at_a_route_that_serves_patch(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""A control wired to a method its route does not serve fails silently."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
assert client.post(f"/api/chats/{chat_id}", data={"title": "x"}).status_code == 405
|
||||||
|
assert client.patch(f"/api/chats/{chat_id}", data={"title": "x"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_new_chat_screen_offers_no_rename(client: TestClient, db, registered):
|
||||||
|
"""There is no row to rename until the first message is sent."""
|
||||||
|
_add_connection(db)
|
||||||
|
assert 'data-prompt-field="title"' not in client.get("/chat").text
|
||||||
Reference in New Issue
Block a user