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 | |
| 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>
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | CHANGELOG.md | 73 | ||||
| -rw-r--r-- | LICENSE | 338 | ||||
| -rw-r--r-- | README.md | 324 | ||||
| -rw-r--r-- | initial-prompt.md | 98 | ||||
| -rw-r--r-- | llamachat.desktop | 12 | ||||
| -rwxr-xr-x | llamachat.py | 42 | ||||
| -rw-r--r-- | llamachat/__init__.py | 15 | ||||
| -rw-r--r-- | llamachat/__main__.py | 267 | ||||
| -rw-r--r-- | llamachat/backend.py | 237 | ||||
| -rw-r--r-- | llamachat/config.py | 167 | ||||
| -rw-r--r-- | llamachat/db.py | 254 | ||||
| -rw-r--r-- | llamachat/ipc.py | 109 | ||||
| -rw-r--r-- | llamachat/ui.py | 902 | ||||
| -rw-r--r-- | llamachat_venv.py | 81 | ||||
| -rwxr-xr-x | test_llamachat.py | 514 |
16 files changed, 3438 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac2afd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a7435e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-07-31 + +First working version. + +### Added + +- Persistent background process with a Unix-socket control channel, so a + window toggle costs a socket round trip instead of a process start. The + control commands (`--toggle`, `--show`, `--hide`, `--ping`, `--quit`) do + not import Qt and answer in well under a tenth of a second. +- Two chat modes. One-shot asks a single question with no context carried + over; Chat keeps a resumable multi-turn conversation. +- Model picker populated at runtime from the router's `/v1/models`, with + vision-capable models marked. +- Streaming replies rendered token by token. +- Markdown rendering for replies: headings, emphasis, lists, tables, inline + code and tinted fenced code blocks, via `QTextDocument` with no markdown + dependency. User input is shown literally so attached file contents are + never reflowed. +- Collapsed reasoning. Models with a reasoning budget return their thinking + in a separate field; it shows as a one-line summary that expands on click, + tracked per reply. +- File attachment by drag-and-drop or file dialog. Text and code files are + inlined into the prompt and truncated to fit the model's context with a + warning. Images require a vision model, and dropping one on a text model + offers to switch. +- SQLite history with FTS5 full-text search. Chat sessions reopen and + continue with context intact; one-shot entries reopen read-only. +- Attachment provenance: original path, size, SHA-256, and a thumbnail for + images, so a reopened conversation still shows what was sent even if the + file has since moved. +- System tray icon for show/hide/quit. +- Configuration in `~/.config/llamachat/config.toml`, written with defaults + on first run. +- `presets.ini` parsing for the two things the API does not report: which + models have an mmproj file, and each model's context size. +- Hyprland Lua snippets for autostart, keybind and floating window rules. + +### Fixed + +- `presets.ini` opens with a bare `version = 1` before any section, which + made `configparser` reject the whole file. +- Sessions sharing a one-second timestamp sorted unpredictably in the + history list and in search results. +- A venv PySide6 bundles almost no Qt plugins, so `QT_QPA_PLATFORMTHEME` + found nothing and every window fell back to Fusion, ignoring qt6ct and + Kvantum. The system plugin directory is now used when its Qt version + matches the bundled one exactly. +- A reply containing a markdown link to the internal reasoning scheme + produced a working toggle, letting model output drive the interface. + The scheme is namespaced, forged anchors are rewritten before display, + and only `http`, `https` and `mailto` links reach the desktop opener. +- Venv discovery compared resolved interpreter paths, so a venv created + with `--system-site-packages`, whose `bin/python3` symlinks to the system + interpreter, was discarded as "already running". + +### Security + +- Markdown is parsed with the `MarkdownNoHTML` flag, so markup in a reply is + displayed rather than interpreted. +- The control socket is created mode 0600. +- Search input is tokenised and quoted before reaching FTS5, so punctuation + cannot be read as query syntax. + @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + 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. + + You should have received a copy of the GNU General Public License along + with this program; if not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Moe Ghoul>, 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c05beb --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +# llamachat + +A small native desktop chat client for a local llama.cpp server running in +router mode. PySide6, no browser, no Electron. It runs as a single +persistent process and toggles like a scratchpad from a Hyprland keybind. + +## Features + +- **Two modes.** One-shot for a quick question with no context carried over, + Chat for a resumable multi-turn conversation. The toggle sits in the top + bar, not in a menu. +- **Model picker** populated at runtime from the router's `/v1/models`. Models + with an `mmproj` file in `presets.ini` are marked with an eye. +- **File attachment** by drag-and-drop anywhere on the window or through the + file dialog. Text and code files are inlined into the prompt, truncated to + fit the model's context with a warning when that happens. Images require a + vision model, and dropping one on a text model offers to switch. +- **History** in SQLite with FTS5 full-text search. Past chat sessions reopen + and continue with their context intact; one-shot entries reopen read-only. +- **Streaming replies** rendered token by token, formatted as markdown: + headings, bold and italic, bullet and numbered lists, tables, inline code + and tinted fenced code blocks. What you type is shown exactly as typed, so + the contents of an attached file are never reflowed. +- **Collapsed reasoning.** Presets with a reasoning budget return the model's + thinking separately from its answer. It shows as a one-line `▸ thinking` + summary above the reply, and clicking that expands it. Each reply collapses + independently and the state is remembered per bubble. +- **Tray icon** for show/hide/quit, hosted by waybar's tray module. + +Not in this version: RAG or embedding search over history, multi-user +support, any cloud or non-local backend, remote access. + +## Requirements + +- Python 3.11 or newer (3.12 tested) +- PySide6, httpx, Pillow +- A llama.cpp server in router mode with an OpenAI-compatible API + +Pillow is only used for attachment thumbnails. + +## Installation + +PySide6 is not in SlackBuilds, so the simplest route on Slackware is a venv. +`--system-site-packages` lets httpx and Pillow come from the system packages +instead of being duplicated: + +```sh +python3 -m venv --system-site-packages ~/.local/share/llamachat-venv +~/.local/share/llamachat-venv/bin/pip install PySide6-Essentials +``` + +Then put the launcher on your PATH: + +```sh +git clone <repo> ~/src/llamachat +ln -s ~/src/llamachat/llamachat.py ~/bin/llamachat +``` + +The launcher starts under the system Python and hands over to an interpreter +that has PySide6, so no activation step is needed and the command works from +any directory. It looks for `.venv/` or `venv/` beside the checkout, then +`~/.local/share/llamachat-venv/`. Set `LLAMACHAT_PYTHON` to name a specific +interpreter, or `LLAMACHAT_NO_REEXEC=1` to disable the handover entirely. + +Install the desktop entry, which lets the portal resolve the application id +and quiets a startup warning: + +```sh +ln -s ~/src/llamachat/llamachat.desktop \ + ~/.local/share/applications/llamachat.desktop +``` + +Verify: + +```sh +llamachat --help +cd ~/src/llamachat && ./test_llamachat.py +``` + +### Qt theming from a venv + +A pip-installed PySide6 bundles its own Qt build with almost no plugins, so +`QT_QPA_PLATFORMTHEME=qt6ct` finds nothing to load and every window falls +back to Fusion, ignoring qt6ct and Kvantum entirely. + +llamachat handles this at startup: if the system Qt6 is the *same version* as +the one PySide6 bundles, it points `QT_PLUGIN_PATH` at the system plugin +directory so the real platform theme loads. The version check matters, +because a plugin built against a different Qt would crash rather than merely +look wrong. Setting `QT_PLUGIN_PATH` yourself disables this and your value is +used unchanged. + +Check what a mismatch would look like: + +```sh +python3 -c 'from PySide6.QtCore import qVersion; print("bundled", qVersion())' +ls /usr/lib64/libQt6Core.so.6.* +``` + +If those differ, the theme will not be picked up. Either install a PySide6 +matching the system Qt (`pip install "PySide6-Essentials==6.11.*"` for a +6.11 system) or build PySide6 against the system Qt. + +## Configuration + +`~/.config/llamachat/config.toml` is written with defaults on first run. + +```toml +base_url = "http://localhost:8181" +presets = "/etc/llama-server/presets.ini" + +# Empty values take the XDG defaults: +# socket -> $XDG_RUNTIME_DIR/llamachat.sock +# db -> $XDG_DATA_HOME/llamachat/history.db +socket = "" +db = "" + +default_model = "" +request_timeout = 300 + +attach_ctx_fraction = 0.5 +chars_per_token = 3.5 +``` + +`presets.ini` is read for two things the API does not report: which models +have an `mmproj` file, and each model's `ctx-size`. Both `;` and `#` count as +comment characters there, matching llama-server, so a commented-out `mmproj` +line correctly reads as "no vision support". + +An attachment may fill `ctx_size * chars_per_token * attach_ctx_fraction` +characters before it is truncated. With the defaults and a 32k model that is +roughly 57000 characters. Models the router offers but `presets.ini` does not +describe fall back to a conservative 4096-token assumption. + +## Usage + +``` +llamachat --daemon start the background instance +llamachat --toggle show if hidden, hide if visible +llamachat --show show the window +llamachat --hide hide the window +llamachat --ping exit 0 when an instance is running +llamachat --quit stop the running instance +``` + +The control commands do not import Qt, so they return in well under a tenth +of a second, which is what makes them usable behind a keybind. + +In the window: **Ctrl+Enter** sends, **Esc** hides, **New** starts a fresh +conversation, right-clicking a history entry offers to delete it. + +## Hyprland configuration + +For Hyprland 0.55+ with the Lua config system. Paths below assume the +`~/.config/hypr/sections/` layout. + +### Autostart + +In `sections/autostart.lua`, inside the existing `hl.on("hyprland.start", ...)` +block: + +```lua +hl.exec_cmd("llamachat --daemon") +``` + +### Keybind + +In `sections/keybindings.lua`: + +```lua +hl.bind(mainMod .. " + Home", hl.dsp.exec_cmd("llamachat --toggle")) +``` + +### Window rules + +In `sections/look_and_feel.lua`: + +```lua +-- llamachat: floating scratchpad-style window +hl.window_rule({ + name = "llamachat-float", + match = { class = "llamachat" }, + float = true, +}) +hl.window_rule({ + name = "llamachat-size", + match = { class = "llamachat" }, + size = { 1000, 700 }, +}) +hl.window_rule({ + name = "llamachat-center", + match = { class = "llamachat" }, + center = true, +}) +``` + +If `center` is not supported by your Hyprland Lua plugin, pin the geometry +instead. `move` is monitor-relative, so for a 2560x1080 DP-1 and a 1000x700 +window the offsets are x=(2560-1000)/2=780 and y=(1080-700)/2=190: + +```lua +hl.window_rule({ + name = "llamachat-monitor", + match = { class = "llamachat" }, + monitor = "DP-1", +}) +hl.window_rule({ + name = "llamachat-move", + match = { class = "llamachat" }, + move = { 780, 190 }, +}) +``` + +The window class is `llamachat`. Class matching in this plugin is a +regex/substring match, so no escaping is needed. + +Reload with `hyprctl reload` after editing. Window rules apply at window-open +time, so if the daemon was already running when you added them, restart it +with `llamachat --quit` followed by `llamachat --daemon`. + +## Architecture + +``` +llamachat.py launcher, venv shebang, symlink target +llamachat/ + __main__.py argument dispatch, daemon, tray, socket event loop + config.py config.toml and presets.ini parsing + backend.py httpx streaming client, attachment loading + db.py SQLite schema, FTS5 search + ui.py the window +test_llamachat.py self-checks for everything except the GUI +``` + +### Control protocol + +One JSON object per line over a Unix socket, one response line per request. +The socket is created mode 0600 in `$XDG_RUNTIME_DIR`. + +``` +-> {"cmd": "toggle"} <- {"ok": true, "visible": false} +-> {"cmd": "show"} <- {"ok": true, "visible": true} +-> {"cmd": "hide"} <- {"ok": true, "visible": false} +-> {"cmd": "ping"} <- {"ok": true} +-> {"cmd": "quit"} <- {"ok": true} +``` + +Anything else answers `{"ok": false, "error": "..."}`. A socket left behind by +a crashed instance is detected with a ping and removed at startup. The +listening socket is serviced by a `QSocketNotifier` on the Qt event loop, so +there is no second thread. + +### Database schema + +```sql +sessions (id, mode, title, model, created_at, updated_at) +messages (id, session_id, role, content, reasoning, created_at) +attachments (id, message_id, path, kind, mime, size, sha256, thumb, truncated) +messages_fts -- FTS5 external-content table over messages.content +``` + +Three triggers keep the FTS index in step with inserts, updates and deletes, +which matters because a streamed reply is inserted empty and updated once it +finishes. + +`reasoning` holds the model's thinking, kept in its own column for two +reasons: the FTS index covers `content` only, so thinking text cannot bury +real search hits, and only `content` is replayed when continuing a +conversation, which is what these APIs expect. Databases created before this +column existed gain it automatically on open. + +Attachment *content* lives inline in `messages.content`, since that is what +was actually sent to the model. The `attachments` table records provenance: +the original path, size, and a SHA-256 so a file can be identified even if it +was later moved or renamed, plus a 128px JPEG thumbnail for images so a +reopened conversation still shows what was sent even when the original file +is gone. Full image bytes are not stored. + +Search input is tokenised and quoted before it reaches FTS5, so punctuation +that would otherwise be read as query syntax cannot cause an error. + +### Markdown rendering + +Replies are parsed by `QTextDocument.setMarkdown()`, so there is no markdown +dependency. It runs in the GitHub dialect with the `MarkdownNoHTML` flag, +which means markup a model emits is displayed literally rather than being +interpreted: a reply containing `<img src=x onerror=...>` shows that text and +nothing executes. + +Markdown links are still real links, so a reply cannot forge one that drives +the interface: anchors using the internal `x-llamachat-reasoning:` scheme are +rewritten before display, and only `http`, `https` and `mailto` links are +handed to the desktop opener. + +Qt renders a fenced block as one `<pre>` element per line with no background +of its own, so llamachat adds the tint that makes a code block read as one. + +## Notes and limits + +- Switching models makes the router unload the previous one. The first token + after a switch can take several seconds; the window shows a waiting state + rather than pretending it is instant. +- Reasoning is read from the `reasoning_content` field the router sends + alongside `content`, not by parsing `<think>` tags out of the reply. How + much of it a model produces is set by `reasoning-budget` in `presets.ini`; + set that to `0` for a preset that should not think at all. +- The attachment budget is an estimate based on a characters-per-token ratio, + not a real tokeniser. It is deliberately conservative. +- The tray icon needs a StatusNotifierItem host. With waybar, that means the + `tray` module in your config. Without one, the icon silently does nothing + and the keybind still works. + +## License + +GPL-2.0-only. See [LICENSE](LICENSE). + +Copyright (C) 2026 Danilo M. <danix@danix.xyz> + +## Development Approach + +This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. + +All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. + +The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. diff --git a/initial-prompt.md b/initial-prompt.md new file mode 100644 index 0000000..413e559 --- /dev/null +++ b/initial-prompt.md @@ -0,0 +1,98 @@ +# Original project brief + +This is the brief llamachat was built from, kept for historical reference. +It describes the v1 target; see the README for what the code actually does +and the CHANGELOG for what changed since. + +## Context + +The backend is a llama.cpp server running in router mode, configured through +a `presets.ini` file that defines multiple models, including at least one +vision-capable model paired with an mmproj file. The server exposes an +OpenAI-compatible API on localhost. + +The target desktop is Slackware with Hyprland on Wayland, using qt6ct and +Kvantum for Qt theming. + +## Goals + +- A lightweight PySide6 (Qt6) desktop app, not a browser-based or Electron + solution. It should pick up the system Qt theme rather than fighting it. +- Toggled via a global keyboard shortcut bound in Hyprland, behaving like a + scratchpad: press once to show, press again to hide. This requires the + app to run as a single persistent background process that listens on a + Unix domain socket for a toggle signal, rather than spawning a new + process/window per invocation. +- Two chat modes: + 1. **One-shot**: quick single question/answer, no multi-turn context. + 2. **Chat**: persistent multi-turn conversation, resumable. + Both are saved to history and searchable. The UI should make the mode + switch obvious and easy (e.g. a toggle or tab, not buried in a menu). +- **Model selection**: a dropdown/picker populated from the models + currently available on the llama.cpp router (query `/v1/models` or + equivalent at runtime, do not hardcode model names). +- **File attachment**: both drag-and-drop onto the chat window and a + standard file picker dialog. + - Text/code files: read content and inject into context (with reasonable + size limits and a truncation warning if the file is too large for the + active model's context window). + - Image files: only valid when a vision-capable (mmproj-paired) model is + selected. If an image is dropped while a non-vision model is active, + prompt to switch to the vision preset (this triggers a router-side + unload/reload, so show a "loading model..." state, expect a + multi-second delay, do not treat it as instant). +- **History**: stored in SQLite, covering both one-shot and chat-mode + sessions. Needs: + - A browsable list (most recent first) that can be scrolled and reopened. + - Full-text search across past conversations (use SQLite FTS5). + - Reopening a past chat-mode conversation should allow continuing it + (append new messages with prior context intact). Reopening a one-shot + entry is read-only reference, no continuation needed. + - No automatic "model searches its own history" / RAG feature in this + version. Explicitly out of scope for v1. +- **Streaming responses**: use the OpenAI-compatible streaming endpoint so + responses render token-by-token, not as one blocking call. + +## Non-goals for v1 + +- No RAG / embedding-based history search. +- No multi-user support. +- No cloud fallback or non-local model support. +- No mobile/remote access, this is a local desktop tool only. + +## Technical constraints + +- Python 3, PySide6 for GUI. +- `httpx` (with streaming support) for talking to the llama.cpp OpenAI- + compatible API. +- SQLite (stdlib `sqlite3`) with an FTS5 virtual table for search. +- Unix domain socket for IPC between the Hyprland keybind and the running + app instance (toggle show/hide). Provide the small client-side command + the Hyprland keybind should call. +- Config (backend URL, socket path, default model, etc.) in a simple file, + not hardcoded, e.g. `~/.config/llamachat/config.toml`. +- Target platform is Slackware/Hyprland/Wayland specifically, so avoid + anything X11-only or assuming GNOME/KDE session services are present. +- The Hyprland config is written in **Lua** (Hyprland 0.55+, which replaced + the older hyprlang `.conf` format). Use Lua syntax throughout (keybinds, + window rules, and the autostart entry as Lua function calls), not + old-style `key = value` hyprlang lines. Do not assume backward + compatibility with hyprlang is present, target Lua only. + +## Process + +1. Confirm the llama.cpp router port/endpoint and presets.ini model naming + convention before assuming them. +2. Propose the SQLite schema (tables for sessions, messages, attachments) + and the FTS5 setup before implementing. +3. Propose the Unix socket protocol (message format for toggle/show/hide, + and any future commands) before implementing. +4. Provide the Hyprland config snippets needed (window rule for + floating/scratchpad behavior, autostart line, keybind) as part of the + deliverable, not just the Python app. +5. Keep the UI minimal: model picker, mode toggle, chat/input area, + drag-drop zone, history sidebar or panel. No feature creep beyond what + is listed above. + +Work through this iteratively: propose the architecture and schema first, +review, then implement. diff --git a/llamachat.desktop b/llamachat.desktop new file mode 100644 index 0000000..2fb35fa --- /dev/null +++ b/llamachat.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=llamachat +GenericName=Local LLM Chat +Comment=Chat client for a local llama.cpp router +Exec=llamachat --toggle +Icon=user-available +Terminal=false +Categories=Utility;Chat; +Keywords=llm;chat;llama;ai; +StartupWMClass=llamachat +NoDisplay=false diff --git a/llamachat.py b/llamachat.py new file mode 100755 index 0000000..55214c2 --- /dev/null +++ b/llamachat.py @@ -0,0 +1,42 @@ +#!/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. <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. +"""Launcher. Symlink this into a directory on your PATH. + +PySide6 usually lives in a virtualenv rather than the system Python, so this +re-executes itself with the venv interpreter when it finds one. Set +LLAMACHAT_PYTHON to point at a specific interpreter, otherwise the venv +beside the checkout and the default location are tried in turn. +""" + +import sys +from pathlib import Path + +# Resolve through the symlink so the package is found next to the real file. +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import llamachat_venv # noqa: E402 + +# The control commands do not need Qt, so only pay for the handover when a +# command that builds a window is about to run. +_NO_QT = {"--toggle", "--show", "--hide", "--ping", "--quit", "--version", + "--help", "-h"} +if not _NO_QT & set(sys.argv[1:]): + llamachat_venv.reexec(__file__) + +from llamachat.__main__ import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/llamachat/__init__.py b/llamachat/__init__.py new file mode 100644 index 0000000..34974b8 --- /dev/null +++ b/llamachat/__init__.py @@ -0,0 +1,15 @@ +# 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. + +__version__ = "0.1.0" diff --git a/llamachat/__main__.py b/llamachat/__main__.py new file mode 100644 index 0000000..0faf88a --- /dev/null +++ b/llamachat/__main__.py @@ -0,0 +1,267 @@ +# 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. +"""Entry point. + +The control commands (--toggle, --show, --hide, --ping, --quit) answer +without importing Qt, so a keybind press stays cheap. +""" + +import argparse +import os +import sys +from pathlib import Path + +from . import __version__, config, ipc + +# Where a distribution keeps its Qt6 plugins. Checked in order. +SYSTEM_QT_PLUGIN_DIRS = ( + "/usr/lib64/qt6/plugins", + "/usr/lib/qt6/plugins", + "/usr/lib/x86_64-linux-gnu/qt6/plugins", +) + +USAGE = f"""\ +llamachat {__version__} - chat client for a local llama.cpp router + + llamachat --daemon start the background instance (Hyprland autostart) + llamachat --toggle show the window if hidden, hide it if visible + llamachat --show show the window + llamachat --hide hide the window + llamachat --ping exit 0 when an instance is running + llamachat --quit stop the running instance + llamachat --version print the version and exit +""" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--daemon", action="store_true") + parser.add_argument("--toggle", action="store_true") + parser.add_argument("--show", action="store_true") + parser.add_argument("--hide", action="store_true") + parser.add_argument("--ping", action="store_true") + parser.add_argument("--quit", action="store_true") + parser.add_argument("-h", "--help", action="store_true") + parser.add_argument("--version", action="store_true") + args = parser.parse_args(argv) + + if args.help: + print(USAGE) + return 0 + + if args.version: + print(f"llamachat {__version__}") + return 0 + + cfg = config.load() + + for name in ("toggle", "show", "hide", "ping", "quit"): + if getattr(args, name): + return _control(cfg, name) + + return _run_daemon(cfg) + + +def _control(cfg, command: str) -> int: + """Send one command to the running instance.""" + if not cfg.socket_path.exists(): + print("llamachat is not running", file=sys.stderr) + return 1 + try: + reply = ipc.send(cfg.socket_path, command) + except OSError as exc: + print(f"llamachat is not reachable: {exc}", file=sys.stderr) + return 1 + if not reply.get("ok"): + print(reply.get("error", "command failed"), file=sys.stderr) + return 1 + return 0 + + +def _system_qt_version() -> str: + """Qt6 version of the system install, or '' when it cannot be told.""" + for directory in SYSTEM_QT_PLUGIN_DIRS: + path = Path(directory) + if not path.is_dir(): + continue + # libQt6Core.so.6.11.1 -> 6.11.1 + for libdir in (path.parent.parent, Path("/usr/lib64"), Path("/usr/lib")): + for lib in sorted(libdir.glob("libQt6Core.so.6.*")): + suffix = lib.name.split("libQt6Core.so.", 1)[1] + if suffix.count(".") == 2: + return suffix + return "" + + +def _use_system_qt_theme() -> None: + """Let the system platform theme plugins load into a venv PySide6. + + A pip-installed PySide6 bundles its own Qt but almost no plugins, so + QT_QPA_PLATFORMTHEME=qt6ct finds nothing and everything falls back to + Fusion. Adding the system plugin directory fixes that, but only when + the two Qt builds are the same version: loading a plugin built against + a different Qt would crash rather than look wrong. + """ + if os.environ.get("QT_PLUGIN_PATH"): + return # the user has already chosen; do not override + + from PySide6.QtCore import qVersion + + system = _system_qt_version() + if not system or system != qVersion(): + return + + for directory in SYSTEM_QT_PLUGIN_DIRS: + if Path(directory, "platformthemes").is_dir(): + os.environ["QT_PLUGIN_PATH"] = directory + return + + +def _run_daemon(cfg) -> int: + """Start the persistent instance: window hidden, tray icon, socket.""" + ipc.clear_stale(cfg.socket_path) + if cfg.socket_path.exists(): + print("llamachat is already running", file=sys.stderr) + return 1 + + # Must happen before QApplication exists, or the theme plugin is never + # consulted. + _use_system_qt_theme() + + # Heavy imports live here so the control path above stays fast. + from PySide6.QtCore import QSocketNotifier, Qt + from PySide6.QtGui import QAction, QIcon, QPixmap, QPainter, QColor, QFont + from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon + + from .backend import Client + from .db import History + from .ui import ChatWindow + + config.write_default() + + app = QApplication(sys.argv) + app.setApplicationName("llamachat") + app.setDesktopFileName("llamachat") + app.setQuitOnLastWindowClosed(False) + + history = History(cfg.db_path) + client = Client(cfg.base_url, cfg.request_timeout) + presets = config.parse_presets(cfg.presets_path) + window = ChatWindow(cfg, history, client, presets) + + def toggle() -> bool: + if window.isVisible(): + window.hide() + else: + show() + return window.isVisible() + + def show() -> None: + window.show() + window.raise_() + window.activateWindow() + window.input.setFocus() + + # Tray icon. Waybar's tray module hosts it. + tray = QSystemTrayIcon(_icon(QPixmap, QPainter, QColor, QFont, QIcon), app) + tray.setToolTip("llamachat") + menu = QMenu() + show_action = QAction("Show / Hide", menu) + show_action.triggered.connect(toggle) + menu.addAction(show_action) + menu.addSeparator() + quit_action = QAction("Quit", menu) + quit_action.triggered.connect(app.quit) + menu.addAction(quit_action) + tray.setContextMenu(menu) + tray.activated.connect( + lambda reason: toggle() if reason == QSystemTrayIcon.Trigger else None + ) + tray.show() + + # Socket, serviced by the Qt event loop rather than a second thread. + server = ipc.create_server(cfg.socket_path) + + def on_connection() -> None: + try: + conn, _ = server.accept() + except OSError: + return + try: + command = ipc.read_request(conn) + if command == "toggle": + ipc.write_reply(conn, {"ok": True, "visible": toggle()}) + elif command == "show": + show() + ipc.write_reply(conn, {"ok": True, "visible": True}) + elif command == "hide": + window.hide() + ipc.write_reply(conn, {"ok": True, "visible": False}) + elif command == "ping": + ipc.write_reply(conn, {"ok": True}) + elif command == "quit": + ipc.write_reply(conn, {"ok": True}) + app.quit() + else: + ipc.write_reply( + conn, {"ok": False, "error": f"unknown cmd: {command}"} + ) + finally: + conn.close() + + notifier = QSocketNotifier(server.fileno(), QSocketNotifier.Read, app) + notifier.activated.connect(on_connection) + + def cleanup() -> None: + notifier.setEnabled(False) + server.close() + cfg.socket_path.unlink(missing_ok=True) + history.close() + + app.aboutToQuit.connect(cleanup) + + try: + return app.exec() + finally: + cleanup() + + +def _icon(QPixmap, QPainter, QColor, QFont, QIcon): + """A drawn tray icon, so there is no icon file to install.""" + pixmap = QPixmap(64, 64) + pixmap.fill(QColor(0, 0, 0, 0)) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(QColor("#3F5EFB")) + painter.setPen(QColor("#3F5EFB")) + painter.drawRoundedRect(4, 8, 56, 40, 8, 8) + painter.drawPolygon([_pt(16, 46), _pt(32, 46), _pt(18, 60)]) + painter.setPen(QColor("white")) + font = QFont() + font.setPixelSize(26) + font.setBold(True) + painter.setFont(font) + painter.drawText(pixmap.rect().adjusted(0, -8, 0, -8), 0x0084, "λ") + painter.end() + return QIcon(pixmap) + + +def _pt(x: int, y: int): + from PySide6.QtCore import QPoint + + return QPoint(x, y) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/llamachat/backend.py b/llamachat/backend.py new file mode 100644 index 0000000..89cb044 --- /dev/null +++ b/llamachat/backend.py @@ -0,0 +1,237 @@ +# 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. +"""Talking to the llama.cpp router over its OpenAI-compatible API.""" + +import base64 +import hashlib +import io +import json +import mimetypes +from dataclasses import dataclass, field +from pathlib import Path + +import httpx +from PIL import Image + +THUMB_SIZE = (128, 128) + +# Extensions we are willing to read as text even though mimetypes does not +# call them text/*. +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", +} + +IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"} + +# Sentinel yielded by the SSE parser at the end of a stream. A distinct +# object rather than '' so an empty delta cannot be mistaken for the end. +DONE = ("done", "") + + +class BackendError(Exception): + """Any failure talking to the router, already phrased for the user.""" + + +@dataclass +class Attachment: + """A file the user attached, ready for both the API and the database.""" + path: Path + kind: str # 'text' | 'image' + mime: str + size: int + sha256: str + text: str = "" # kind == 'text': file content, possibly truncated + data_url: str = "" # kind == 'image': base64 data: URL for the API + thumb: bytes | None = None + truncated: bool = False + + +def classify(path: Path) -> str: + """Decide whether a path is a text or image attachment.""" + mime, _ = mimetypes.guess_type(path.name) + if mime in IMAGE_MIMES: + return "image" + if path.suffix in CODE_SUFFIXES: + return "text" + if mime and mime.startswith("text/"): + return "text" + return "unknown" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_attachment(path: Path, char_budget: int) -> Attachment: + """Read a file into an Attachment, truncating text past char_budget.""" + path = path.expanduser().resolve() + kind = classify(path) + if kind == "unknown": + raise BackendError(f"Unsupported file type: {path.name}") + + size = path.stat().st_size + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + att = Attachment( + path=path, kind=kind, mime=mime, size=size, sha256=_sha256(path) + ) + + if kind == "text": + raw = path.read_text(encoding="utf-8", errors="replace") + if len(raw) > char_budget: + att.text = raw[:char_budget] + att.truncated = True + else: + att.text = raw + return att + + raw = path.read_bytes() + att.data_url = f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}" + att.thumb = _thumbnail(path) + return att + + +def _thumbnail(path: Path) -> bytes | None: + """A small JPEG preview, so reopened chats still show what was sent.""" + try: + with Image.open(path) as img: + img = img.convert("RGB") + img.thumbnail(THUMB_SIZE) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=80) + return buf.getvalue() + except Exception: + # ponytail: a missing preview is cosmetic, never fail an attach over it + return None + + +def build_user_content(text: str, attachments: list[Attachment]): + """Assemble one OpenAI-format user message from text plus attachments. + + Returns a plain string when there is nothing but text, and the + multi-part content array when images are involved. + """ + parts: list[str] = [] + for att in attachments: + if att.kind != "text": + continue + note = " (truncated)" if att.truncated else "" + parts.append( + f"--- file: {att.path.name}{note} ---\n{att.text}\n--- end file ---" + ) + if text: + parts.append(text) + joined = "\n\n".join(parts) + + images = [a for a in attachments if a.kind == "image"] + if not images: + return joined + + content: list[dict] = [{"type": "text", "text": joined}] + for att in images: + content.append( + {"type": "image_url", "image_url": {"url": att.data_url}} + ) + return content + + +class Client: + """Minimal OpenAI-compatible client for the local router.""" + + def __init__(self, base_url: str, timeout: int = 300): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def models(self) -> list[str]: + """Model ids the router currently offers.""" + try: + resp = httpx.get(f"{self.base_url}/v1/models", timeout=10) + resp.raise_for_status() + payload = resp.json() + except httpx.HTTPError as exc: + raise BackendError(f"Cannot reach router at {self.base_url}: {exc}") + except ValueError as exc: + raise BackendError(f"Router sent invalid JSON: {exc}") + return [m["id"] for m in payload.get("data", []) if "id" in m] + + def stream_chat(self, model: str, messages: list[dict]): + """Yield (kind, text) pairs as the model produces them. + + `kind` is 'reasoning' for the model's thinking and 'content' for the + reply proper. Presets with a reasoning budget emit the former in a + separate `reasoning_content` delta field, which is what lets the UI + keep the two apart. + + Loading a different model makes the router unload the previous one, + so the first chunk can take several seconds. That wait happens + inside the initial `stream()` call. + """ + body = {"model": model, "messages": messages, "stream": True} + try: + with httpx.stream( + "POST", + f"{self.base_url}/v1/chat/completions", + json=body, + timeout=httpx.Timeout(self.timeout, connect=10), + ) as resp: + if resp.status_code != 200: + resp.read() + raise BackendError( + f"Router returned {resp.status_code}: {resp.text[:300]}" + ) + for line in resp.iter_lines(): + chunk = _parse_sse_line(line) + if chunk is None: + continue + if chunk == DONE: + return + yield chunk + except httpx.HTTPError as exc: + raise BackendError(f"Request failed: {exc}") + + +def _parse_sse_line(line: str) -> tuple[str, str] | None: + """Decode one SSE line. + + Returns a ('reasoning'|'content', text) pair, the DONE sentinel at the + end of the stream, or None for lines carrying nothing to render. + """ + if not line.startswith("data:"): + return None + payload = line[5:].strip() + if payload == "[DONE]": + return DONE + try: + obj = json.loads(payload) + except ValueError: + return None + choices = obj.get("choices") or [] + if not choices: + return None + delta = choices[0].get("delta") or {} + + reasoning = delta.get("reasoning_content") + if reasoning: + return ("reasoning", reasoning) + content = delta.get("content") + if content: + return ("content", content) + return None diff --git a/llamachat/config.py b/llamachat/config.py new file mode 100644 index 0000000..fac72d5 --- /dev/null +++ b/llamachat/config.py @@ -0,0 +1,167 @@ +# 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. +"""Config file loading and llama-server presets.ini parsing.""" + +import configparser +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path + +CONFIG_PATH = Path( + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") +) / "llamachat" / "config.toml" + +DEFAULTS = { + "base_url": "http://localhost:8181", + "presets": "/etc/llama-server/presets.ini", + "socket": "", # empty -> $XDG_RUNTIME_DIR/llamachat.sock + "db": "", # empty -> $XDG_DATA_HOME/llamachat/history.db + "default_model": "", + "request_timeout": 300, + # Fraction of a model's context we allow a single attachment to fill. + "attach_ctx_fraction": 0.5, + # Rough chars-per-token used to turn a ctx-size into a char budget. + "chars_per_token": 3.5, +} + + +@dataclass +class Preset: + """One [section] of presets.ini, reduced to what the UI needs.""" + name: str + vision: bool + ctx_size: int + + def char_budget(self, fraction: float, chars_per_token: float) -> int: + return int(self.ctx_size * chars_per_token * fraction) + + +@dataclass +class Config: + base_url: str + presets_path: Path + socket_path: Path + db_path: Path + default_model: str + request_timeout: int + attach_ctx_fraction: float + chars_per_token: float + + +def _runtime_dir() -> Path: + rd = os.environ.get("XDG_RUNTIME_DIR") + if rd: + return Path(rd) + # ponytail: /tmp fallback only matters on a broken session; no uid-dir + # creation dance, XDG_RUNTIME_DIR is set on any real Wayland login. + return Path("/tmp") + + +def _data_dir() -> Path: + return Path( + os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") + ) / "llamachat" + + +def load(path: Path = CONFIG_PATH) -> Config: + """Read config.toml, falling back to DEFAULTS for anything absent.""" + values = dict(DEFAULTS) + if path.exists(): + with open(path, "rb") as fh: + values.update(tomllib.load(fh)) + + socket = values["socket"] or (_runtime_dir() / "llamachat.sock") + db = values["db"] or (_data_dir() / "history.db") + + return Config( + base_url=str(values["base_url"]).rstrip("/"), + presets_path=Path(values["presets"]).expanduser(), + socket_path=Path(str(socket)).expanduser(), + db_path=Path(str(db)).expanduser(), + default_model=str(values["default_model"]), + request_timeout=int(values["request_timeout"]), + attach_ctx_fraction=float(values["attach_ctx_fraction"]), + chars_per_token=float(values["chars_per_token"]), + ) + + +def write_default(path: Path = CONFIG_PATH) -> Path: + """Create a commented config.toml if none exists. Returns the path.""" + if path.exists(): + return path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "# llamachat configuration\n" + f'base_url = "{DEFAULTS["base_url"]}"\n' + f'presets = "{DEFAULTS["presets"]}"\n' + '\n' + '# Leave empty for XDG defaults:\n' + '# socket -> $XDG_RUNTIME_DIR/llamachat.sock\n' + '# db -> $XDG_DATA_HOME/llamachat/history.db\n' + 'socket = ""\n' + 'db = ""\n' + '\n' + '# Model selected at startup. Empty picks the first available.\n' + 'default_model = ""\n' + '\n' + '# Seconds before a generation request is abandoned.\n' + f'request_timeout = {DEFAULTS["request_timeout"]}\n' + '\n' + '# How much of the model context one attachment may fill, and the\n' + '# chars-per-token estimate used to convert ctx-size into characters.\n' + f'attach_ctx_fraction = {DEFAULTS["attach_ctx_fraction"]}\n' + f'chars_per_token = {DEFAULTS["chars_per_token"]}\n' + ) + return path + + +def parse_presets(path: Path) -> dict[str, Preset]: + """Parse llama-server's presets.ini. + + Both ';' and '#' start a comment there, so a commented-out `#mmproj` + line correctly reads as "this preset has no vision support". + """ + parser = configparser.ConfigParser( + comment_prefixes=(";", "#"), + inline_comment_prefixes=(";", "#"), + strict=False, + ) + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + + # presets.ini opens with a bare `version = 1` before any section, which + # configparser rejects. Give those leading keys a section to live in. + try: + parser.read_string("[__file__]\n" + text) + except configparser.Error: + return {} + + presets: dict[str, Preset] = {} + for name in parser.sections(): + if name == "__file__": + continue + section = parser[name] + try: + ctx = int(section.get("ctx-size", "4096")) + except ValueError: + ctx = 4096 + presets[name] = Preset( + name=name, + vision=bool(section.get("mmproj", "").strip()), + ctx_size=ctx, + ) + return presets diff --git a/llamachat/db.py b/llamachat/db.py new file mode 100644 index 0000000..d746a0e --- /dev/null +++ b/llamachat/db.py @@ -0,0 +1,254 @@ +# 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. +"""SQLite history store with FTS5 search.""" + +import sqlite3 +import time +from pathlib import Path + +SCHEMA = """ +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + title TEXT, + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + reasoning TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id); +CREATE INDEX IF NOT EXISTS idx_sessions_updated + ON sessions(updated_at DESC, id DESC); + +CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + path TEXT NOT NULL, + kind TEXT NOT NULL, + mime TEXT, + size INTEGER, + sha256 TEXT, + thumb BLOB, + truncated INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content='messages', + content_rowid='id', + tokenize='unicode61' +); + +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES ('delete', old.id, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES ('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +""" + + +def _now() -> int: + return int(time.time()) + + +class History: + """Session/message/attachment storage. One connection, one thread.""" + + def __init__(self, path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(path) + self.conn.row_factory = sqlite3.Row + self.conn.executescript(SCHEMA) + self._migrate() + self.conn.commit() + + def _migrate(self) -> None: + """Add columns introduced after a database was first created.""" + columns = { + row["name"] + for row in self.conn.execute("PRAGMA table_info(messages)") + } + if "reasoning" not in columns: + self.conn.execute( + "ALTER TABLE messages ADD COLUMN reasoning TEXT NOT NULL" + " DEFAULT ''" + ) + + def close(self) -> None: + self.conn.close() + + # -- sessions --------------------------------------------------------- + + def create_session(self, mode: str, model: str, title: str = "") -> int: + now = _now() + cur = self.conn.execute( + "INSERT INTO sessions (mode, title, model, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?)", + (mode, title, model, now, now), + ) + self.conn.commit() + return int(cur.lastrowid) + + def touch_session(self, session_id: int, model: str | None = None) -> None: + if model: + self.conn.execute( + "UPDATE sessions SET updated_at = ?, model = ? WHERE id = ?", + (_now(), model, session_id), + ) + else: + self.conn.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (_now(), session_id), + ) + self.conn.commit() + + def set_title(self, session_id: int, title: str) -> None: + self.conn.execute( + "UPDATE sessions SET title = ? WHERE id = ?", (title, session_id) + ) + self.conn.commit() + + def get_session(self, session_id: int) -> sqlite3.Row | None: + return self.conn.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + + def recent_sessions(self, limit: int = 200) -> list[sqlite3.Row]: + # id breaks ties: several sessions can share a one-second timestamp. + return self.conn.execute( + "SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT ?", + (limit,), + ).fetchall() + + def delete_session(self, session_id: int) -> None: + self.conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + self.conn.commit() + + # -- messages --------------------------------------------------------- + + def add_message(self, session_id: int, role: str, content: str) -> int: + cur = self.conn.execute( + "INSERT INTO messages (session_id, role, content, created_at)" + " VALUES (?, ?, ?, ?)", + (session_id, role, content, _now()), + ) + self.conn.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (_now(), session_id), + ) + self.conn.commit() + return int(cur.lastrowid) + + def update_message( + self, message_id: int, content: str, reasoning: str | None = None + ) -> None: + if reasoning is None: + self.conn.execute( + "UPDATE messages SET content = ? WHERE id = ?", + (content, message_id), + ) + else: + self.conn.execute( + "UPDATE messages SET content = ?, reasoning = ? WHERE id = ?", + (content, reasoning, message_id), + ) + self.conn.commit() + + def messages(self, session_id: int) -> list[sqlite3.Row]: + return self.conn.execute( + "SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,) + ).fetchall() + + # -- attachments ------------------------------------------------------ + + def add_attachment( + self, + message_id: int, + path: str, + kind: str, + mime: str = "", + size: int = 0, + sha256: str = "", + thumb: bytes | None = None, + truncated: bool = False, + ) -> int: + cur = self.conn.execute( + "INSERT INTO attachments" + " (message_id, path, kind, mime, size, sha256, thumb, truncated)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (message_id, path, kind, mime, size, sha256, thumb, int(truncated)), + ) + self.conn.commit() + return int(cur.lastrowid) + + def attachments(self, message_id: int) -> list[sqlite3.Row]: + return self.conn.execute( + "SELECT * FROM attachments WHERE message_id = ? ORDER BY id", + (message_id,), + ).fetchall() + + # -- search ----------------------------------------------------------- + + def search(self, query: str, limit: int = 100) -> list[sqlite3.Row]: + """Full-text search over message content. + + Returns one row per matching message, newest first, carrying the + owning session so the UI can offer to reopen it. + """ + if not query.strip(): + return [] + return self.conn.execute( + "SELECT m.id AS message_id, m.session_id, m.role, m.created_at," + " s.title, s.mode, s.model," + " snippet(messages_fts, 0, '<b>', '</b>', '…', 16) AS snip" + " FROM messages_fts" + " JOIN messages m ON m.id = messages_fts.rowid" + " JOIN sessions s ON s.id = m.session_id" + " WHERE messages_fts MATCH ?" + " ORDER BY m.created_at DESC, m.id DESC LIMIT ?", + (_fts_query(query), limit), + ).fetchall() + + +def _fts_query(text: str) -> str: + """Turn user input into a safe FTS5 query. + + Every token is quoted, so punctuation that FTS5 would read as syntax + (a bare '-', 'AND', a stray quote) cannot raise OperationalError. + """ + tokens = [t.replace('"', '""') for t in text.split() if t] + return " ".join(f'"{t}"' for t in tokens) diff --git a/llamachat/ipc.py b/llamachat/ipc.py new file mode 100644 index 0000000..99b1f25 --- /dev/null +++ b/llamachat/ipc.py @@ -0,0 +1,109 @@ +# 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. +"""Unix-socket control channel. + +Protocol: one JSON object per line, one response line per request. + + -> {"cmd": "toggle"} <- {"ok": true, "visible": false} + -> {"cmd": "show"} <- {"ok": true, "visible": true} + -> {"cmd": "hide"} <- {"ok": true, "visible": false} + -> {"cmd": "ping"} <- {"ok": true} + -> {"cmd": "quit"} <- {"ok": true} + +Unknown commands answer {"ok": false, "error": "..."}. + +This module imports nothing from Qt, so the client path stays fast enough +to sit behind a keybind. +""" + +import json +import socket +from pathlib import Path + +COMMANDS = ("toggle", "show", "hide", "ping", "quit") + + +def send(socket_path: Path, cmd: str, timeout: float = 2.0) -> dict: + """Send one command to a running instance and return its reply.""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(str(socket_path)) + sock.sendall((json.dumps({"cmd": cmd}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + finally: + sock.close() + if not buf: + return {"ok": False, "error": "no reply"} + try: + return json.loads(buf.decode().strip()) + except ValueError as exc: + return {"ok": False, "error": f"bad reply: {exc}"} + + +def is_running(socket_path: Path) -> bool: + """True when a live instance answers on the socket.""" + if not socket_path.exists(): + return False + try: + return bool(send(socket_path, "ping").get("ok")) + except OSError: + return False + + +def clear_stale(socket_path: Path) -> None: + """Remove a socket file left behind by a crashed instance.""" + if socket_path.exists() and not is_running(socket_path): + socket_path.unlink(missing_ok=True) + + +def create_server(socket_path: Path) -> socket.socket: + """Bind and listen. Caller must have cleared any stale socket first.""" + socket_path.parent.mkdir(parents=True, exist_ok=True) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(str(socket_path)) + socket_path.chmod(0o600) + srv.listen(4) + srv.setblocking(False) + return srv + + +def read_request(conn: socket.socket) -> str: + """Read one command name from a connection. '' when unreadable.""" + conn.settimeout(2.0) + buf = b"" + try: + while not buf.endswith(b"\n") and len(buf) < 4096: + chunk = conn.recv(4096) + if not chunk: + break + buf += chunk + except OSError: + return "" + try: + return str(json.loads(buf.decode().strip()).get("cmd", "")) + except ValueError: + return "" + + +def write_reply(conn: socket.socket, payload: dict) -> None: + try: + conn.sendall((json.dumps(payload) + "\n").encode()) + except OSError: + pass diff --git a/llamachat/ui.py b/llamachat/ui.py new file mode 100644 index 0000000..d8ec10e --- /dev/null +++ b/llamachat/ui.py @@ -0,0 +1,902 @@ +# 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. +"""The chat window.""" + +import html +import re +from pathlib import Path + +from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot +from PySide6.QtGui import ( + QAction, QDesktopServices, QKeySequence, QTextDocument, +) +from PySide6.QtWidgets import ( + QButtonGroup, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, + QPlainTextEdit, QPushButton, QRadioButton, QSplitter, QTextBrowser, + QVBoxLayout, QWidget, +) + +from . import backend +from .backend import Attachment, BackendError + +MODE_ONESHOT = "oneshot" +MODE_CHAT = "chat" + +# URL scheme for the reasoning toggle. A reply is untrusted text, so links +# it produces are stripped rather than trusted to not collide with this. +REASONING_SCHEME = "x-llamachat-reasoning:" + + +class StreamWorker(QObject): + """Runs one streaming request off the GUI thread.""" + + chunk = Signal(str) + reasoning = Signal(str) + finished = Signal() + failed = Signal(str) + + def __init__(self, client, model: str, messages: list[dict]): + super().__init__() + self.client = client + self.model = model + self.messages = messages + self._stop = False + + def stop(self) -> None: + self._stop = True + + @Slot() + def run(self) -> None: + try: + for kind, piece in self.client.stream_chat(self.model, self.messages): + if self._stop: + break + if kind == "reasoning": + self.reasoning.emit(piece) + else: + self.chunk.emit(piece) + except BackendError as exc: + self.failed.emit(str(exc)) + return + except Exception as exc: # noqa: BLE001 - surface anything as UI text + self.failed.emit(f"Unexpected error: {exc}") + return + self.finished.emit() + + +class ChatWindow(QMainWindow): + """Model picker, mode toggle, transcript, input, history panel.""" + + def __init__(self, cfg, history, client, presets): + super().__init__() + self.cfg = cfg + self.history = history + self.client = client + self.presets = presets + + self.session_id: int | None = None + self.mode = MODE_ONESHOT + self.read_only = False + self.attachments: list[Attachment] = [] + self.thread: QThread | None = None + self.worker: StreamWorker | None = None + self.assistant_message_id: int | None = None + self.assistant_buffer = "" + self.reasoning_buffer = "" + # Every rendered bubble, so a reasoning toggle can redraw the + # transcript without refetching anything. + self.bubbles: list[dict] = [] + self.expanded: set[int] = set() + + self.setWindowTitle("llamachat") + self.resize(1000, 700) + self.setAcceptDrops(True) + self._build_ui() + self.refresh_models() + self.refresh_history() + + # -- construction ----------------------------------------------------- + + def _build_ui(self) -> None: + central = QWidget() + outer = QVBoxLayout(central) + + # Top bar: model, mode, search. + top = QHBoxLayout() + self.model_box = QComboBox() + self.model_box.setMinimumWidth(220) + self.model_box.currentTextChanged.connect(self._on_model_changed) + top.addWidget(QLabel("Model:")) + top.addWidget(self.model_box) + + self.reload_button = QPushButton("⟳") + self.reload_button.setToolTip("Reload model list from the router") + self.reload_button.setFixedWidth(32) + self.reload_button.clicked.connect(self.refresh_models) + top.addWidget(self.reload_button) + + top.addSpacing(16) + self.oneshot_radio = QRadioButton("One-shot") + self.chat_radio = QRadioButton("Chat") + self.oneshot_radio.setChecked(True) + group = QButtonGroup(self) + group.addButton(self.oneshot_radio) + group.addButton(self.chat_radio) + self.oneshot_radio.toggled.connect(self._on_mode_changed) + top.addWidget(self.oneshot_radio) + top.addWidget(self.chat_radio) + + top.addStretch() + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Search history…") + self.search_box.setClearButtonEnabled(True) + self.search_box.setMaximumWidth(280) + self.search_box.textChanged.connect(self._on_search) + top.addWidget(self.search_box) + outer.addLayout(top) + + # Body: history list beside the transcript and input. + splitter = QSplitter(Qt.Horizontal) + + self.history_list = QListWidget() + self.history_list.setMinimumWidth(200) + self.history_list.itemActivated.connect(self._on_history_activated) + self.history_list.itemClicked.connect(self._on_history_activated) + self.history_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.history_list.customContextMenuRequested.connect( + self._on_history_menu + ) + splitter.addWidget(self.history_list) + + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + + self.transcript = QTextBrowser() + # Links are handled here, not followed: reasoning:N toggles a + # thinking block, anything else opens in the browser. + self.transcript.setOpenLinks(False) + self.transcript.anchorClicked.connect(self._on_anchor_clicked) + right_layout.addWidget(self.transcript, 1) + + self.attach_label = QLabel() + self.attach_label.setWordWrap(True) + self.attach_label.hide() + right_layout.addWidget(self.attach_label) + + self.status_label = QLabel() + self.status_label.hide() + right_layout.addWidget(self.status_label) + + self.input = QPlainTextEdit() + self.input.setPlaceholderText( + "Type a message. Ctrl+Enter sends, Esc hides the window." + ) + self.input.setMaximumHeight(140) + right_layout.addWidget(self.input) + + buttons = QHBoxLayout() + self.new_button = QPushButton("New") + self.new_button.clicked.connect(self.new_session) + buttons.addWidget(self.new_button) + + self.attach_button = QPushButton("Attach…") + self.attach_button.clicked.connect(self._on_attach_clicked) + buttons.addWidget(self.attach_button) + + self.clear_attach_button = QPushButton("Clear files") + self.clear_attach_button.clicked.connect(self.clear_attachments) + self.clear_attach_button.hide() + buttons.addWidget(self.clear_attach_button) + + buttons.addStretch() + self.stop_button = QPushButton("Stop") + self.stop_button.clicked.connect(self.stop_stream) + self.stop_button.hide() + buttons.addWidget(self.stop_button) + + self.send_button = QPushButton("Send") + self.send_button.setDefault(True) + self.send_button.clicked.connect(self.send) + buttons.addWidget(self.send_button) + right_layout.addLayout(buttons) + + splitter.addWidget(right) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([240, 760]) + outer.addWidget(splitter, 1) + + self.setCentralWidget(central) + + send_action = QAction(self) + send_action.setShortcut(QKeySequence("Ctrl+Return")) + send_action.triggered.connect(self.send) + self.addAction(send_action) + + send_enter = QAction(self) + send_enter.setShortcut(QKeySequence("Ctrl+Enter")) + send_enter.triggered.connect(self.send) + self.addAction(send_enter) + + # -- model handling --------------------------------------------------- + + def refresh_models(self) -> None: + """Repopulate the picker from the router, keeping the selection.""" + previous = self.model_box.currentText() + try: + available = self.client.models() + except BackendError as exc: + self.show_status(str(exc), error=True) + return + + self.model_box.blockSignals(True) + self.model_box.clear() + for name in available: + preset = self.presets.get(name) + label = f"{name} 👁" if preset and preset.vision else name + self.model_box.addItem(label, name) + self.model_box.blockSignals(False) + + target = previous or self.cfg.default_model + if target: + index = self.model_box.findData(_strip_marker(target)) + if index < 0: + index = self.model_box.findText(target) + if index >= 0: + self.model_box.setCurrentIndex(index) + if available: + self.hide_status() + + def current_model(self) -> str: + return self.model_box.currentData() or self.model_box.currentText() + + def current_preset(self): + return self.presets.get(self.current_model()) + + def char_budget(self) -> int: + preset = self.current_preset() + if preset is None: + return int(4096 * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction) + return preset.char_budget( + self.cfg.attach_ctx_fraction, self.cfg.chars_per_token + ) + + def vision_models(self) -> list[str]: + names = [] + for i in range(self.model_box.count()): + name = self.model_box.itemData(i) + preset = self.presets.get(name) + if preset and preset.vision: + names.append(name) + return names + + def _on_model_changed(self, _text: str) -> None: + if self.session_id is not None and not self.read_only: + self.history.touch_session(self.session_id, self.current_model()) + + # -- mode handling ---------------------------------------------------- + + def _on_mode_changed(self, _checked: bool) -> None: + new_mode = MODE_ONESHOT if self.oneshot_radio.isChecked() else MODE_CHAT + if new_mode == self.mode: + return + self.mode = new_mode + self.new_session() + + def new_session(self) -> None: + """Drop the current conversation and start clean.""" + self.session_id = None + self.read_only = False + self.assistant_message_id = None + self.assistant_buffer = "" + self.reasoning_buffer = "" + self.bubbles.clear() + self.expanded.clear() + self.clear_attachments() + self.transcript.clear() + self.input.clear() + self.input.setReadOnly(False) + self.send_button.setEnabled(True) + self.history_list.clearSelection() + self.hide_status() + + # -- attachments ------------------------------------------------------ + + def _on_attach_clicked(self) -> None: + paths, _ = QFileDialog.getOpenFileNames(self, "Attach files") + if paths: + self.add_attachments([Path(p) for p in paths]) + + def add_attachments(self, paths: list[Path]) -> None: + """Load files, refusing images unless the model can see them.""" + wants_vision = any(backend.classify(p) == "image" for p in paths) + if wants_vision and not self._ensure_vision_model(): + return + + for path in paths: + try: + att = backend.load_attachment(path, self.char_budget()) + except (BackendError, OSError) as exc: + self.show_status(f"{path.name}: {exc}", error=True) + continue + self.attachments.append(att) + if att.truncated: + self.show_status( + f"{path.name} was truncated to fit the model context " + f"({self.char_budget()} characters).", + error=True, + ) + self.update_attach_label() + + def _ensure_vision_model(self) -> bool: + """Offer to switch to a vision preset. True when one is active.""" + preset = self.current_preset() + if preset and preset.vision: + return True + + candidates = self.vision_models() + if not candidates: + QMessageBox.warning( + self, + "No vision model", + "No model on the router has an mmproj file configured, so " + "images cannot be sent.", + ) + return False + + target = candidates[0] + answer = QMessageBox.question( + self, + "Switch model?", + f"Images need a vision-capable model.\n\n" + f"Switch to {target}?\n\n" + "The router unloads the current model to do this, so the next " + "reply will take several seconds to start.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if answer != QMessageBox.Yes: + return False + + index = self.model_box.findData(target) + if index >= 0: + self.model_box.setCurrentIndex(index) + return True + + def clear_attachments(self) -> None: + self.attachments.clear() + self.update_attach_label() + + def update_attach_label(self) -> None: + if not self.attachments: + self.attach_label.hide() + self.clear_attach_button.hide() + return + names = [] + for att in self.attachments: + mark = "🖼" if att.kind == "image" else "📄" + suffix = " (truncated)" if att.truncated else "" + names.append(f"{mark} {att.path.name}{suffix}") + self.attach_label.setText("Attached: " + ", ".join(names)) + self.attach_label.show() + self.clear_attach_button.show() + + # -- drag and drop ---------------------------------------------------- + + def dragEnterEvent(self, event) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dragMoveEvent(self, event) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event) -> None: + paths = [ + Path(url.toLocalFile()) + for url in event.mimeData().urls() + if url.isLocalFile() + ] + if paths: + self.add_attachments(paths) + event.acceptProposedAction() + + # -- sending ---------------------------------------------------------- + + def send(self) -> None: + if self.thread is not None: + return + if self.read_only: + self.show_status( + "One-shot entries are read-only. Press New to start again.", + error=True, + ) + return + + text = self.input.toPlainText().strip() + if not text and not self.attachments: + return + model = self.current_model() + if not model: + self.show_status("No model selected.", error=True) + return + + if self.session_id is None: + title = (text or self.attachments[0].path.name)[:60] + self.session_id = self.history.create_session( + self.mode, model, title + ) + + content = backend.build_user_content(text, self.attachments) + stored = content if isinstance(content, str) else content[0]["text"] + message_id = self.history.add_message(self.session_id, "user", stored) + for att in self.attachments: + self.history.add_attachment( + message_id, + path=str(att.path), + kind=att.kind, + mime=att.mime, + size=att.size, + sha256=att.sha256, + thumb=att.thumb, + truncated=att.truncated, + ) + + self.append_bubble("user", stored, self.attachments) + self.input.clear() + + if self.mode == MODE_CHAT: + messages = self._chat_context(content) + else: + messages = [{"role": "user", "content": content}] + + self.clear_attachments() + self.refresh_history() + self._start_stream(model, messages) + + def _chat_context(self, latest_content) -> list[dict]: + """Prior turns plus the new message, for multi-turn mode.""" + messages = [] + rows = self.history.messages(self.session_id) + for row in rows[:-1]: # the latest user row is replaced below + messages.append({"role": row["role"], "content": row["content"]}) + messages.append({"role": "user", "content": latest_content}) + return messages + + def _start_stream(self, model: str, messages: list[dict]) -> None: + self.assistant_buffer = "" + self.reasoning_buffer = "" + self.assistant_message_id = self.history.add_message( + self.session_id, "assistant", "" + ) + self.append_bubble("assistant", "") + self.show_status(f"Waiting for {model}… (a model switch takes a while)") + self.send_button.setEnabled(False) + self.stop_button.show() + + self.thread = QThread(self) + self.worker = StreamWorker(self.client, model, messages) + self.worker.moveToThread(self.thread) + self.thread.started.connect(self.worker.run) + self.worker.chunk.connect(self._on_chunk) + self.worker.reasoning.connect(self._on_reasoning) + self.worker.finished.connect(self._on_stream_finished) + self.worker.failed.connect(self._on_stream_failed) + self.thread.start() + + @Slot(str) + def _on_chunk(self, piece: str) -> None: + if not self.assistant_buffer: + self.hide_status() + self.assistant_buffer += piece + self._update_last_bubble(text=self.assistant_buffer) + + @Slot(str) + def _on_reasoning(self, piece: str) -> None: + if not self.reasoning_buffer: + self.hide_status() + self.reasoning_buffer += piece + self._update_last_bubble(reasoning=self.reasoning_buffer) + + @Slot() + def _on_stream_finished(self) -> None: + if self.assistant_message_id is not None: + self.history.update_message( + self.assistant_message_id, + self.assistant_buffer, + self.reasoning_buffer, + ) + self._teardown_stream() + self.refresh_history() + + @Slot(str) + def _on_stream_failed(self, message: str) -> None: + if self.assistant_message_id is not None: + self.history.update_message( + self.assistant_message_id, + self.assistant_buffer, + self.reasoning_buffer, + ) + self.show_status(message, error=True) + self._teardown_stream() + + def stop_stream(self) -> None: + if self.worker is not None: + self.worker.stop() + + def _teardown_stream(self) -> None: + if self.thread is not None: + self.thread.quit() + self.thread.wait(3000) + self.thread = None + self.worker = None + self.assistant_message_id = None + self.send_button.setEnabled(True) + self.stop_button.hide() + + # -- transcript rendering --------------------------------------------- + + def append_bubble( + self, + role: str, + text: str, + attachments: list[Attachment] | None = None, + saved_rows=None, + reasoning: str = "", + ) -> None: + self.bubbles.append( + { + "role": role, + "text": text, + "attachments": attachments, + "saved_rows": saved_rows, + "reasoning": reasoning, + } + ) + self._render() + + def _update_last_bubble( + self, text: str | None = None, reasoning: str | None = None + ) -> None: + """Rewrite the streaming assistant bubble.""" + if not self.bubbles: + return + if text is not None: + self.bubbles[-1]["text"] = text + if reasoning is not None: + self.bubbles[-1]["reasoning"] = reasoning + self._render(live=True) + + def _render(self, live: bool = False) -> None: + """Redraw the whole transcript. + + Rewriting everything keeps the collapse state and the streaming + update on one code path. Conversations are short enough that the + cost does not show. + """ + bar = self.transcript.verticalScrollBar() + at_end = bar.value() >= bar.maximum() - 4 + previous = bar.value() + + parts = [] + for i, bubble in enumerate(self.bubbles): + streaming = live and i == len(self.bubbles) - 1 + parts.append( + _bubble_html( + bubble["role"], + bubble["text"], + bubble["attachments"], + bubble["saved_rows"], + reasoning=bubble["reasoning"], + index=i, + expanded=i in self.expanded, + live=streaming and not bubble["text"], + ) + ) + self.transcript.setHtml("".join(parts)) + + if at_end: + self._scroll_to_end() + else: + bar.setValue(previous) + + def _on_anchor_clicked(self, url) -> None: + """Expand or collapse a thinking block.""" + target = url.toString() + if target.startswith("blocked:"): + return # a link a reply tried to forge + if not target.startswith(REASONING_SCHEME): + if url.scheme() in ("http", "https", "mailto"): + QDesktopServices.openUrl(url) + return + try: + index = int(target[len(REASONING_SCHEME) :]) + except ValueError: + return + if index in self.expanded: + self.expanded.discard(index) + else: + self.expanded.add(index) + self._render(live=self.thread is not None) + + def _scroll_to_end(self) -> None: + bar = self.transcript.verticalScrollBar() + bar.setValue(bar.maximum()) + + # -- history panel ---------------------------------------------------- + + def refresh_history(self) -> None: + if self.search_box.text().strip(): + return # search results are showing; leave them alone + self.history_list.clear() + for row in self.history.recent_sessions(): + marker = "💬" if row["mode"] == MODE_CHAT else "⚡" + item = QListWidgetItem(f"{marker} {row['title'] or '(untitled)'}") + item.setData(Qt.UserRole, row["id"]) + item.setToolTip(f"{row['model'] or '?'} — {row['mode']}") + self.history_list.addItem(item) + + def _on_search(self, text: str) -> None: + if not text.strip(): + self.refresh_history() + return + self.history_list.clear() + for row in self.history.search(text): + item = QListWidgetItem( + f"{row['title'] or '(untitled)'}\n {_plain(row['snip'])}" + ) + item.setData(Qt.UserRole, row["session_id"]) + self.history_list.addItem(item) + + def _on_history_activated(self, item: QListWidgetItem) -> None: + session_id = item.data(Qt.UserRole) + if session_id is not None: + self.open_session(int(session_id)) + + def _on_history_menu(self, point) -> None: + item = self.history_list.itemAt(point) + if item is None: + return + menu = QMenu(self) + delete = menu.addAction("Delete") + if menu.exec(self.history_list.mapToGlobal(point)) == delete: + self.history.delete_session(int(item.data(Qt.UserRole))) + if self.session_id == int(item.data(Qt.UserRole)): + self.new_session() + self.refresh_history() + + def open_session(self, session_id: int) -> None: + """Load a past conversation. Chat mode continues, one-shot is read-only.""" + session = self.history.get_session(session_id) + if session is None: + return + + self.session_id = session_id + self.mode = session["mode"] + self.read_only = self.mode == MODE_ONESHOT + self.clear_attachments() + + self.oneshot_radio.blockSignals(True) + self.chat_radio.blockSignals(True) + self.oneshot_radio.setChecked(self.mode == MODE_ONESHOT) + self.chat_radio.setChecked(self.mode == MODE_CHAT) + self.oneshot_radio.blockSignals(False) + self.chat_radio.blockSignals(False) + + if session["model"]: + index = self.model_box.findData(session["model"]) + if index >= 0: + self.model_box.setCurrentIndex(index) + + self.bubbles.clear() + self.expanded.clear() + for row in self.history.messages(session_id): + self.bubbles.append( + { + "role": row["role"], + "text": row["content"], + "attachments": None, + "saved_rows": self.history.attachments(row["id"]), + "reasoning": _column(row, "reasoning"), + } + ) + self._render() + self._scroll_to_end() + + self.input.setReadOnly(self.read_only) + self.send_button.setEnabled(not self.read_only) + if self.read_only: + self.show_status( + "One-shot entry, read-only. Press New to start a fresh chat." + ) + else: + self.hide_status() + + # -- status line ------------------------------------------------------ + + def show_status(self, text: str, error: bool = False) -> None: + self.status_label.setText(text) + self.status_label.setStyleSheet( + "color: palette(bright-text); background: palette(highlight); " + "padding: 4px; border-radius: 3px;" + if error + else "padding: 4px;" + ) + self.status_label.show() + + def hide_status(self) -> None: + self.status_label.hide() + + # -- window behaviour ------------------------------------------------- + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Escape: + self.hide() + return + super().keyPressEvent(event) + + def closeEvent(self, event) -> None: + """Closing hides; the daemon keeps running for the next toggle.""" + event.ignore() + self.hide() + + +# GitHub dialect for tables and strikethrough; NoHTML so markup in a reply +# is shown literally instead of being interpreted. +MARKDOWN_FLAGS = ( + QTextDocument.MarkdownDialectGitHub | QTextDocument.MarkdownNoHTML +) + +# Qt renders fenced code as one <pre> per line with no background of its +# own. Giving those a tint is what makes a code block read as a block. +_PRE_STYLE = ( + "background:rgba(127,127,127,0.18);padding:1px 6px;margin:0;" + "font-family:monospace" +) + + +def _markdown_to_fragment(text: str) -> str: + """Render markdown to an HTML fragment safe to splice into the page. + + QTextDocument does the parsing, so there is no markdown dependency. Its + output is a whole document, and only the body content is wanted here. + """ + if not text: + return "" + doc = QTextDocument() + doc.setMarkdown(text, MARKDOWN_FLAGS) + html_text = doc.toHtml() + + start = html_text.find("<body") + if start == -1: + return html.escape(text).replace("\n", "<br>") + start = html_text.find(">", start) + end = html_text.rfind("</body>") + if start == -1 or end == -1: + return html.escape(text).replace("\n", "<br>") + fragment = html_text[start + 1 : end].strip() + + # A reply could contain [x](x-llamachat-reasoning:0), which markdown + # turns into a real anchor. Defuse those so only the toggles this code + # emits can drive the UI. + fragment = fragment.replace(f'href="{REASONING_SCHEME}', 'href="blocked:') + + # Tint code blocks, which Qt leaves unstyled. Qt emits one <pre> per + # line, so the tint has to land on every one of them to read as a block. + return _PRE_RE.sub(_tint_pre, fragment) + + +_PRE_RE = re.compile(r"<pre(\s+style=\"([^\"]*)\")?\s*>") + + +def _tint_pre(match: re.Match) -> str: + existing = (match.group(2) or "").strip() + if existing and not existing.endswith(";"): + existing += ";" + return f'<pre style="{existing}{_PRE_STYLE}">' + + +def _column(row, name: str, default: str = "") -> str: + """Read a column that may predate a schema migration.""" + try: + value = row[name] + except (IndexError, KeyError): + return default + return value if value is not None else default + + +def _strip_marker(name: str) -> str: + return name.replace(" 👁", "").strip() + + +def _plain(text: str) -> str: + """Flatten an FTS snippet into one line of plain text.""" + return ( + text.replace("<b>", "").replace("</b>", "").replace("\n", " ")[:80] + ) + + +def _reasoning_html(text: str, index: int, expanded: bool, live: bool) -> str: + """The thinking block: a clickable summary line, body only when open. + + `index` identifies which bubble the toggle link belongs to, so several + replies in one transcript expand independently. + """ + if not text: + return "" + lines = text.count("\n") + 1 + arrow = "▾" if expanded else "▸" + label = "thinking…" if live else f"thinking ({len(text)} chars, {lines} lines)" + header = ( + f'<a href="{REASONING_SCHEME}{index}" style="color:palette(mid);' + f'text-decoration:none">{arrow} {label}</a>' + ) + if not expanded: + return f'<div style="margin:2px 0">{header}</div>' + body = html.escape(text).replace("\n", "<br>") + return ( + f'<div style="margin:2px 0">{header}</div>' + f'<div style="margin:2px 0 6px 14px;color:palette(mid);' + f'font-style:italic">{body}</div>' + ) + + +def _bubble_html( + role: str, + text: str, + attachments: list[Attachment] | None = None, + saved_rows=None, + reasoning: str = "", + index: int = 0, + expanded: bool = False, + live: bool = False, +) -> str: + """One transcript entry as HTML.""" + label = {"user": "You", "assistant": "Model"}.get(role, role) + colour = "palette(highlight)" if role == "user" else "palette(mid)" + + # Replies are markdown; what the user typed is shown as typed, so an + # attached file's contents cannot be reflowed into headings and lists. + if role == "assistant": + body = _markdown_to_fragment(text) + else: + body = html.escape(text).replace("\n", "<br>") + + files = "" + if attachments: + names = ", ".join(html.escape(a.path.name) for a in attachments) + files = f"<br><i>files: {names}</i>" + elif saved_rows: + parts = [] + for row in saved_rows: + missing = "" if Path(row["path"]).exists() else " (missing)" + parts.append(html.escape(row["path"]) + missing) + files = f"<br><i>files: {', '.join(parts)}</i>" + + think = _reasoning_html(reasoning, index, expanded, live) + + if role == "assistant": + # Markdown produces block elements, so the speaker label sits on its + # own line rather than trying to lead the first paragraph. + return ( + f'<div style="margin:6px 0">{think}' + f'<div style="color:{colour}"><b>{label}:</b></div>' + f"{body}{files}</div>" + ) + return ( + f'<div style="margin:6px 0">{think}' + f'<b style="color:{colour}">{label}:</b> {body}{files}</div>' + ) 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:]) 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. <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. +"""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 <think> 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 "<ul" in fragment + assert "<table" in fragment + assert "<pre" in fragment + # Every <pre> is tinted, since Qt emits one per line of a code block. + assert fragment.count("<pre") == fragment.count("background:rgba") + # The document wrapper must not leak into the fragment. + assert "<html" not in fragment and "<body" not in fragment + + # Markup in a reply is shown, never interpreted. + hostile = _markdown_to_fragment( + 'Text <b>tag</b> <img src=x onerror=alert(1)> <a href="http://bad">l</a>' + ) + 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 "<a href" not in hostile + assert "<a href="http://bad">" in hostile, hostile + + # A markdown link is a real anchor, so check a reply cannot forge one + # aimed at the reasoning toggle. + from llamachat.ui import REASONING_SCHEME + + forged = _markdown_to_fragment(f"[click]({REASONING_SCHEME}0)") + assert f'href="{REASONING_SCHEME}' not in forged, forged + assert 'href="blocked:' in forged, forged + + # Ordinary markdown links still work. + link = _markdown_to_fragment("[site](http://example.com)") + assert "http://example.com" in link + + # Empty input renders nothing rather than an empty document wrapper. + assert _markdown_to_fragment("") == "" + + # Partial markdown arrives constantly while streaming and must not raise. + for partial in ("```", "```py", "```py\ndef f(", "| a |", "**unclosed", "#"): + _markdown_to_fragment(partial) + print("ok markdown rendering") + + +def test_system_qt_theme_guard(): + """The plugin path is only borrowed when the Qt versions agree.""" + import os + + from llamachat.__main__ import _system_qt_version, _use_system_qt_theme + + # An explicit user setting is never overridden. + saved = os.environ.get("QT_PLUGIN_PATH") + try: + os.environ["QT_PLUGIN_PATH"] = "/user/choice" + _use_system_qt_theme() + assert os.environ["QT_PLUGIN_PATH"] == "/user/choice" + finally: + if saved is None: + os.environ.pop("QT_PLUGIN_PATH", None) + else: + os.environ["QT_PLUGIN_PATH"] = saved + + system = _system_qt_version() + if not system: + print("skip qt theme guard (no system Qt6 found)") + return + assert system.count(".") == 2, system + + from PySide6.QtCore import qVersion + + saved = os.environ.pop("QT_PLUGIN_PATH", None) + try: + _use_system_qt_theme() + chosen = os.environ.get("QT_PLUGIN_PATH") + if system == qVersion(): + assert chosen, "matching Qt versions should adopt system plugins" + assert Path(chosen, "platformthemes").is_dir() + else: + assert chosen is None, ( + f"must not load {system} plugins into Qt {qVersion()}" + ) + finally: + if saved is None: + os.environ.pop("QT_PLUGIN_PATH", None) + else: + os.environ["QT_PLUGIN_PATH"] = saved + print("ok system qt theme guard") + + +def test_version_matches_changelog(): + """The package version must be the newest release in the changelog.""" + import re + + import llamachat + + assert re.fullmatch(r"\d+\.\d+\.\d+", llamachat.__version__), ( + f"not semver: {llamachat.__version__}" + ) + + changelog = Path(__file__).resolve().parent / "CHANGELOG.md" + if not changelog.exists(): + print("skip changelog check (file absent)") + return + + released = re.findall( + r"^## \[(\d+\.\d+\.\d+)\]", changelog.read_text(), re.MULTILINE + ) + assert released, "changelog has no released versions" + assert released[0] == llamachat.__version__, ( + f"__version__ is {llamachat.__version__} but the newest changelog " + f"entry is {released[0]}" + ) + print("ok version matches changelog") + + +def test_venv_discovery(): + """The launcher must find a venv even when it shares the system binary.""" + saved = os.environ.pop("LLAMACHAT_PYTHON", None) + try: + found = llamachat_venv.find_interpreter() + current = Path(sys.executable) + other = [ + c for c in llamachat_venv.CANDIDATES if c.is_file() and c != current + ] + if other: + # A venv built with --system-site-packages has a bin/python3 that + # symlinks to the system interpreter. Comparing resolved paths + # would discard it as "the interpreter we are already running". + assert found is not None, ( + f"a candidate exists ({other[0]}) but none was selected" + ) + assert found.is_file() + else: + # Already running as the only candidate; nothing to hand over to. + assert found is None + + # An explicit override wins over the search order. + os.environ["LLAMACHAT_PYTHON"] = sys.executable + chosen = llamachat_venv.find_interpreter() + # Only rejected because it is the interpreter already running. + assert chosen is None or chosen == Path(sys.executable) + + os.environ["LLAMACHAT_PYTHON"] = "/nonexistent/python3" + assert llamachat_venv.find_interpreter() is None + finally: + if saved is None: + os.environ.pop("LLAMACHAT_PYTHON", None) + else: + os.environ["LLAMACHAT_PYTHON"] = saved + + # reexec must be a no-op once PySide6 is importable, or it would loop. + if llamachat_venv.have_pyside(): + llamachat_venv.reexec(__file__) # returns rather than exec'ing + print("ok venv discovery") + + +def test_config_defaults(): + cfg = config.load(Path("/nonexistent/config.toml")) + assert cfg.base_url == "http://localhost:8181" + assert cfg.presets_path == Path("/etc/llama-server/presets.ini") + assert cfg.socket_path.name == "llamachat.sock" + assert cfg.db_path.name == "history.db" + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.toml" + path.write_text( + 'base_url = "http://localhost:9999/"\ndefault_model = "m"\n' + ) + cfg = config.load(path) + assert cfg.base_url == "http://localhost:9999" # trailing / stripped + assert cfg.default_model == "m" + assert cfg.request_timeout == 300 # default kept + print("ok config defaults") + + +if __name__ == "__main__": + test_presets() + test_real_presets() + test_fts_query_escaping() + test_history_roundtrip() + test_attachment_truncation() + test_classify() + test_sse_parsing() + test_reasoning_storage() + test_migration_adds_reasoning() + test_user_content() + test_markdown_rendering() + test_system_qt_theme_guard() + test_version_matches_changelog() + test_venv_discovery() + test_config_defaults() + print("\nall checks passed") |
