summaryrefslogtreecommitdiffstats
path: root/llamachat_venv.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-31 10:35:56 +0200
committerDanilo M. <danix@danix.xyz>2026-07-31 10:35:56 +0200
commit325a6a7c12832d8b9fb9b67567e8a1c496dc1330 (patch)
treee2128aa71ca2f739f1ddc78060a980fbe06eacbc /llamachat_venv.py
downloadllamachat-325a6a7c12832d8b9fb9b67567e8a1c496dc1330.tar.gz
llamachat-325a6a7c12832d8b9fb9b67567e8a1c496dc1330.zip
feat: initial release of llamachat 0.1.0v0.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 <noreply@anthropic.com>
Diffstat (limited to 'llamachat_venv.py')
-rw-r--r--llamachat_venv.py81
1 files changed, 81 insertions, 0 deletions
diff --git a/llamachat_venv.py b/llamachat_venv.py
new file mode 100644
index 0000000..68fd981
--- /dev/null
+++ b/llamachat_venv.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# llamachat - a small native chat client for a local llama.cpp router
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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.
+"""Finding the interpreter that has PySide6.
+
+PySide6 is usually installed in a virtualenv rather than the system Python,
+so a script started with `#!/usr/bin/env python3` has to hand over to that
+interpreter. Keeping the search here means the launcher and the test script
+agree on where to look, and neither hardcodes a path.
+
+`LLAMACHAT_PYTHON` names an interpreter explicitly. `LLAMACHAT_NO_REEXEC`
+disables the handover, which is also how the re-executed process avoids
+looping.
+"""
+
+import os
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+
+# Checked in order; the first interpreter that exists wins.
+CANDIDATES = (
+ HERE / ".venv" / "bin" / "python3",
+ HERE / "venv" / "bin" / "python3",
+ Path.home() / ".local" / "share" / "llamachat-venv" / "bin" / "python3",
+)
+
+
+def have_pyside() -> bool:
+ try:
+ import PySide6 # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def find_interpreter() -> Path | None:
+ """The first candidate interpreter that exists and is not this one.
+
+ Paths are compared unresolved on purpose. A venv created with
+ --system-site-packages has a bin/python3 that symlinks to the system
+ interpreter, so resolving both sides would make the venv look identical
+ to the interpreter we are trying to escape. What differs is sys.prefix,
+ which follows from the path used to start it, not from the real binary.
+ """
+ override = os.environ.get("LLAMACHAT_PYTHON")
+ candidates = [Path(override)] if override else list(CANDIDATES)
+ current = Path(sys.executable)
+ for candidate in candidates:
+ if candidate.is_file() and candidate != current:
+ return candidate
+ return None
+
+
+def reexec(script: str) -> None:
+ """Restart `script` under an interpreter that has PySide6.
+
+ Returns normally when the current interpreter is already usable, when
+ the handover is disabled, or when no other interpreter was found; in
+ that last case the caller fails later with a clear ImportError.
+ """
+ if os.environ.get("LLAMACHAT_NO_REEXEC") or have_pyside():
+ return
+ interpreter = find_interpreter()
+ if interpreter is None:
+ return
+ # Set before exec so the new process cannot bounce again.
+ os.environ["LLAMACHAT_NO_REEXEC"] = "1"
+ path = str(Path(script).resolve())
+ os.execv(str(interpreter), [str(interpreter), path] + sys.argv[1:])