From 325a6a7c12832d8b9fb9b67567e8a1c496dc1330 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 31 Jul 2026 10:35:56 +0200 Subject: feat: initial release of llamachat 0.1.0 A native PySide6 chat client for a local llama.cpp server in router mode. Runs as a single persistent process with a Unix-socket control channel, so a Hyprland keybind toggles the window with a socket round trip rather than a process start. The control commands do not import Qt and answer in under a tenth of a second. Features: one-shot and multi-turn chat modes, runtime model discovery from /v1/models, streaming replies rendered as markdown, collapsible reasoning for models with a thinking budget, drag-and-drop file and image attachment with context-aware truncation, and SQLite history with FTS5 search. Markdown is parsed with MarkdownNoHTML so markup in a reply is displayed rather than interpreted, and links a reply produces cannot drive the interface. Includes the Hyprland Lua snippets for autostart, keybind and window rules, and a self-check suite covering everything except the GUI. Co-Authored-By: Claude Opus 5 --- test_llamachat.py | 514 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100755 test_llamachat.py (limited to 'test_llamachat.py') diff --git a/test_llamachat.py b/test_llamachat.py new file mode 100755 index 0000000..4a113e0 --- /dev/null +++ b/test_llamachat.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +# +# llamachat - a small native chat client for a local llama.cpp router +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +"""Self-checks for the non-GUI logic. Run: ./test_llamachat.py""" + +import os +import sys +import tempfile +from pathlib import Path + +# Some checks need PySide6, so reuse the launcher's venv discovery rather +# than requiring the venv interpreter to be named on the command line. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import llamachat_venv # noqa: E402 + +llamachat_venv.reexec(__file__) + +from llamachat import backend, config, db + +PRESETS_SAMPLE = """\ +version = 1 + +; A preset whose mmproj line is commented out -> not vision capable. +#[Disabled-Model] +#ngl = all +#mmproj = /models/disabled/mmproj.gguf + +[vision-model] +ngl = all +ctx-size = 32768 +mmproj = /models/vision/mmproj.gguf + +[text-model] +ngl = all +ctx-size = 16384 +; --- Multimodal --- +; WARNING: eats VRAM. +#mmproj = /models/text/mmproj.gguf + +[no-ctx-model] +ngl = all +""" + + +def test_presets(): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "presets.ini" + path.write_text(PRESETS_SAMPLE) + presets = config.parse_presets(path) + + # A '#'-commented section must not appear at all. + assert "Disabled-Model" not in presets, presets.keys() + assert set(presets) == {"vision-model", "text-model", "no-ctx-model"} + + # mmproj present -> vision; commented out -> not vision. + assert presets["vision-model"].vision is True + assert presets["text-model"].vision is False + assert presets["no-ctx-model"].vision is False + + assert presets["vision-model"].ctx_size == 32768 + assert presets["text-model"].ctx_size == 16384 + assert presets["no-ctx-model"].ctx_size == 4096 # default + + # 32768 tokens * 3.5 chars * 0.5 -> 57344 chars + assert presets["vision-model"].char_budget(0.5, 3.5) == 57344 + + # A missing file yields no presets rather than raising. + assert config.parse_presets(Path("/nonexistent/presets.ini")) == {} + print("ok presets parsing") + + +def test_real_presets(): + """The user's actual file, if present, must classify as expected.""" + path = Path("/etc/llama-server/presets.ini") + if not path.exists(): + print("skip real presets (file absent)") + return + presets = config.parse_presets(path) + assert "gemma-4-12B-it" in presets + assert presets["gemma-4-12B-it"].vision is True + # Qwen3.5-9B has its mmproj line commented out with '#'. + if "Qwen3.5-9B" in presets: + assert presets["Qwen3.5-9B"].vision is False + print("ok real presets classification") + + +def test_fts_query_escaping(): + # Bare punctuation and FTS keywords must not become query syntax. + assert db._fts_query("hello world") == '"hello" "world"' + assert db._fts_query("foo AND bar") == '"foo" "AND" "bar"' + assert db._fts_query('say "hi"') == '"say" """hi"""' + assert db._fts_query("-flag") == '"-flag"' + assert db._fts_query(" ") == "" + print("ok fts query escaping") + + +def test_history_roundtrip(): + with tempfile.TemporaryDirectory() as tmp: + history = db.History(Path(tmp) / "test.db") + + chat = history.create_session("chat", "vision-model", "About otters") + history.add_message(chat, "user", "Tell me about otters please") + history.add_message(chat, "assistant", "Otters are semiaquatic mammals") + + shot = history.create_session("oneshot", "text-model", "Capital city") + history.add_message(shot, "user", "What is the capital of Italy") + + rows = history.messages(chat) + assert len(rows) == 2 + assert rows[0]["role"] == "user" + + # Newest session first. + recent = history.recent_sessions() + assert len(recent) == 2 + assert recent[0]["id"] == shot + + # FTS finds content across sessions, and punctuation cannot break it. + assert len(history.search("otters")) == 2 + assert len(history.search("capital")) == 1 + assert history.search("zebra") == [] + assert history.search('otters "AND') == [] # must not raise + + # Attachments survive with enough detail to reconstruct context. + message_id = history.add_message(chat, "user", "look at this") + history.add_attachment( + message_id, "/home/u/pic.png", "image", + mime="image/png", size=1234, sha256="abc", thumb=b"\xff\xd8jpeg", + ) + saved = history.attachments(message_id) + assert len(saved) == 1 + assert saved[0]["path"] == "/home/u/pic.png" + assert saved[0]["kind"] == "image" + assert saved[0]["thumb"] == b"\xff\xd8jpeg" + + # Editing a streamed message keeps the FTS index in step. + streamed = history.add_message(chat, "assistant", "") + history.update_message(streamed, "the platypus is unusual") + assert len(history.search("platypus")) == 1 + + # Deleting a session takes its messages out of search too. + history.delete_session(chat) + assert history.search("otters") == [] + assert len(history.recent_sessions()) == 1 + + history.close() + print("ok history roundtrip") + + +def test_attachment_truncation(): + with tempfile.TemporaryDirectory() as tmp: + big = Path(tmp) / "big.py" + big.write_text("x" * 5000) + + att = backend.load_attachment(big, char_budget=1000) + assert att.kind == "text" + assert att.truncated is True + assert len(att.text) == 1000 + assert att.size == 5000 + assert len(att.sha256) == 64 + + small = Path(tmp) / "small.txt" + small.write_text("hello") + att = backend.load_attachment(small, char_budget=1000) + assert att.truncated is False + assert att.text == "hello" + + odd = Path(tmp) / "thing.bin" + odd.write_bytes(b"\x00\x01") + try: + backend.load_attachment(odd, char_budget=1000) + except backend.BackendError: + pass + else: + raise AssertionError("unsupported type should raise") + print("ok attachment truncation") + + +def test_classify(): + assert backend.classify(Path("a.py")) == "text" + assert backend.classify(Path("a.SlackBuild")) == "text" + assert backend.classify(Path("a.png")) == "image" + assert backend.classify(Path("a.jpg")) == "image" + assert backend.classify(Path("a.so")) == "unknown" + print("ok file classification") + + +def test_sse_parsing(): + line = 'data: {"choices":[{"delta":{"content":"hi"}}]}' + assert backend._parse_sse_line(line) == ("content", "hi") + + # Reasoning arrives in its own field, which is what lets the UI keep + # thinking and reply apart without parsing tags. + think = 'data: {"choices":[{"delta":{"reasoning_content":"hmm"}}]}' + assert backend._parse_sse_line(think) == ("reasoning", "hmm") + + assert backend._parse_sse_line("data: [DONE]") == backend.DONE + assert backend._parse_sse_line(": keepalive") is None + assert backend._parse_sse_line("") is None + assert backend._parse_sse_line("data: {bad json") is None + + # An opening delta of {'role': 'assistant', 'content': None} carries + # nothing to render and must not be mistaken for end-of-stream. + opening = backend._parse_sse_line( + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}' + ) + assert opening is None + assert opening != backend.DONE + print("ok sse parsing") + + +def test_reasoning_storage(): + with tempfile.TemporaryDirectory() as tmp: + history = db.History(Path(tmp) / "r.db") + sid = history.create_session("chat", "m", "t") + + # A streamed reply is inserted empty, then filled in once done. + mid = history.add_message(sid, "assistant", "") + history.update_message(mid, "51", "the model's private thinking") + + row = history.messages(sid)[0] + assert row["content"] == "51" + assert row["reasoning"] == "the model's private thinking" + + # Reasoning must stay out of the search index, or thinking text + # would drown out real hits. + assert history.search("51") != [] + assert history.search("private") == [] + + # Omitting the argument leaves stored reasoning untouched. + history.update_message(mid, "52") + assert history.messages(sid)[0]["reasoning"] == ( + "the model's private thinking" + ) + history.close() + print("ok reasoning storage") + + +def test_migration_adds_reasoning(): + """A database created before the reasoning column must still open.""" + import sqlite3 + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "old.db" + conn = sqlite3.connect(path) + conn.executescript( + "CREATE TABLE sessions (id INTEGER PRIMARY KEY, mode TEXT," + " title TEXT, model TEXT, created_at INTEGER, updated_at INTEGER);" + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id INTEGER," + " role TEXT NOT NULL, content TEXT NOT NULL," + " created_at INTEGER NOT NULL);" + "INSERT INTO sessions VALUES (1,'chat','old','m',0,0);" + "INSERT INTO messages VALUES (1,1,'user','older message',0);" + ) + conn.commit() + conn.close() + + history = db.History(path) + rows = history.messages(1) + assert rows[0]["content"] == "older message" + assert rows[0]["reasoning"] == "" # backfilled by the migration + + mid = history.add_message(1, "assistant", "new") + history.update_message(mid, "new", "fresh thinking") + assert history.messages(1)[1]["reasoning"] == "fresh thinking" + history.close() + print("ok reasoning column migration") + + +def test_user_content(): + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "code.py" + src.write_text("print(1)") + text_att = backend.load_attachment(src, 1000) + + # Text only -> a plain string, file inlined before the prompt. + content = backend.build_user_content("explain", [text_att]) + assert isinstance(content, str) + assert "print(1)" in content + assert content.endswith("explain") + + # No attachments -> just the prompt. + assert backend.build_user_content("hi", []) == "hi" + + # An image -> the multi-part array the vision API expects. + img = backend.Attachment( + path=Path("/x/a.png"), kind="image", mime="image/png", + size=1, sha256="", data_url="data:image/png;base64,AAA", + ) + content = backend.build_user_content("what is this", [img]) + assert isinstance(content, list) + assert content[0]["type"] == "text" + assert content[1]["type"] == "image_url" + assert content[1]["image_url"]["url"].startswith("data:image/png") + print("ok user content assembly") + + +def test_markdown_rendering(): + """Replies render as markdown; markup inside them stays literal.""" + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from llamachat.ui import _markdown_to_fragment + + app = QApplication.instance() or QApplication([]) + assert app is not None + + fragment = _markdown_to_fragment( + "**bold** and *italic* and `code`\n\n" + "- a\n- b\n\n" + "| x | y |\n|---|---|\n| 1 | 2 |\n\n" + "```py\ndef f():\n pass\n```\n" + ) + assert "font-weight:700" in fragment + assert "font-style:italic" in fragment + assert " is tinted, since Qt emits one per line of a code block. + assert fragment.count("tag l' + ) + assert "<b>" in hostile, hostile + assert "font-weight:700" not in hostile + # The URL may appear, but only as escaped text, never as a live anchor. + assert "