diff options
| author | Danilo M. <danix@danix.xyz> | 2026-07-31 10:35:56 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-07-31 10:35:56 +0200 |
| commit | 325a6a7c12832d8b9fb9b67567e8a1c496dc1330 (patch) | |
| tree | e2128aa71ca2f739f1ddc78060a980fbe06eacbc /llamachat_venv.py | |
| download | llamachat-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.py | 81 |
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:]) |
