diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 09:22:19 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 09:22:19 +0200 |
| commit | 75d9eb77a7e249f7ff8286c681dc2ec4380342be (patch) | |
| tree | c0dfbf0205cb39d40850446c9a577f32ff8cef33 | |
| parent | b3ec33b9a0f851792f98eda8b209d9cf9798d46a (diff) | |
| download | desktop-assistant-75d9eb77a7e249f7ff8286c681dc2ec4380342be.tar.gz desktop-assistant-75d9eb77a7e249f7ff8286c681dc2ec4380342be.zip | |
titles: let the model name a conversation after its first reply
A new conversation is still saved under the first six words of the
question, then the model is asked for a title of at most six words in
the conversation's language, thinking off, and the row is renamed.
The request runs after done under the reply's turn, through ask(), so
the next ask, stop, new or open hangs it up through interrupt(): a
title never holds the next reply back, and a dropped one leaves the six
words. A failure is logged only, since the reply is already saved.
The self-check's fake llama-server answers title requests apart from
the scripted replies, so a title can never take a reply meant for an
ask in the test that follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | assistant.py | 44 |
2 files changed, 44 insertions, 2 deletions
@@ -833,7 +833,7 @@ kokoro-onnx GitHub release `model-files-v1.0`. still applies; its tasks 1-4 target this daemon, the voice-specific bits of task 4 aside. - **llamachat ports**, if the canvas replaces llamachat for good: - attachments, skills, a model picker, model-written titles. + attachments, skills, a model picker (model-written titles done). - **Phase 2 tools:** tools that write or modify (events, messages, desktop), with a review step, own spec. Desktop modes done (`mode`, no review step: one bit); the settings window's other pages next, diff --git a/assistant.py b/assistant.py index f246101..cf92c16 100644 --- a/assistant.py +++ b/assistant.py @@ -244,6 +244,8 @@ PAGE_PX = 1600 # longest side of a page render MAX_AUDIO_S = 60 HEAR = ("Transcribe this audio exactly, in the language spoken (Italian or English). " "Output only the transcript.") +TITLE = ("Write a title of at most six words for this conversation, in the language " + "it is in. Output only the title.") class Refused(Exception): @@ -1027,13 +1029,38 @@ def answer(mine, text, files=()): if turn != mine: log("interrupted") return + fresh = cur["id"] is None cur["id"] = save_turn(DB, cur["id"], text, said, used, entry, chips) + conv = cur["id"] cur["history"].append(entry) cur["turns"].append({"user": text, "reply": said, "tools": used, "files": chips}) cur["pending"] = None emit({"type": "done", "id": cur["id"]}) emit({"type": "state", "state": "idle"}) log("assistant:", said) + if fresh: + retitle(mine, conv, text, said) + + +def retitle(mine, conv, user, said): + # The first six words stand until the model names the conversation. Asked + # after done, under the reply's turn: the next ask, stop, new or open + # hangs it up, so it never holds a reply back, and the six words stay. + try: + msgs = [{"role": "user", + "content": f"{TITLE}\n\nUser: {user[:2000]}\n\nAssistant: {said[:2000]}"}] + text = "".join(p for k, p in ask(msgs, {}, None, mine, think=False, meter=False) + if k == "token") + line = next((l for l in text.splitlines() if l.strip()), "") + title = line.strip().strip("\"'*#").strip()[:80] + with lock: + if turn != mine or not title: + return + with DB: + DB.execute("UPDATE conversations SET title = ? WHERE id = ?", (title, conv)) + log(f"titled: {title}") + except Exception: # the reply is saved; a title is not worth an error + log("title failed:\n" + traceback.format_exc().rstrip()) def main(): @@ -1290,7 +1317,15 @@ def test(): self.wfile.write(json.dumps(body).encode()) def do_POST(self): - script["body"] = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if str(body["messages"][0]["content"]).startswith(TITLE): + # Answered apart, so a title never takes a reply meant for an ask. + self.send_response(200) + self.end_headers() + self.wfile.write(b"data: " + json.dumps({"choices": [{"delta": { + "content": script.get("title", "")}}]}).encode() + b"\n\ndata: [DONE]\n\n") + return + script["body"] = body script.get("bodies", []).append(script["body"]) if script.get("hold"): # A long prompt pass: nothing is sent, so only the client @@ -1354,6 +1389,7 @@ def test(): {"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "now", "arguments": "{}"}}]}], [{"content": "It is "}, {"content": "noon."}]] + script["title"] = '"Ora attuale"\n' send(type="ask", text="what time is it") got = [(e["type"], e.get("delta") or e.get("name") or e.get("state") or e.get("text")) for e in until("done") if e["type"] != "ctx"] @@ -1364,6 +1400,12 @@ def test(): assert until("state")[-1]["state"] == "idle" assert script["body"]["chat_template_kwargs"]["enable_thinking"] is True conv = cur["id"] + for _ in range(40): # the title comes after done + if list_convs(DB)[0]["title"] == "Ora attuale": + break + time.sleep(0.05) + assert list_convs(DB)[0]["title"] == "Ora attuale", list_convs(DB) + script["title"] = "" assert load_conv(DB, conv)[1] == [{"user": "what time is it", "reply": "It is noon.", "tools": ["now"], "files": []}] # The tool result went to the model but history keeps a placeholder. |
