diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 15:07:23 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 15:07:23 +0200 |
| commit | a3e4f3db58b0c2cf1c97053a62b1bcf68b70feac (patch) | |
| tree | db21cb4093ffb4000343d13ea8cbda77d93ebfd5 | |
| parent | 072166db317428351779233ce82352e09dcb5c0a (diff) | |
| download | desktop-assistant-a3e4f3db58b0c2cf1c97053a62b1bcf68b70feac.tar.gz desktop-assistant-a3e4f3db58b0c2cf1c97053a62b1bcf68b70feac.zip | |
Take files with a question
ask carries paths; the daemon reads them as the user. Text files and a
PDF's text go inline within half the free context, shared equally, and
are stored that way, so a reopened conversation still has them. Images,
and PDF pages with no text, go as image parts to a model the router
reports as seeing images, and are stored as a path and hash, resolved
when sent: a file changed or gone since is reported as no longer
available. turns gains a files column for the chips, added to an older
database on open. A refused file is one log line, not a traceback.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
| -rw-r--r-- | assistant.py | 338 |
1 files changed, 307 insertions, 31 deletions
diff --git a/assistant.py b/assistant.py index 777ceba..7b95255 100644 --- a/assistant.py +++ b/assistant.py @@ -21,8 +21,11 @@ finished turn is kept in a SQLite history with full-text search. Run: python3 assistant.py (--test runs the self-check) """ +import base64 import glob +import hashlib import json +import mimetypes import os import re import signal @@ -147,25 +150,30 @@ def db_open(path): conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.executescript(SCHEMA) + # Added with attachments; a database from before them gains the column. + if "files" not in {r[1] for r in conn.execute("PRAGMA table_info(turns)")}: + conn.execute("ALTER TABLE turns ADD COLUMN files TEXT") return conn -def save_turn(conn, conv, user, reply, tools, messages): +def save_turn(conn, conv, user, reply, tools, messages, files=()): # Returns the conversation id: the row is made at its first finished turn, - # so a New that is never used leaves nothing behind. + # so a New that is never used leaves nothing behind. A question that is + # only files is titled by their names. now = int(time.time()) + title = " ".join(user.split()[:6]) or ", ".join(f["name"] for f in files) with conn: if conv is None: conv = conn.execute("INSERT INTO conversations(title, created, updated) " - "VALUES (?, ?, ?)", - (" ".join(user.split()[:6]), now, now)).lastrowid + "VALUES (?, ?, ?)", (title, now, now)).lastrowid else: conn.execute("UPDATE conversations SET updated = ? WHERE id = ?", (now, conv)) seq = conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 FROM turns WHERE conv = ?", (conv,)).fetchone()[0] - conn.execute("INSERT INTO turns(conv, seq, user, reply, tools, messages) " - "VALUES (?, ?, ?, ?, ?, ?)", - (conv, seq, user, reply, json.dumps(tools), json.dumps(messages))) + conn.execute("INSERT INTO turns(conv, seq, user, reply, tools, messages, files) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (conv, seq, user, reply, json.dumps(tools), json.dumps(messages), + json.dumps(list(files)))) return conv @@ -176,12 +184,13 @@ def list_convs(conn): def load_conv(conn, conv): # (model history, shown turns), or None for an id that is not there. - rows = conn.execute("SELECT user, reply, tools, messages FROM turns WHERE conv = ? " - "ORDER BY seq", (conv,)).fetchall() + rows = conn.execute("SELECT user, reply, tools, messages, files FROM turns " + "WHERE conv = ? ORDER BY seq", (conv,)).fetchall() if not rows: return None - return ([json.loads(m) for *_, m in rows], - [{"user": u, "reply": r, "tools": json.loads(t)} for u, r, t, _ in rows]) + return ([json.loads(m) for _, _, _, m, _ in rows], + [{"user": u, "reply": r, "tools": json.loads(t), "files": json.loads(f or "[]")} + for u, r, t, _, f in rows]) def search(conn, q): @@ -209,6 +218,134 @@ def delete_conv(conn, conv): conn.execute("DELETE FROM conversations WHERE id = ?", (conv,)) +# Attachments. Paths come from the canvas; the daemon runs as the user and +# reads them itself. Text, and a PDF's text, go inline and are stored that +# way, so a reopened conversation still has them. Images are stored as a +# path and hash and read again when sent, so the history does not grow by +# every picture, and a file changed since is reported gone rather than +# shown as something it no longer is. +CODE_SUFFIXES = { + ".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx", + ".tsx", ".sh", ".bash", ".zsh", ".lua", ".rb", ".pl", ".php", ".java", + ".kt", ".swift", ".sql", ".toml", ".yaml", ".yml", ".json", ".ini", + ".cfg", ".conf", ".md", ".rst", ".txt", ".csv", ".xml", ".html", ".css", + ".diff", ".patch", ".slackbuild", ".info", ".desktop", ".service", ".qml", +} +IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} +MAX_FILES = 8 +MAX_IMAGE = 20 << 20 +MAX_PAGE_IMAGES = 4 # scanned PDF pages sent as images + + +class Refused(Exception): + """A file or request the user has to change; logged without a trace.""" + + +def kind_of(path): + mime = mimetypes.guess_type(path)[0] or "" + if mime in IMAGE_TYPES: + return "image" + if mime == "application/pdf": + return "pdf" + if mime.startswith("text/") or os.path.splitext(path)[1].lower() in CODE_SUFFIXES: + return "text" + return "other" + + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def pdf_pages(path): + out = subprocess.run(["pdftotext", "-layout", path, "-"], capture_output=True, + text=True, timeout=60) + if out.returncode: + last = out.stderr.strip().splitlines()[-1:] or ["pdftotext failed"] + raise Refused(f"cannot read {os.path.basename(path)}: {last[0]}") + pages = out.stdout.split("\f") + return pages[:-1] if out.stdout.endswith("\f") else pages # each page ends in \f + + +def attach(paths, budget): + # (text blocks, image references, chips) for the files; Refused says + # what is wrong with the first bad one. Text files and PDFs share the + # budget of characters equally. + if len(paths) > MAX_FILES: + raise Refused(f"at most {MAX_FILES} files") + loaded = [] + for p in paths: + name = os.path.basename(p) or p + if not (os.path.isabs(p) and os.path.isfile(p) and os.access(p, os.R_OK)): + raise Refused(f"cannot read {name}") + kind = kind_of(p) + if kind == "other": + raise Refused(f"{name}: chat supports text, images and PDF") + if kind == "image" and os.path.getsize(p) > MAX_IMAGE: + raise Refused(f"{name}: images over 20 MB are refused") + loaded.append((p, name, kind)) + share = budget // max(1, sum(k != "image" for _, _, k in loaded)) + blocks, refs, chips = [], [], [] + for p, name, kind in loaded: + chip = {"name": name, "kind": kind, "truncated": False} + chips.append(chip) + if kind == "image": + refs.append({"type": "image_ref", "name": name, "path": p, "sha256": sha256(p)}) + continue + if kind == "pdf": + pages = pdf_pages(p) + chip["pages"] = len(pages) + body = "\n\n".join(f"[page {i}]\n{t.rstrip()}" for i, t in enumerate(pages, 1)) + # A page with no text is a scan: the model reads it as an image. + blank = [i for i, t in enumerate(pages, 1) if not t.strip()][:MAX_PAGE_IMAGES] + digest = sha256(p) if blank else "" + refs += [{"type": "image_ref", "name": f"{name} page {n}", "path": p, + "sha256": digest, "page": n} for n in blank] + else: + with open(p, "rb") as f: # no more than the share could use + body = f.read(share * 4 + 4).decode("utf-8", "replace") + if len(body) > share: + body, chip["truncated"] = body[:share], True + note = " (truncated)" if chip["truncated"] else "" + blocks.append(f"--- file: {name}{note} ---\n{body}\n--- end file ---") + return blocks, refs, chips + + +def resolve(part): + # An image reference back into an image the model can see, or a note + # that it is gone: moved, deleted, or changed since it was sent. + if part.get("type") != "image_ref": + return part + gone = {"type": "text", "text": f"[image {part['name']} is no longer available]"} + try: + if sha256(part["path"]) != part["sha256"]: + return gone + if "page" in part: + n = str(part["page"]) + out = subprocess.run(["pdftoppm", "-f", n, "-l", n, "-r", "150", "-png", part["path"]], + capture_output=True, timeout=60) + if out.returncode or not out.stdout: + return gone + data, mime = out.stdout, "image/png" + else: + with open(part["path"], "rb") as f: + data = f.read() + mime = mimetypes.guess_type(part["path"])[0] + except (OSError, subprocess.TimeoutExpired): + return gone + return {"type": "image_url", + "image_url": {"url": f"data:{mime};base64,{base64.b64encode(data).decode()}"}} + + +def wire(messages): + # What is sent: the stored messages with their image references resolved. + return [{**m, "content": [resolve(p) for p in m["content"]]} + if isinstance(m.get("content"), list) else m for m in messages] + + # The canvas's feed: JSON lines on a Unix socket, both ways. Every client # gets every event. A client that stalls for 2 s is dropped rather than # holding up the others. @@ -354,6 +491,9 @@ def handle(raw): # equal an unsaved conversation's None. bad = ("bad id" if kind in ("open", "delete") and type(cmd.get("id")) is not int else "bad text" if kind == "ask" and not isinstance(cmd.get("text"), str) + else "bad files" if kind == "ask" and not ( + isinstance(cmd.get("files", []), list) + and all(isinstance(f, str) for f in cmd.get("files", []))) else "bad query" if kind == "search" and not isinstance(cmd.get("q"), str) else None) if bad: @@ -372,10 +512,10 @@ def handle(raw): def command(kind, cmd): # Caller holds lock; arguments are already checked. if kind == "ask": - text = cmd["text"].strip() - if text: + text, files = cmd["text"].strip(), cmd.get("files", []) + if text or files: interrupt() - threading.Thread(target=reply, args=(turn, text), daemon=True).start() + threading.Thread(target=reply, args=(turn, text, files), daemon=True).start() elif kind == "stop": interrupt() elif kind == "new": @@ -474,6 +614,16 @@ def llm_model(): models[0]["id"]) +def sees_images(model): + # The router reports each model's inputs; a server that does not is + # left to decide for itself. + with urllib.request.urlopen(CFG["llm"] + "/models", timeout=10) as r: + models = json.load(r)["data"] + m = next((m for m in models if m["id"] == model), {}) + inputs = (m.get("architecture") or {}).get("input_modalities") + return inputs is None or "image" in inputs + + def ask(messages, calls, tools=None): # Yields ("think", text) and ("token", text) as they stream; tool-call # fragments go into calls. @@ -540,11 +690,13 @@ def say(mine, kind, value, done=False): return True -def reply(mine, text): +def reply(mine, text, files=()): try: - answer(mine, text) + answer(mine, text, files) except Exception as e: - log("reply failed:\n" + traceback.format_exc().rstrip()) + # A refused file is the user's to fix, not a bug: one line, no trace. + log(f"refused: {e}" if isinstance(e, Refused) + else "reply failed:\n" + traceback.format_exc().rstrip()) with lock: if turn == mine: cur["pending"] = None @@ -554,16 +706,25 @@ def reply(mine, text): emit({"type": "state", "state": "idle"}) -def answer(mine, text): +def answer(mine, text, files=()): + blocks, refs, chips = [], [], [] + if files: + # Half of what the context has free, at about 3 characters a token. + free = (ctx["max"] - ctx["used"]) // 2 * 3 if ctx["max"] else 24000 + blocks, refs, chips = attach(files, max(free, 2000)) + model = llm_model() + if refs and not sees_images(model): + raise Refused(f"{model} cannot see images") with lock: if turn != mine: return history = list(cur["history"]) - cur["pending"] = {"user": text, "reply": "", "think": "", "tools": []} - emit({"type": "user", "text": text}) + cur["pending"] = {"user": text, "reply": "", "think": "", "tools": [], "files": chips} + emit({"type": "user", "text": text, "files": chips}) emit({"type": "state", "state": "thinking"}) - log(f"you: {text}") - question = {"role": "user", "content": text} + log(f"you: {text}" + "".join(f" [{c['name']}]" for c in chips)) + body = "\n\n".join(blocks + [text] if text else blocks) + question = {"role": "user", "content": [{"type": "text", "text": body}] + refs if refs else body} # The model has no clock: without the date it makes one up for tools. # Also stamped on the question itself, which history does not keep: with # the time only in the system prompt, "what time is it?" asked again an @@ -573,7 +734,8 @@ def answer(mine, text): # ponytail: last 10 turns, summarise if long sessions need memory msgs = ([{"role": "system", "content": f"{CFG['system']} It is {now}."}] + [m for past in history[-10:] for m in past] - + [{"role": "user", "content": f"[{now}] {text}"}]) + + [{"role": "user", "content": [{"type": "text", "text": f"[{now}] {body}"}] + refs + if refs else f"[{now}] {body}"}]) first = len(msgs) # this turn's tool messages start here said, used = "", [] # ponytail: a stopped reply notices at its next chunk, so llama-server's @@ -581,7 +743,8 @@ def answer(mine, text): for rnd in range(CFG["tool_rounds"] + 1): calls, content = {}, "" # The last round offers no tools, which forces an answer. - for kind, piece in ask(msgs, calls, TOOL_SCHEMA if rnd < CFG["tool_rounds"] else None): + for kind, piece in ask(wire(msgs), calls, + TOOL_SCHEMA if rnd < CFG["tool_rounds"] else None): if not say(mine, kind, piece): log("interrupted") return @@ -619,9 +782,9 @@ def answer(mine, text): if turn != mine: log("interrupted") return - cur["id"] = save_turn(DB, cur["id"], text, said, used, entry) + cur["id"] = save_turn(DB, cur["id"], text, said, used, entry, chips) cur["history"].append(entry) - cur["turns"].append({"user": text, "reply": said, "tools": used}) + 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"}) @@ -716,8 +879,8 @@ def test(): hist, shown = load_conv(db, a) assert hist == [[{"role": "user", "content": "q"}], [{"role": "user", "content": "q2"}]] assert shown == [{"user": "che tempo fa in città oggi pomeriggio?", "reply": "Piove.", - "tools": ["weather"]}, - {"user": "e domani?", "reply": "Sole.", "tools": []}] + "tools": ["weather"], "files": []}, + {"user": "e domani?", "reply": "Sole.", "tools": [], "files": []}] assert load_conv(db, 999) is None hits = search(db, "citta") # no accent typed, accented word found assert [h["id"] for h in hits] == [a] and "**città**" in hits[0]["snippet"] @@ -740,16 +903,103 @@ def test(): assert os.stat(os.path.dirname(path)).st_mode & 0o777 == 0o700 assert os.stat(path).st_mode & 0o777 == 0o600 + # Attachments. + import struct + import zlib + + def tiny_pdf(path, pages): + # One Helvetica line per page; "" is a page with no text, a scan. + objs = ["<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [%s] /Count %d >>" + % (" ".join(f"{4 + 2 * i} 0 R" for i in range(len(pages))), len(pages)), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"] + for i, t in enumerate(pages): + stream = f"BT /F1 24 Tf 72 700 Td ({t}) Tj ET" if t else "" + objs += [f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources " + f"<< /Font << /F1 3 0 R >> >> /Contents {5 + 2 * i} 0 R >>", + f"<< /Length {len(stream)} >>\nstream\n{stream}\nendstream"] + out, offsets = b"%PDF-1.4\n", [] + for k, o in enumerate(objs, 1): + offsets.append(len(out)) + out += f"{k} 0 obj\n{o}\nendobj\n".encode() + xref = len(out) + out += (f"xref\n0 {len(objs) + 1}\n0000000000 65535 f \n".encode() + + b"".join(f"{o:010d} 00000 n \n".encode() for o in offsets) + + f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref}\n%%EOF\n".encode()) + with open(path, "wb") as f: + f.write(out) + + def tiny_png(path, rgb=b"\xff\x00\x00"): + def chunk(t, d): + return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(b"\x00" + rgb)) + chunk(b"IEND", b"")) + + files_dir = tempfile.TemporaryDirectory() + fd = files_dir.name + code, png, pdf, other = (os.path.join(fd, n) for n in ("a.py", "red.png", "doc.pdf", "x.bin")) + with open(code, "w") as f: + f.write("print('hi')\n" * 50) + tiny_png(png) + tiny_pdf(pdf, ["hello pdf", ""]) + with open(other, "wb") as f: + f.write(b"\x00\x01") + assert [kind_of(p) for p in (code, png, pdf, other, "/x/README.md", "/x/b.SlackBuild")] == \ + ["text", "image", "pdf", "other", "text", "text"] + blocks, refs, chips = attach([code, pdf, png], 100000) + assert blocks[0].startswith("--- file: a.py ---\nprint('hi')") and "(truncated)" not in blocks[0] + assert "[page 1]\nhello pdf" in blocks[1] and "[page 2]" in blocks[1] + assert chips == [{"name": "a.py", "kind": "text", "truncated": False}, + {"name": "doc.pdf", "kind": "pdf", "truncated": False, "pages": 2}, + {"name": "red.png", "kind": "image", "truncated": False}] + # The blank page is a scan: it goes as an image, rendered when sent. + assert [r["name"] for r in refs] == ["doc.pdf page 2", "red.png"] and refs[0]["page"] == 2 + sent = [resolve(r) for r in refs] + assert all(x["type"] == "image_url" for x in sent) + assert sent[1]["image_url"]["url"].startswith("data:image/png;base64,") + assert base64.b64decode(sent[0]["image_url"]["url"].split(",", 1)[1])[:4] == b"\x89PNG" + small = attach([code], 24)[0][0] # the share is the cut: two lines + assert "(truncated)" in small and small.count("print") == 2 + for bad, why in ((["a.py"], "cannot read a.py"), ([fd + "/nope.txt"], "cannot read nope.txt"), + ([fd], "cannot read"), ([other], "x.bin: chat supports text, images and PDF"), + ([code] * 9, "at most 8 files")): + try: + attach(bad, 1000) + assert False, bad + except Refused as e: + assert why in str(e), (bad, e) + # Stored as a reference: survives a save and reload, gone once changed. + a = save_turn(db, None, "", "A red pixel.", [], [{"role": "user", "content": refs[1:]}], + chips[2:]) + hist, shown = load_conv(db, a) + assert shown[0]["files"] == chips[2:] and list_convs(db)[0]["title"] == "red.png" + assert wire(hist[0])[0]["content"][0]["type"] == "image_url" + tiny_png(png, b"\x00\xff\x00") + assert wire(hist[0])[0]["content"][0] == { + "type": "text", "text": "[image red.png is no longer available]"} + os.remove(png) + assert resolve(refs[1])["type"] == "text" # deleted reads the same + # An old database gains the files column. + with tempfile.TemporaryDirectory() as d: + old = sqlite3.connect(os.path.join(d, "h.db")) + old.executescript(SCHEMA) + old.close() + assert "files" in {r[1] for r in db_open(os.path.join(d, "h.db")) + .execute("PRAGMA table_info(turns)")} + # Protocol, against a fake llama-server. global DB, TOOLS, TOOL_SCHEMA - script = {"replies": [], "delay": 0} + script = {"replies": [], "delay": 0, "inputs": ["text", "image"]} class Fake(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass def do_GET(self): - body = ({"data": [{"id": "m", "status": {"value": "loaded"}}]} + body = ({"data": [{"id": "m", "status": {"value": "loaded"}, + "architecture": {"input_modalities": script["inputs"]}}]} if self.path.endswith("/models") else {"default_generation_settings": {"n_ctx": 4096}}) self.send_response(200) @@ -818,7 +1068,7 @@ def test(): assert script["body"]["chat_template_kwargs"]["enable_thinking"] is True conv = cur["id"] assert load_conv(DB, conv)[1] == [{"user": "what time is it", "reply": "It is noon.", - "tools": ["now"]}] + "tools": ["now"], "files": []}] # The tool result went to the model but history keeps a placeholder. assert load_conv(DB, conv)[0][0][2] == {"role": "tool", "tool_call_id": "c1", "content": "(shown to the user at the time)"} @@ -888,6 +1138,32 @@ def test(): seen = until("conversations") assert {"type": "conversation", "id": None, "turns": []} in seen # was current assert seen[-1]["items"] == [] + # Files: the text inline, the image resolved, chips on the turn. + tiny_png(png) + script["replies"] = [[{"content": "Red."}]] + send(type="ask", text="what is it?", files=[pdf, png]) + user = until("user")[-1] + assert user["files"][1] == {"name": "red.png", "kind": "image", "truncated": False} + until("done") + q = script["body"]["messages"][-1]["content"] + assert "hello pdf" in q[0]["text"] and q[0]["text"].endswith("what is it?") + assert [x["type"] for x in q] == ["text", "image_url", "image_url"] + assert cur["turns"][-1]["files"] == user["files"] + # Only files, no text. + script["replies"] = [[{"content": "Code."}]] + send(type="ask", text="", files=[code]) + assert until("user")[-1]["text"] == "" + until("done") + # A model without vision is refused before anything is sent. + script["inputs"] = ["text"] + send(type="ask", text="see?", files=[png]) + e = until("error")[-1] + assert e["message"] == "m cannot see images" and e["turn"] is True + script["inputs"] = ["text", "image"] + send(type="ask", text="x", files=[other]) + assert "chat supports" in until("error")[-1]["message"] + send(type="ask", text="x", files="not a list") + assert until("error")[-1]["message"] == "bad files" # llama-server down: an error, not a dead thread. CFG["llm"] = "http://127.0.0.1:9/v1" send(type="ask", text="anyone?") |
