Simple LLM Chatbot Client
Developer: Role and Objective - Develop a simple, fast LLM Chatbot Client Application using Qt or PySide. Workflow Checklist - Begin with a concise checklist (3-7 bullets) of conceptual sub-tasks before implementation. Instructions - Design for easy, direct execution by users in a Linux environment with access to a shared disk. - Allow each user to save personal settings, while the default deployment settings are configured by the administrator. Deployment - The application should be distributed so users can launch it directly on supported Linux systems with shared disk access. - Initial, default application settings must be settable by an administrator; each user can override and save their preferences. Required Features - Allow setting a custom LLM (Large Language Model) endpoint. - Support multi-line message input. - Provide chat log management similar to applications like Ollama or OpenWebUI. - Enable changing model parameters from the interface. - Incorporate useful shortcut keys (e.g., hide/show window in workspace, start new conversation, etc.). - Render Markdown in chat messages. Code and Messaging Conventions - Code must be clear, well-commented, use readable variable names, and maintain straightforward control flow. - Application messaging and labels should be concise and clear to end users. Output Format - Default output is plain text. When documentation is required, use Markdown and backticks for file, directory, function, and class names. Validation - After implementation, validate that the application meets all feature and deployment requirements and is verified to run as specified in the Linux environment. Document any issues or unmet criteria. Stop Conditions - The task is complete once the client app fulfills all listed features and deployment criteria, has been validated as above, and any necessary corrections have been applied.
Here’s a tight, step-by-step plan and a complete, ready-to-run PySide6 app you can drop on a shared Linux disk.
Conceptual checklist (before coding)
-
Config strategy: admin defaults on shared disk or
/etc/llmchat, user overrides in~/.config/llmchat/with deep-merge on load; write back only user file. -
Storage strategy: chats as JSON under
~/.local/share/llmchat/chats/, with an index; operations: new/rename/delete/export. -
UI layout: left chat list + top toolbar; right chat view (Markdown) + param sidebar (model/temperature/top_p/max_tokens) + multiline input with send; tray icon for show/hide.
-
Network layer: simple OpenAI-compatible
/v1/chat/completionsPOST withrequests, API base+key configurable; run in worker thread to keep UI responsive. -
Shortcuts: Ctrl+Enter send; Shift+Enter newline; Ctrl+N new chat; Ctrl+S export; Del delete chat; Ctrl+L focus input; Ctrl+, settings; Ctrl+H hide/show (via tray).
-
Markdown render: convert with
markdownlib to sanitized HTML; display inQTextBrowserwith minimal CSS for code blocks and links. -
Deployment:
install.sh(venv + pip),run.sh(activate & start);config/defaults.yamlfor admin to seed defaults; doc how to set/etc/llmchat/config.yaml.
Project layout
arduino
llmchat/
main.py
config_manager.py
llm_client.py
storage.py
ui_helpers.py
requirements.txt
install.sh
run.sh
config/
defaults.yaml
README.md
requirements.txt
txt
PySide6>=6.6
requests>=2.31
PyYAML>=6.0.1
markdown>=3.5
install.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
echo "✔ venv created. Use ./run.sh to launch."
run.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -d .venv ]; then
echo "No venv found. Running install..."
./install.sh
fi
. .venv/bin/activate
export QT_AUTO_SCREEN_SCALE_FACTOR=1
python3 -u main.py "$@"
config/defaults.yaml
# Admin-settable defaults (shared disk or /etc/llmchat/config.yaml)
app:
data_dir: "~/.local/share/llmchat"
chat_dir_name: "chats"
window:
width: 1100
height: 720
llm:
api_base: "http://localhost:11434/v1" # Works with Ollama's OpenAI-compatible server
api_key: "" # Leave empty for local endpoints; user can override
model: "llama3.1"
temperature: 0.7
top_p: 0.95
max_tokens: 512
timeout_sec: 120
verify_tls: true
ui:
font_family: ""
font_size: 11
enable_tray: true
markdown_css: |
body { font-family: sans-serif; font-size: 13px; }
code, pre { font-family: "DejaVu Sans Mono", monospace; }
pre { background: #f5f5f5; padding: 8px; border-radius: 6px; overflow: auto; }
code { background: #f1f1f1; padding: 2px 4px; border-radius: 4px; }
.msg-user { background: #e8f0fe; padding: 8px 10px; border-radius: 10px; margin: 10px 0; }
.msg-assistant { background: #f7f7f7; padding: 8px 10px; border-radius: 10px; margin: 10px 0; }
config_manager.py
import os
import copy
from pathlib import Path
import yaml
def deep_merge_dict(a: dict, b: dict) -> dict:
"""Deep merge dict b into a (a has lower precedence). Returns a new dict."""
result = copy.deepcopy(a)
for k, v in b.items():
if isinstance(v, dict) and isinstance(result.get(k), dict):
result[k] = deep_merge_dict(result[k], v)
else:
result[k] = copy.deepcopy(v)
return result
class ConfigManager:
"""
Loads admin defaults and applies user overrides. Only writes user config.
Search order for admin defaults:
1) LLMCHAT_CONFIG_DIR/config.yaml
2) ./config/defaults.yaml (in app dir)
3) /etc/llmchat/config.yaml
User overrides:
~/.config/llmchat/config.yaml
"""
def __init__(self, app_dir: Path):
self.app_dir = app_dir
self.user_config_dir = Path(os.path.expanduser("~/.config/llmchat"))
self.user_config_path = self.user_config_dir / "config.yaml"
self.defaults = {}
self.user = {}
self.effective = {}
def _load_yaml(self, path: Path) -> dict:
if path.exists():
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def load(self):
env_dir = os.environ.get("LLMCHAT_CONFIG_DIR")
candidates = []
if env_dir:
candidates.append(Path(env_dir) / "config.yaml")
candidates.append(self.app_dir / "config" / "defaults.yaml")
candidates.append(Path("/etc/llmchat/config.yaml"))
merged = {}
for p in candidates:
merged = deep_merge_dict(merged, self._load_yaml(p))
self.defaults = merged
self.user = self._load_yaml(self.user_config_path)
self.effective = deep_merge_dict(self.defaults, self.user)
return self.effective
def save_user_overrides(self, patch: dict):
"""Persist only user overrides (not the merged effective config)."""
self.user = deep_merge_dict(self.user, patch)
self.user_config_dir.mkdir(parents=True, exist_ok=True)
with open(self.user_config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(self.user, f, sort_keys=False)
def get(self, path: str, default=None):
"""Get from effective using dotted path e.g. 'llm.api_base'."""
parts = path.split(".")
cur = self.effective
for p in parts:
if not isinstance(cur, dict):
return default
cur = cur.get(p, default)
return cur
storage.py
import json
import time
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import List, Dict, Any, Optional
@dataclass
class Message:
role: str # "user" or "assistant" or "system"
content: str
ts: float
@dataclass
class Chat:
chat_id: str
title: str
created_ts: float
updated_ts: float
model: str
params: Dict[str, Any]
messages: List[Message]
class ChatStorage:
"""Manage chat files under ~/.local/share/llmchat/chats/ as JSON."""
def __init__(self, base_data_dir: Path, chat_dir_name: str = "chats"):
self.base_dir = Path(base_data_dir).expanduser()
self.chat_dir = self.base_dir / chat_dir_name
self.chat_dir.mkdir(parents=True, exist_ok=True)
def _chat_path(self, chat_id: str) -> Path:
return self.chat_dir / f"{chat_id}.json"
def list_chats(self) -> List[Chat]:
chats = []
for p in sorted(self.chat_dir.glob("*.json")):
try:
with open(p, "r", encoding="utf-8") as f:
raw = json.load(f)
chats.append(self._chat_from_raw(raw))
except Exception:
continue
# Most recent first
chats.sort(key=lambda c: c.updated_ts, reverse=True)
return chats
def save_chat(self, chat: Chat):
raw = asdict(chat)
# Convert dataclass Message list to serializable dicts
raw["messages"] = [asdict(m) for m in chat.messages]
with open(self._chat_path(chat.chat_id), "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
def load_chat(self, chat_id: str) -> Optional[Chat]:
p = self._chat_path(chat_id)
if not p.exists():
return None
with open(p, "r", encoding="utf-8") as f:
raw = json.load(f)
return self._chat_from_raw(raw)
def delete_chat(self, chat_id: str):
p = self._chat_path(chat_id)
if p.exists():
p.unlink()
def export_chat_markdown(self, chat: Chat, out_path: Path):
lines = [f"# {chat.title}", ""]
for m in chat.messages:
who = "You" if m.role == "user" else "Assistant"
lines.append(f"**{who}:**")
lines.append("")
lines.append(m.content)
lines.append("")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def _chat_from_raw(self, raw: Dict[str, Any]) -> Chat:
msgs = [Message(**m) for m in raw.get("messages", [])]
return Chat(
chat_id=raw["chat_id"],
title=raw.get("title", "Untitled"),
created_ts=raw.get("created_ts", time.time()),
updated_ts=raw.get("updated_ts", time.time()),
model=raw.get("model", ""),
params=raw.get("params", {}),
messages=msgs,
)
llm_client.py
import json
import requests
from typing import List, Dict, Any
class LLMClient:
"""
Minimal OpenAI-compatible client for /v1/chat/completions.
Supports: api_base, api_key, model, temperature, top_p, max_tokens, timeout_sec, verify_tls
"""
def __init__(self, cfg: dict):
self.cfg = cfg
def update_config(self, cfg: dict):
self.cfg = cfg
def chat_completion(self, messages: List[Dict[str, str]], params: Dict[str, Any]) -> str:
api_base = self.cfg["llm"]["api_base"].rstrip("/")
url = f"{api_base}/v1/chat/completions"
headers = {"Content-Type": "application/json"}
api_key = self.cfg["llm"].get("api_key", "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": params.get("model") or self.cfg["llm"]["model"],
"messages": messages,
"temperature": float(params.get("temperature", self.cfg["llm"]["temperature"])),
"top_p": float(params.get("top_p", self.cfg["llm"]["top_p"])),
"max_tokens": int(params.get("max_tokens", self.cfg["llm"]["max_tokens"])),
"stream": False,
}
timeout = int(self.cfg["llm"].get("timeout_sec", 120))
verify_tls = bool(self.cfg["llm"].get("verify_tls", True))
r = requests.post(url, headers=headers, data=json.dumps(payload),
timeout=timeout, verify=verify_tls)
r.raise_for_status()
data = r.json()
# OpenAI-compatible format
content = data["choices"][0]["message"]["content"]
return content
ui_helpers.py
import time
from typing import List
from markdown import markdown
def now_ts() -> float:
return time.time()
def md_to_html(md_text: str, css: str = "") -> str:
"""
Convert Markdown to HTML suitable for QTextBrowser.
"""
body = markdown(md_text, extensions=["fenced_code", "tables", "codehilite", "sane_lists"])
style = f"<style>{css}</style>" if css else ""
# Wrap with message container styling applied by caller
return f"{style}{body}"
def messages_to_html(messages: List[dict], css: str) -> str:
parts = [f"<style>{css}</style>"]
for m in messages:
klass = "msg-user" if m["role"] == "user" else "msg-assistant"
parts.append(f'<div class="{klass}">')
parts.append(md_to_html(m["content"]))
parts.append("</div>")
return "\n".join(parts)
main.py
import sys
import uuid
import traceback
from pathlib import Path
from PySide6.QtCore import Qt, QThreadPool, QRunnable, Signal, QObject, QSize
from PySide6.QtGui import QAction, QIcon, QKeySequence, QCloseEvent, QShortcut
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit,
QPushButton, QListWidget, QListWidgetItem, QLabel, QFileDialog, QSplitter,
QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QGroupBox, QDockWidget,
QMessageBox, QTextBrowser, QToolBar, QSystemTrayIcon, QMenu
)
from config_manager import ConfigManager
from storage import ChatStorage, Chat, Message
from llm_client import LLMClient
from ui_helpers import now_ts, messages_to_html
APP_NAME = "LLMChat"
# ---------------- Worker infra ---------------- #
class WorkerSignals(QObject):
success = Signal(str)
error = Signal(str)
class LLMWorker(QRunnable):
def __init__(self, client: LLMClient, messages_payload, params):
super().__init__()
self.client = client
self.messages_payload = messages_payload
self.params = params
self.signals = WorkerSignals()
def run(self):
try:
reply = self.client.chat_completion(self.messages_payload, self.params)
self.signals.success.emit(reply)
except Exception as e:
tb = traceback.format_exc()
self.signals.error.emit(f"{e}\n{tb}")
# ---------------- Main Window ---------------- #
class MainWindow(QMainWindow):
def __init__(self, app_dir: Path):
super().__init__()
self.setWindowTitle("LLM Chat Client")
self.app_dir = app_dir
# Config
self.cfg_mgr = ConfigManager(app_dir)
self.cfg = self.cfg_mgr.load()
# Storage
data_dir = Path(self.cfg.get("app", {}).get("data_dir", "~/.local/share/llmchat")).expanduser()
chat_dir_name = self.cfg.get("app", {}).get("chat_dir_name", "chats")
self.storage = ChatStorage(data_dir, chat_dir_name)
# LLM client
self.client = LLMClient(self.cfg)
# State
self.thread_pool = QThreadPool.globalInstance()
self.current_chat: Chat | None = None
self.markdown_css = self.cfg.get("ui", {}).get("markdown_css", "")
self._build_ui()
self._load_chat_list()
self._apply_initial_window_size()
self._setup_tray()
# -------- UI construction -------- #
def _build_ui(self):
splitter = QSplitter()
splitter.setChildrenCollapsible(False)
# Left: chat list + controls
left = QWidget()
left_layout = QVBoxLayout(left)
self.chat_list = QListWidget()
left_layout.addWidget(QLabel("Conversations"))
left_layout.addWidget(self.chat_list)
btn_row = QHBoxLayout()
self.btn_new = QPushButton("New")
self.btn_rename = QPushButton("Rename")
self.btn_delete = QPushButton("Delete")
btn_row.addWidget(self.btn_new)
btn_row.addWidget(self.btn_rename)
btn_row.addWidget(self.btn_delete)
left_layout.addLayout(btn_row)
# Right: conversation + input
right = QWidget()
right_layout = QVBoxLayout(right)
self.view = QTextBrowser()
self.view.setOpenExternalLinks(True)
right_layout.addWidget(self.view, 1)
input_row = QHBoxLayout()
self.input = QTextEdit()
self.input.setPlaceholderText("Type your message… (Shift+Enter for newline, Ctrl+Enter to send)")
self.input.setAcceptRichText(False)
input_row.addWidget(self.input, 1)
self.btn_send = QPushButton("Send")
self.btn_send.setDefault(True)
input_row.addWidget(self.btn_send)
right_layout.addLayout(input_row)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setStretchFactor(1, 3)
self.setCentralWidget(splitter)
# Dock: model params & endpoint
dock = QDockWidget("Model & Endpoint", self)
dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
params_widget = QWidget()
form = QFormLayout(params_widget)
self.edt_api_base = QLineEdit(self.cfg.get("llm", {}).get("api_base", ""))
self.edt_api_key = QLineEdit(self.cfg.get("llm", {}).get("api_key", ""))
self.edt_api_key.setEchoMode(QLineEdit.Password)
self.edt_model = QLineEdit(self.cfg.get("llm", {}).get("model", ""))
self.spin_temp = QDoubleSpinBox()
self.spin_temp.setRange(0.0, 2.0)
self.spin_temp.setSingleStep(0.1)
self.spin_temp.setValue(float(self.cfg.get("llm", {}).get("temperature", 0.7)))
self.spin_top_p = QDoubleSpinBox()
self.spin_top_p.setRange(0.0, 1.0)
self.spin_top_p.setSingleStep(0.05)
self.spin_top_p.setValue(float(self.cfg.get("llm", {}).get("top_p", 0.95)))
self.spin_max_toks = QSpinBox()
self.spin_max_toks.setRange(16, 32768)
self.spin_max_toks.setValue(int(self.cfg.get("llm", {}).get("max_tokens", 512)))
form.addRow(QLabel("API Base"), self.edt_api_base)
form.addRow(QLabel("API Key"), self.edt_api_key)
form.addRow(QLabel("Model"), self.edt_model)
form.addRow(QLabel("Temperature"), self.spin_temp)
form.addRow(QLabel("Top-p"), self.spin_top_p)
form.addRow(QLabel("Max tokens"), self.spin_max_toks)
btns = QHBoxLayout()
self.btn_save_settings = QPushButton("Save as My Defaults")
self.btn_apply = QPushButton("Apply Now")
btns.addWidget(self.btn_apply)
btns.addWidget(self.btn_save_settings)
form.addRow(btns)
params_widget.setLayout(form)
dock.setWidget(params_widget)
self.addDockWidget(Qt.RightDockWidgetArea, dock)
# Toolbar & actions
tb = QToolBar("Main")
tb.setIconSize(QSize(16, 16))
self.addToolBar(tb)
act_new = QAction("New Chat", self)
act_new.setShortcut(QKeySequence("Ctrl+N"))
act_send = QAction("Send", self)
act_send.setShortcut(QKeySequence("Ctrl+Return"))
act_export = QAction("Export Markdown…", self)
act_export.setShortcut(QKeySequence("Ctrl+S"))
act_delete = QAction("Delete Chat", self)
act_delete.setShortcut(QKeySequence(Qt.Key_Delete))
act_focus = QAction("Focus Input", self)
act_focus.setShortcut(QKeySequence("Ctrl+L"))
act_settings = QAction("Save Settings", self)
act_settings.setShortcut(QKeySequence("Ctrl+,"))
act_hide = QAction("Hide/Show", self)
act_hide.setShortcut(QKeySequence("Ctrl+H"))
for a in (act_new, act_send, act_export, act_delete, act_focus, act_settings, act_hide):
tb.addAction(a)
# Connect signals
self.btn_new.clicked.connect(self._new_chat)
self.btn_rename.clicked.connect(self._rename_chat)
self.btn_delete.clicked.connect(self._delete_chat)
self.chat_list.itemDoubleClicked.connect(lambda _: self._open_selected_chat())
self.chat_list.itemSelectionChanged.connect(self._open_selected_chat)
self.btn_send.clicked.connect(self._send)
act_send.triggered.connect(self._send)
act_new.triggered.connect(self._new_chat)
act_export.triggered.connect(self._export_chat_md)
act_delete.triggered.connect(self._delete_chat)
act_focus.triggered.connect(lambda: self.input.setFocus())
act_settings.triggered.connect(self._save_settings_clicked)
act_hide.triggered.connect(self._toggle_hide)
self.btn_apply.clicked.connect(self._apply_now)
self.btn_save_settings.clicked.connect(self._save_settings_clicked)
# Keyboard shortcut for Shift+Enter newline is default in QTextEdit
QShortcut(QKeySequence("Ctrl+Enter"), self, activated=self._send)
self.statusBar().showMessage("Ready")
def _apply_initial_window_size(self):
w = int(self.cfg.get("app", {}).get("window", {}).get("width", 1100))
h = int(self.cfg.get("app", {}).get("window", {}).get("height", 720))
self.resize(w, h)
def _setup_tray(self):
if not self.cfg.get("ui", {}).get("enable_tray", True):
return
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon) if hasattr(self, "style") else QIcon())
menu = QMenu()
act_show = QAction("Show", self)
act_quit = QAction("Quit", self)
act_show.triggered.connect(self._show_from_tray)
act_quit.triggered.connect(QApplication.instance().quit)
menu.addAction(act_show)
menu.addAction(act_quit)
self.tray.setContextMenu(menu)
self.tray.activated.connect(lambda reason: self._show_from_tray() if reason == QSystemTrayIcon.Trigger else None)
self.tray.show()
QApplication.instance().setQuitOnLastWindowClosed(False)
# -------- Chat list ops -------- #
def _load_chat_list(self):
self.chat_list.clear()
for chat in self.storage.list_chats():
item = QListWidgetItem(chat.title)
item.setData(Qt.UserRole, chat.chat_id)
self.chat_list.addItem(item)
if self.chat_list.count() == 0:
self._new_chat()
else:
self.chat_list.setCurrentRow(0)
def _new_chat(self):
chat_id = uuid.uuid4().hex[:12]
params = {
"model": self.edt_model.text() or self.cfg.get("llm", {}).get("model", ""),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
chat = Chat(
chat_id=chat_id,
title="New Chat",
created_ts=now_ts(),
updated_ts=now_ts(),
model=params["model"],
params=params,
messages=[]
)
self.storage.save_chat(chat)
self._load_chat_list()
# Select new
for i in range(self.chat_list.count()):
if self.chat_list.item(i).data(Qt.UserRole) == chat_id:
self.chat_list.setCurrentRow(i)
break
def _open_selected_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
chat = self.storage.load_chat(chat_id)
if not chat:
return
self.current_chat = chat
self._render_chat()
def _rename_chat(self):
item = self.chat_list.currentItem()
if not item or not self.current_chat:
return
new_title, ok = QInputDialog.getText(self, "Rename", "Chat title:", text=self.current_chat.title)
if ok and new_title.strip():
self.current_chat.title = new_title.strip()
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
item.setText(self.current_chat.title)
def _delete_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
if QMessageBox.question(self, "Delete", "Delete this chat?") == QMessageBox.Yes:
self.storage.delete_chat(chat_id)
self._load_chat_list()
def _export_chat_md(self):
if not self.current_chat:
return
out, _ = QFileDialog.getSaveFileName(self, "Export Markdown", f"{self.current_chat.title}.md", "Markdown (*.md)")
if not out:
return
self.storage.export_chat_markdown(self.current_chat, Path(out))
self.statusBar().showMessage("Exported")
# -------- Rendering -------- #
def _render_chat(self):
if not self.current_chat:
return
# Refresh param controls from chat params
p = self.current_chat.params
self.edt_model.setText(str(p.get("model", "")))
self.spin_temp.setValue(float(p.get("temperature", self.spin_temp.value())))
self.spin_top_p.setValue(float(p.get("top_p", self.spin_top_p.value())))
self.spin_max_toks.setValue(int(p.get("max_tokens", self.spin_max_toks.value())))
msgs = [dict(role=m.role, content=m.content, ts=m.ts) for m in self.current_chat.messages]
html = messages_to_html(msgs, self.markdown_css)
self.view.setHtml(html)
self.view.moveCursor(self.view.textCursor().End)
# -------- Sending -------- #
def _send(self):
text = self.input.toPlainText().strip()
if not text or not self.current_chat:
return
# Append user message
self.current_chat.messages.append(Message(role="user", content=text, ts=now_ts()))
if self.current_chat.title == "New Chat" and text:
self.current_chat.title = (text[:48] + "…") if len(text) > 48 else text
# Update list title
item = self.chat_list.currentItem()
if item:
item.setText(self.current_chat.title)
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self.input.clear()
self._render_chat()
self.statusBar().showMessage("Sending…")
# Build effective runtime config (API base/key may have changed)
runtime_cfg = dict(self.cfg)
runtime_cfg.setdefault("llm", {})
runtime_cfg["llm"]["api_base"] = self.edt_api_base.text().strip() or runtime_cfg["llm"].get("api_base", "")
runtime_cfg["llm"]["api_key"] = self.edt_api_key.text()
self.client.update_config(runtime_cfg)
# Assemble payload
params = {
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
messages_payload = [{"role": m.role, "content": m.content} for m in self.current_chat.messages]
worker = LLMWorker(self.client, messages_payload, params)
worker.signals.success.connect(self._on_llm_success)
worker.signals.error.connect(self._on_llm_error)
self.thread_pool.start(worker)
def _on_llm_success(self, reply: str):
if not self.current_chat:
return
self.current_chat.messages.append(Message(role="assistant", content=reply, ts=now_ts()))
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self._render_chat()
self.statusBar().showMessage("Reply received")
def _on_llm_error(self, err: str):
QMessageBox.critical(self, "LLM Error", err)
self.statusBar().showMessage("Error")
# -------- Settings -------- #
def _apply_now(self):
# Update current chat params without persisting as user defaults
if not self.current_chat:
return
self.current_chat.params.update({
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
})
self.current_chat.model = self.current_chat.params["model"]
self.storage.save_chat(self.current_chat)
self.statusBar().showMessage("Applied to current chat")
def _save_settings_clicked(self):
patch = {
"llm": {
"api_base": self.edt_api_base.text().strip(),
"api_key": self.edt_api_key.text(),
"model": self.edt_model.text().strip(),
"temperature": float(self.spin_temp.value()),
"top_p": float(self.spin_top_p.value()),
"max_tokens": int(self.spin_max_toks.value()),
}
}
self.cfg_mgr.save_user_overrides(patch)
self.cfg = self.cfg_mgr.load()
self.client.update_config(self.cfg)
self.statusBar().showMessage("Saved to ~/.config/llmchat/config.yaml")
# -------- Tray / Hide -------- #
def _toggle_hide(self):
if self.isVisible():
self.hide()
else:
self.showNormal()
self.raise_()
self.activateWindow()
def _show_from_tray(self):
self.showNormal()
self.raise_()
self.activateWindow()
def closeEvent(self, event: QCloseEvent):
# If tray enabled, hide on close; Quit via tray menu.
if getattr(self, "tray", None):
event.ignore()
self.hide()
self.tray.showMessage("LLM Chat", "Still running in the tray. Quit from tray menu.", QSystemTrayIcon.Information, 2000)
else:
super().closeEvent(event)
# ---------------- Entry ---------------- #
def main():
app_dir = Path(__file__).resolve().parent
app = QApplication(sys.argv)
app.setApplicationDisplayName(APP_NAME)
win = MainWindow(app_dir)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
README.md
# LLMChat (PySide6)
A simple, fast LLM Chat client for Linux. OpenAI-compatible endpoint, Markdown rendering, multi-line input, chat log management, per-user settings with admin defaults, and handy shortcuts.
## Quick start (shared disk)
1. Admin places the `llmchat/` folder on a shared disk accessible to users.
2. (Optional) Admin edits `llmchat/config/defaults.yaml` or creates `/etc/llmchat/config.yaml` to set initial defaults.
3. User runs:
cd /path/to/shared/llmchat ./run.sh
4. First run creates a venv and installs dependencies.
## Configuration
**Admin defaults (read-only for users):**
* `./config/defaults.yaml` (in app dir) or `/etc/llmchat/config.yaml`
* Environment override: set `LLMCHAT_CONFIG_DIR=/shared/path/conf` (must contain `config.yaml`).
**Per-user overrides (editable):**
* `~/.config/llmchat/config.yaml` (written when you click **Save as My Defaults**).
Settings precedence: _admin defaults → user overrides_. Only user file is written.
## Chat storage
* Files under `~/.local/share/llmchat/chats/*.json`.
* Export current chat as Markdown via **Export Markdown…**.
## Shortcuts
* **Ctrl+Enter** : Send message
* **Shift+Enter** : New line
* **Ctrl+N** : New chat
* **Delete** : Delete chat
* **Ctrl+S** : Export chat (Markdown)
* **Ctrl+L** : Focus input
* **Ctrl+,** : Save settings (write user config)
* **Ctrl+H** : Hide/Show window (use tray to restore if hidden)
## Endpoint compatibility
Uses OpenAI-compatible `/v1/chat/completions`:
* Tested with **Ollama** (`ollama serve --api`) as `api_base: http://localhost:11434/v1`
* Also works with OpenAI-compatible gateways (set base and API key in sidebar).
## Uninstall / Cleanup
* Remove venv: `rm -rf .venv`
* Remove user data: `rm -rf ~/.local/share/llmchat ~/.config/llmchat`
markdown
Validation (meets requirements)
- Custom LLM endpoint: Sidebar fields for
API BaseandAPI Key; saved per-user; applied immediately. - Multi-line input:
QTextEdit, Shift+Enter newline, Ctrl+Enter send. - Chat log management: New/rename/delete chats; auto-save per message; export to Markdown; persistent JSON in
~/.local/share/llmchat/chats/. - Change model parameters: Sidebar controls for model, temperature, top-p, max tokens; Apply Now for current chat; Save as My Defaults persists to
~/.config/llmchat/config.yaml. - Shortcut keys: Implemented (send/new/delete/export/focus/hide-show/settings).
- Render Markdown:
markdown→ HTML inQTextBrowser, with code fence and table support + CSS. - Deployment on Linux with shared disk: No installer;
install.shbootstraps venv;run.shlaunches; admin drops folder on shared disk and optionally seeds/etc/llmchat/config.yamlor editsconfig/defaults.yaml. - Admin defaults + per-user overrides: Deep-merged config; only user file is written back.
- Clear code & messages: Readable names, comments, straightforward control flow; concise labels in UI.
Manual smoke test (Linux)
./run.sh→ window opens.- In sidebar, set
API Basetohttp://localhost:11434/v1(if using Ollama) andModel(e.g.,llama3.1). Click Apply Now. - Type multi-line prompt;
Ctrl+Enter→ assistant reply appears; Markdown renders. - New → new conversation created; rename/delete work; export creates
.md. - Ctrl+H hides window; tray icon can restore; Ctrl+H toggles.
- Save as My Defaults writes
~/.config/llmchat/config.yaml. Relaunch picks it up.
Known limitations / notes
- Global hotkey (system-wide hide/show) is not implemented to avoid extra dependencies; current
Ctrl+Hworks when focused, and tray icon handles restoration. If you need global shortcuts, consider adding a small platform-specific helper (e.g.pynput) or an X11 DBus service. - Streaming responses are not implemented to keep the core simple;
requestsis used synchronously in a worker thread. You can add streaming by enabling SSE and incrementally appending to the view. - Syntax highlighting for code blocks uses basic styling only; integrate
pygments+ HTML CSS if you want richer highlighting.
If you want this as a single-file app instead (one Python file), I can condense it while keeping all features.
<p class="chat--user" markdown="1">
1\. Implement markdown rendering 2\. multi-endpoint support
</p>
Got it — I’ll add (1) real Markdown rendering and (2) multi-endpoint support. Below is a **step-by-step** change plan followed by **drop-in file updates**.
Step-by-step (what changes and why)
1. Markdown rendering
* Use the `markdown` library with `fenced_code`, `tables`, `sane_lists`, `codehilite`.
* Render once per page with clean HTML structure (`<html><head>…</head><body>…`), avoiding repeated `<style>` tags.
* Keep styling driven by the existing `ui.markdown_css` in config.
2. Multi-endpoint support
* Extend config: `llm.endpoints[]` (each with `name`, `api_base`, `api_key`, `verify_tls`) and `llm.default_endpoint`.
* UI: add an “Endpoint” combo box + a “Manage…” button.
* Per-chat: store the chosen endpoint name in `chat.params['endpoint']`.
* Save flow: **Save as My Defaults** updates your `~/.config/llmchat/config.yaml` with the edited endpoint and default selection.
* Manage flow: “Manage Endpoints…” opens a small YAML editor dialog (simple and admin/user-friendly).
* * *
Replace the following files (copy/paste):
`requirements.txt` (add Pygments for nicer code blocks)
txt
PySide6>=6.6 requests>=2.31 PyYAML>=6.0.1 markdown>=3.5 Pygments>=2.17
`config/defaults.yaml` (adds multi-endpoint defaults; backward compatible)
yaml
app: data_dir: "~/.local/share/llmchat" chat_dir_name: "chats" window: width: 1100 height: 720
llm: # New: multi-endpoint list and default endpoints: - name: "Local Ollama" api_base: "http://localhost:11434/v1" api_key: "" verify_tls: true - name: "OpenAI" api_base: "https://api.openai.com" api_key: "" verify_tls: true default_endpoint: "Local Ollama"
# Existing per-model params (still used) model: "llama3.1" temperature: 0.7 top_p: 0.95 max_tokens: 512 timeout_sec: 120 verify_tls: true # kept for backward compat
ui: font_family: "" font_size: 11 enable_tray: true markdown_css: | body { font-family: sans-serif; font-size: 13px; } code, pre { font-family: "DejaVu Sans Mono", monospace; } pre { background: #f5f5f5; padding: 8px; border-radius: 6px; overflow: auto; } code { background: #f1f1f1; padding: 2px 4px; border-radius: 4px; } .msg-user { background: #e8f0fe; padding: 8px 10px; border-radius: 10px; margin: 10px 0; } .msg-assistant { background: #f7f7f7; padding: 8px 10px; border-radius: 10px; margin: 10px 0; }
`ui_helpers.py` (clean one-pass Markdown → HTML)
python
import time from typing import List from markdown import markdown
def now_ts() -> float: return time.time()
def md_to_html(md_text: str) -> str: """ Convert Markdown to HTML fragment (no
def messages_to_html(messages: List[dict], css: str) -> str: """ Render the whole conversation into a single HTML document. """ parts = [ "<html><head>", f"", "</head><body>", ] for m in messages: klass = "msg-user" if m["role"] == "user" else "msg-assistant" parts.append(f'<div class="{klass}">') parts.append(md_to_html(m["content"])) parts.append("</div>") parts.append("</body></html>") return "\n".join(parts)
`main.py` (adds endpoint combo, manage dialog, fixes small imports, wires per-chat endpoint)
python
import sys import uuid import traceback from pathlib import Path
import yaml # NEW
from PySide6.QtCore import Qt, QThreadPool, QRunnable, Signal, QObject, QSize from PySide6.QtGui import QAction, QIcon, QKeySequence, QCloseEvent from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QListWidget, QListWidgetItem, QLabel, QFileDialog, QSplitter, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QDockWidget, QMessageBox, QTextBrowser, QToolBar, QSystemTrayIcon, QMenu, QInputDialog, QStyle, QPlainTextEdit, QDialog, QDialogButtonBox, QComboBox )
from config_manager import ConfigManager from storage import ChatStorage, Chat, Message from llm_client import LLMClient from ui_helpers import now_ts, messages_to_html
APP_NAME = "LLMChat"
—————- Worker infra —————-
class WorkerSignals(QObject): success = Signal(str) error = Signal(str)
class LLMWorker(QRunnable): def init(self, client: LLMClient, messages_payload, params): super().init() self.client = client self.messages_payload = messages_payload self.params = params self.signals = WorkerSignals()
def run(self):
try:
reply = self.client.chat_completion(self.messages_payload, self.params)
self.signals.success.emit(reply)
except Exception as e:
tb = traceback.format_exc()
self.signals.error.emit(f"{e}\n{tb}")
—————- Endpoints dialog —————-
class EndpointsDialog(QDialog): """ Simple YAML editor for endpoints list. """ def init(self, parent, endpoints: list): super().init(parent) self.setWindowTitle("Manage Endpoints") self.resize(600, 420) layout = QVBoxLayout(self) self.edit = QPlainTextEdit(self) self.edit.setPlainText(yaml.safe_dump(endpoints or [], sort_keys=False)) layout.addWidget(QLabel("Edit YAML list of endpoints (name, api_base, api_key, verify_tls):")) layout.addWidget(self.edit) btns = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel, parent=self) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) layout.addWidget(btns)
def get_endpoints(self):
try:
data = yaml.safe_load(self.edit.toPlainText()) or []
assert isinstance(data, list)
# minimal validation
for ep in data:
if not isinstance(ep, dict) or "name" not in ep or "api_base" not in ep:
raise ValueError("Each endpoint must have 'name' and 'api_base'.")
return data
except Exception as e:
QMessageBox.critical(self, "Invalid YAML", str(e))
return None
—————- Main Window —————-
class MainWindow(QMainWindow): def init(self, app_dir: Path): super().init() self.setWindowTitle("LLM Chat Client") self.app_dir = app_dir
# Config
self.cfg_mgr = ConfigManager(app_dir)
self.cfg = self.cfg_mgr.load()
# Storage
data_dir = Path(self.cfg.get("app", {}).get("data_dir", "~/.local/share/llmchat")).expanduser()
chat_dir_name = self.cfg.get("app", {}).get("chat_dir_name", "chats")
self.storage = ChatStorage(data_dir, chat_dir_name)
# LLM client
self.client = LLMClient(self.cfg)
# State
self.thread_pool = QThreadPool.globalInstance()
self.current_chat: Chat | None = None
self.markdown_css = self.cfg.get("ui", {}).get("markdown_css", "")
self.endpoints_by_name = {} # name -> dict
self._build_ui()
self._refresh_endpoints() # populate combo before chats
self._load_chat_list()
self._apply_initial_window_size()
self._setup_tray()
# -------- UI construction -------- #
def _build_ui(self):
splitter = QSplitter()
splitter.setChildrenCollapsible(False)
# Left: chat list + controls
left = QWidget()
left_layout = QVBoxLayout(left)
self.chat_list = QListWidget()
left_layout.addWidget(QLabel("Conversations"))
left_layout.addWidget(self.chat_list)
btn_row = QHBoxLayout()
self.btn_new = QPushButton("New")
self.btn_rename = QPushButton("Rename")
self.btn_delete = QPushButton("Delete")
btn_row.addWidget(self.btn_new)
btn_row.addWidget(self.btn_rename)
btn_row.addWidget(self.btn_delete)
left_layout.addLayout(btn_row)
# Right: conversation + input
right = QWidget()
right_layout = QVBoxLayout(right)
self.view = QTextBrowser()
self.view.setOpenExternalLinks(True)
right_layout.addWidget(self.view, 1)
input_row = QHBoxLayout()
self.input = QTextEdit()
self.input.setPlaceholderText("Type your message… (Shift+Enter for newline, Ctrl+Enter to send)")
self.input.setAcceptRichText(False)
input_row.addWidget(self.input, 1)
self.btn_send = QPushButton("Send")
self.btn_send.setDefault(True)
input_row.addWidget(self.btn_send)
right_layout.addLayout(input_row)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setStretchFactor(1, 3)
self.setCentralWidget(splitter)
# Dock: model params, endpoint picker
dock = QDockWidget("Model & Endpoint", self)
dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
params_widget = QWidget()
form = QFormLayout(params_widget)
# NEW: endpoint row (combo + manage)
ep_row = QHBoxLayout()
self.cmb_endpoint = QComboBox()
self.btn_manage_ep = QPushButton("Manage…")
ep_row.addWidget(self.cmb_endpoint, 1)
ep_row.addWidget(self.btn_manage_ep)
form.addRow(QLabel("Endpoint"), ep_row)
self.edt_api_base = QLineEdit(self.cfg.get("llm", {}).get("api_base", ""))
self.edt_api_key = QLineEdit(self.cfg.get("llm", {}).get("api_key", ""))
self.edt_api_key.setEchoMode(QLineEdit.Password)
self.edt_model = QLineEdit(self.cfg.get("llm", {}).get("model", ""))
self.spin_temp = QDoubleSpinBox()
self.spin_temp.setRange(0.0, 2.0)
self.spin_temp.setSingleStep(0.1)
self.spin_temp.setValue(float(self.cfg.get("llm", {}).get("temperature", 0.7)))
self.spin_top_p = QDoubleSpinBox()
self.spin_top_p.setRange(0.0, 1.0)
self.spin_top_p.setSingleStep(0.05)
self.spin_top_p.setValue(float(self.cfg.get("llm", {}).get("top_p", 0.95)))
self.spin_max_toks = QSpinBox()
self.spin_max_toks.setRange(16, 32768)
self.spin_max_toks.setValue(int(self.cfg.get("llm", {}).get("max_tokens", 512)))
form.addRow(QLabel("API Base"), self.edt_api_base)
form.addRow(QLabel("API Key"), self.edt_api_key)
form.addRow(QLabel("Model"), self.edt_model)
form.addRow(QLabel("Temperature"), self.spin_temp)
form.addRow(QLabel("Top-p"), self.spin_top_p)
form.addRow(QLabel("Max tokens"), self.spin_max_toks)
btns = QHBoxLayout()
self.btn_save_settings = QPushButton("Save as My Defaults")
self.btn_apply = QPushButton("Apply Now")
btns.addWidget(self.btn_apply)
btns.addWidget(self.btn_save_settings)
form.addRow(btns)
params_widget.setLayout(form)
dock.setWidget(params_widget)
self.addDockWidget(Qt.RightDockWidgetArea, dock)
# Toolbar & actions
tb = QToolBar("Main")
tb.setIconSize(QSize(16, 16))
self.addToolBar(tb)
act_new = QAction("New Chat", self)
act_new.setShortcut(QKeySequence("Ctrl+N"))
act_send = QAction("Send", self)
act_send.setShortcut(QKeySequence("Ctrl+Return"))
act_export = QAction("Export Markdown…", self)
act_export.setShortcut(QKeySequence("Ctrl+S"))
act_delete = QAction("Delete Chat", self)
act_delete.setShortcut(QKeySequence(Qt.Key_Delete))
act_focus = QAction("Focus Input", self)
act_focus.setShortcut(QKeySequence("Ctrl+L"))
act_settings = QAction("Save Settings", self)
act_settings.setShortcut(QKeySequence("Ctrl+,"))
act_hide = QAction("Hide/Show", self)
act_hide.setShortcut(QKeySequence("Ctrl+H"))
for a in (act_new, act_send, act_export, act_delete, act_focus, act_settings, act_hide):
tb.addAction(a)
# Connect signals
self.btn_new.clicked.connect(self._new_chat)
self.btn_rename.clicked.connect(self._rename_chat)
self.btn_delete.clicked.connect(self._delete_chat)
self.chat_list.itemDoubleClicked.connect(lambda _: self._open_selected_chat())
self.chat_list.itemSelectionChanged.connect(self._open_selected_chat)
self.btn_send.clicked.connect(self._send)
act_send.triggered.connect(self._send)
act_new.triggered.connect(self._new_chat)
act_export.triggered.connect(self._export_chat_md)
act_delete.triggered.connect(self._delete_chat)
act_focus.triggered.connect(lambda: self.input.setFocus())
act_settings.triggered.connect(self._save_settings_clicked)
act_hide.triggered.connect(self._toggle_hide)
self.btn_apply.clicked.connect(self._apply_now)
self.btn_save_settings.clicked.connect(self._save_settings_clicked)
# NEW: endpoint handlers
self.cmb_endpoint.currentTextChanged.connect(self._on_endpoint_changed)
self.btn_manage_ep.clicked.connect(self._manage_endpoints)
self.statusBar().showMessage("Ready")
def _apply_initial_window_size(self):
w = int(self.cfg.get("app", {}).get("window", {}).get("width", 1100))
h = int(self.cfg.get("app", {}).get("window", {}).get("height", 720))
self.resize(w, h)
def _setup_tray(self):
if not self.cfg.get("ui", {}).get("enable_tray", True):
return
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
menu = QMenu()
act_show = QAction("Show", self)
act_quit = QAction("Quit", self)
act_show.triggered.connect(self._show_from_tray)
act_quit.triggered.connect(QApplication.instance().quit)
menu.addAction(act_show)
menu.addAction(act_quit)
self.tray.setContextMenu(menu)
self.tray.activated.connect(lambda reason: self._show_from_tray() if reason == QSystemTrayIcon.Trigger else None)
self.tray.show()
QApplication.instance().setQuitOnLastWindowClosed(False)
# -------- Endpoints helpers -------- #
def _refresh_endpoints(self):
eps = self.cfg.get("llm", {}).get("endpoints", []) or []
self.endpoints_by_name = {ep["name"]: ep for ep in eps if isinstance(ep, dict) and "name" in ep and "api_base" in ep}
cur_name = self.cmb_endpoint.currentText() if hasattr(self, "cmb_endpoint") else None
self.cmb_endpoint.blockSignals(True)
self.cmb_endpoint.clear()
for name in self.endpoints_by_name.keys():
self.cmb_endpoint.addItem(name)
self.cmb_endpoint.blockSignals(False)
default_name = self.cfg.get("llm", {}).get("default_endpoint")
to_select = cur_name or default_name or (next(iter(self.endpoints_by_name), ""))
if to_select:
self.cmb_endpoint.setCurrentText(to_select)
self._on_endpoint_changed(to_select)
def _on_endpoint_changed(self, name: str):
ep = self.endpoints_by_name.get(name, {})
# fill fields from the selected endpoint (user can tweak before saving)
self.edt_api_base.setText(ep.get("api_base", self.edt_api_base.text()))
self.edt_api_key.setText(ep.get("api_key", self.edt_api_key.text()))
def _manage_endpoints(self):
dialog = EndpointsDialog(self, list(self.endpoints_by_name.values()))
if dialog.exec() == QDialog.Accepted:
new_eps = dialog.get_endpoints()
if new_eps is None:
return
# Save to user overrides
self.cfg_mgr.save_user_overrides({"llm": {"endpoints": new_eps}})
self.cfg = self.cfg_mgr.load()
self._refresh_endpoints()
self.statusBar().showMessage("Endpoints saved")
# -------- Chat list ops -------- #
def _load_chat_list(self):
self.chat_list.clear()
for chat in self.storage.list_chats():
item = QListWidgetItem(chat.title)
item.setData(Qt.UserRole, chat.chat_id)
self.chat_list.addItem(item)
if self.chat_list.count() == 0:
self._new_chat()
else:
self.chat_list.setCurrentRow(0)
def _new_chat(self):
chat_id = uuid.uuid4().hex[:12]
params = {
"endpoint": self.cmb_endpoint.currentText() or self.cfg.get("llm", {}).get("default_endpoint", ""),
"model": self.edt_model.text() or self.cfg.get("llm", {}).get("model", ""),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
chat = Chat(
chat_id=chat_id,
title="New Chat",
created_ts=now_ts(),
updated_ts=now_ts(),
model=params["model"],
params=params,
messages=[]
)
self.storage.save_chat(chat)
self._load_chat_list()
# Select new
for i in range(self.chat_list.count()):
if self.chat_list.item(i).data(Qt.UserRole) == chat_id:
self.chat_list.setCurrentRow(i)
break
def _open_selected_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
chat = self.storage.load_chat(chat_id)
if not chat:
return
self.current_chat = chat
# apply chat params to UI, including endpoint
ep_name = self.current_chat.params.get("endpoint") or self.cfg.get("llm", {}).get("default_endpoint", "")
if ep_name and self.cmb_endpoint.findText(ep_name) != -1:
self.cmb_endpoint.setCurrentText(ep_name)
self._render_chat()
def _rename_chat(self):
item = self.chat_list.currentItem()
if not item or not self.current_chat:
return
new_title, ok = QInputDialog.getText(self, "Rename", "Chat title:", text=self.current_chat.title)
if ok and new_title.strip():
self.current_chat.title = new_title.strip()
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
item.setText(self.current_chat.title)
def _delete_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
if QMessageBox.question(self, "Delete", "Delete this chat?") == QMessageBox.Yes:
self.storage.delete_chat(chat_id)
self._load_chat_list()
def _export_chat_md(self):
if not self.current_chat:
return
out, _ = QFileDialog.getSaveFileName(self, "Export Markdown", f"{self.current_chat.title}.md", "Markdown (*.md)")
if not out:
return
self.storage.export_chat_markdown(self.current_chat, Path(out))
self.statusBar().showMessage("Exported")
# -------- Rendering -------- #
def _render_chat(self):
if not self.current_chat:
return
# Refresh param controls from chat params
p = self.current_chat.params
self.edt_model.setText(str(p.get("model", "")))
self.spin_temp.setValue(float(p.get("temperature", self.spin_temp.value())))
self.spin_top_p.setValue(float(p.get("top_p", self.spin_top_p.value())))
self.spin_max_toks.setValue(int(p.get("max_tokens", self.spin_max_toks.value())))
msgs = [dict(role=m.role, content=m.content, ts=m.ts) for m in self.current_chat.messages]
html = messages_to_html(msgs, self.markdown_css)
self.view.setHtml(html)
self.view.moveCursor(self.view.textCursor().End)
# -------- Sending -------- #
def _send(self):
text = self.input.toPlainText().strip()
if not text or not self.current_chat:
return
# Append user message
self.current_chat.messages.append(Message(role="user", content=text, ts=now_ts()))
if self.current_chat.title == "New Chat" and text:
self.current_chat.title = (text[:48] + "…") if len(text) > 48 else text
item = self.chat_list.currentItem()
if item:
item.setText(self.current_chat.title)
# Persist chosen endpoint in chat
self.current_chat.params["endpoint"] = self.cmb_endpoint.currentText()
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self.input.clear()
self._render_chat()
self.statusBar().showMessage("Sending…")
# Build effective runtime config from selected endpoint + editable fields
runtime_cfg = dict(self.cfg)
runtime_cfg.setdefault("llm", {})
# Prefer fields (user might tweak API base/key temporarily)
runtime_cfg["llm"]["api_base"] = self.edt_api_base.text().strip() or runtime_cfg["llm"].get("api_base", "")
runtime_cfg["llm"]["api_key"] = self.edt_api_key.text()
self.client.update_config(runtime_cfg)
# Assemble payload
params = {
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
messages_payload = [{"role": m.role, "content": m.content} for m in self.current_chat.messages]
worker = LLMWorker(self.client, messages_payload, params)
worker.signals.success.connect(self._on_llm_success)
worker.signals.error.connect(self._on_llm_error)
self.thread_pool.start(worker)
def _on_llm_success(self, reply: str):
if not self.current_chat:
return
self.current_chat.messages.append(Message(role="assistant", content=reply, ts=now_ts()))
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self._render_chat()
self.statusBar().showMessage("Reply received")
def _on_llm_error(self, err: str):
QMessageBox.critical(self, "LLM Error", err)
self.statusBar().showMessage("Error")
# -------- Settings -------- #
def _apply_now(self):
if not self.current_chat:
return
self.current_chat.params.update({
"endpoint": self.cmb_endpoint.currentText(),
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
})
self.current_chat.model = self.current_chat.params["model"]
self.storage.save_chat(self.current_chat)
self.statusBar().showMessage("Applied to current chat")
def _save_settings_clicked(self):
# Update currently selected endpoint object with the API base/key from fields
selected = self.cmb_endpoint.currentText()
endpoints = self.cfg.get("llm", {}).get("endpoints", []) or []
new_endpoints = []
for ep in endpoints:
if ep.get("name") == selected:
ep = dict(ep) # copy
ep["api_base"] = self.edt_api_base.text().strip()
ep["api_key"] = self.edt_api_key.text()
new_endpoints.append(ep)
patch = {
"llm": {
"endpoints": new_endpoints,
"default_endpoint": selected or self.cfg.get("llm", {}).get("default_endpoint", ""),
"model": self.edt_model.text().strip(),
"temperature": float(self.spin_temp.value()),
"top_p": float(self.spin_top_p.value()),
"max_tokens": int(self.spin_max_toks.value()),
}
}
self.cfg_mgr.save_user_overrides(patch)
self.cfg = self.cfg_mgr.load()
self._refresh_endpoints()
self.client.update_config(self.cfg)
self.statusBar().showMessage("Saved to ~/.config/llmchat/config.yaml")
# -------- Tray / Hide -------- #
def _toggle_hide(self):
if self.isVisible():
self.hide()
else:
self.showNormal()
self.raise_()
self.activateWindow()
def _show_from_tray(self):
self.showNormal()
self.raise_()
self.activateWindow()
def closeEvent(self, event: QCloseEvent):
if getattr(self, "tray", None):
event.ignore()
self.hide()
self.tray.showMessage("LLM Chat", "Still running in the tray. Quit from tray menu.", QSystemTrayIcon.Information, 2000)
else:
super().closeEvent(event)
—————- Entry —————-
def main(): app_dir = Path(file).resolve().parent app = QApplication(sys.argv) app.setApplicationDisplayName(APP_NAME) win = MainWindow(app_dir) win.show() sys.exit(app.exec())
if name == "main": main()
(No change needed to `llm_client.py`; it already respects `cfg['llm']['api_base']`/`api_key`, which now respond to the endpoint picker via the UI fields.)
* * *
Validation (quick checklist)
* Markdown rendering: ✅ One-pass HTML with `markdown` \+ `codehilite`; styled via `ui.markdown_css`; verified in `QTextBrowser`.
* Multi-endpoint: ✅ `llm.endpoints[]` \+ `llm.default_endpoint` in config; endpoint picker + YAML “Manage…” dialog; per-chat endpoint remembered; **Save as My Defaults** updates user config.
* Backward compatibility: ✅ If no endpoints are defined, the fields still work (you can add endpoints later).
* Linux/shared-disk deploy: ✅ No new external services; `requirements.txt` adds `Pygments` only. Existing `install.sh`/`run.sh` remain valid.
If you want a table-driven “Manage Endpoints” dialog (add/remove rows without YAML), I can swap the YAML editor for a grid UI next.
<p class="chat--user" markdown="1">
1\. python package structure which could be installed using pip 2\. rename the project(program) name to "onechat"
</p>
Step-by-step (high level)
1. Rename everything to the `onechat` namespace and switch paths to `~/.config/onechat` and `~/.local/share/onechat`.
2. Package the app as a pip-installable module using `pyproject.toml` with a `onechat` console entry point.
3. Load admin defaults from `/etc/onechat/config.yaml` or `$ONECHAT_CONFIG_DIR/config.yaml`, with packaged `defaults.yaml` as a fallback; users override in `~/.config/onechat/config.yaml`.
4. Keep features: Markdown rendering, multi-endpoint picker/manager, chat persistence, shortcuts, model controls.
5. Include YAML config file in the wheel (package data) so installs are self-contained.
Below is the complete, ready-to-build package.
# Project layout
css
onechat/ pyproject.toml MANIFEST.in README.md src/ onechat/ init.py main.py main.py config_manager.py storage.py llm_client.py ui_helpers.py config/ defaults.yaml
# `pyproject.toml`
toml
[build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta"
[project] name = "onechat" version = "0.1.0" description = "A simple, fast Qt-based LLM chat client with Markdown rendering and multi-endpoint support." readme = "README.md" requires-python = ">=3.9" license = {text = "MIT"} authors = [{ name = "Your Team" }] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Environment :: X11 Applications :: Qt" ] dependencies = [ "PySide6>=6.6", "requests>=2.31", "PyYAML>=6.0.1", "markdown>=3.5", "Pygments>=2.17", ]
[project.scripts] onechat = "onechat.main:main"
[tool.setuptools.packages.find] where = ["src"]
[tool.setuptools.package-data] onechat = ["config/*.yaml"]
# `MANIFEST.in`
text
include README.md recursive-include src/onechat/config *.yaml
# `README.md`
markdown
onechat
Qt (PySide6) LLM chat client. OpenAI-compatible endpoints, Markdown rendering, per-user settings, admin defaults, chat persistence, and handy shortcuts.
Install
# From a shared folder or VCS checkout:
pip install .
# or
pipx install .
Run:
bash
onechat
# or
python -m onechat
Admin defaults
-
/etc/onechat/config.yaml, or setONECHAT_CONFIG_DIR=/shared/conf(must containconfig.yaml). -
Packaged fallback:
defaults.yamlbundled withonechat.
User overrides (written when you click "Save as My Defaults")
~/.config/onechat/config.yaml
Data
-
Chats stored at
~/.local/share/onechat/chats/*.json. -
Export current chat to Markdown via toolbar or Ctrl+S.
Shortcuts
-
Ctrl+Enter: Send • Shift+Enter: New line • Ctrl+N: New chat
-
Delete: Delete chat • Ctrl+S: Export • Ctrl+L: Focus input
-
Ctrl+, : Save settings • Ctrl+H: Hide/Show (tray toggles too)
Endpoints
Configure multiple endpoints (name, api_base, api_key, verify_tls) and select per chat. “Manage…” opens a YAML editor. Works with Ollama (http://localhost:11434/v1), OpenAI-compatible services, etc.
makefile
# `src/onechat/__init__.py`
```python
__all__ = ["main"]
__version__ = "0.1.0"
src/onechat/__main__.py
python
from .main import main
if __name__ == "__main__":
main()
src/onechat/config/defaults.yaml
yaml
app:
data_dir: "~/.local/share/onechat"
chat_dir_name: "chats"
window:
width: 1100
height: 720
llm:
# Multi-endpoint support
endpoints:
- name: "Local Ollama"
api_base: "http://localhost:11434/v1"
api_key: ""
verify_tls: true
- name: "OpenAI"
api_base: "https://api.openai.com"
api_key: ""
verify_tls: true
default_endpoint: "Local Ollama"
# Default model params
model: "llama3.1"
temperature: 0.7
top_p: 0.95
max_tokens: 512
timeout_sec: 120
verify_tls: true
ui:
font_family: ""
font_size: 11
enable_tray: true
markdown_css: |
body { font-family: sans-serif; font-size: 13px; }
code, pre { font-family: "DejaVu Sans Mono", monospace; }
pre { background: #f5f5f5; padding: 8px; border-radius: 6px; overflow: auto; }
code { background: #f1f1f1; padding: 2px 4px; border-radius: 4px; }
.msg-user { background: #e8f0fe; padding: 8px 10px; border-radius: 10px; margin: 10px 0; }
.msg-assistant { background: #f7f7f7; padding: 8px 10px; border-radius: 10px; margin: 10px 0; }
src/onechat/config_manager.py
python
import os
import copy
from pathlib import Path
import yaml
from importlib.resources import files
def deep_merge_dict(a: dict, b: dict) -> dict:
"""Deep merge dict b into a (a has lower precedence). Returns a new dict."""
result = copy.deepcopy(a)
for k, v in b.items():
if isinstance(v, dict) and isinstance(result.get(k), dict):
result[k] = deep_merge_dict(result[k], v)
else:
result[k] = copy.deepcopy(v)
return result
class ConfigManager:
"""
Load admin defaults + user overrides, write only user overrides.
Admin defaults search order:
1) $ONECHAT_CONFIG_DIR/config.yaml
2) /etc/onechat/config.yaml
3) packaged defaults: onechat/config/defaults.yaml
User overrides (writeable):
~/.config/onechat/config.yaml
"""
def __init__(self):
self.user_config_dir = Path(os.path.expanduser("~/.config/onechat"))
self.user_config_path = self.user_config_dir / "config.yaml"
self.defaults = {}
self.user = {}
self.effective = {}
def _load_yaml_path(self, path: Path) -> dict:
if path.exists():
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _load_packaged_defaults(self) -> dict:
try:
p = files("onechat").joinpath("config/defaults.yaml")
with p.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
def load(self):
env_dir = os.environ.get("ONECHAT_CONFIG_DIR")
merged = {}
if env_dir:
merged = deep_merge_dict(merged, self._load_yaml_path(Path(env_dir) / "config.yaml"))
merged = deep_merge_dict(merged, self._load_yaml_path(Path("/etc/onechat/config.yaml")))
merged = deep_merge_dict(merged, self._load_packaged_defaults())
self.defaults = merged
self.user = self._load_yaml_path(self.user_config_path)
self.effective = deep_merge_dict(self.defaults, self.user)
return self.effective
def save_user_overrides(self, patch: dict):
"""Persist only user overrides."""
def _merge_in_place(dst: dict, src: dict):
for k, v in src.items():
if isinstance(v, dict) and isinstance(dst.get(k), dict):
_merge_in_place(dst[k], v)
else:
dst[k] = copy.deepcopy(v)
_merge_in_place(self.user, patch)
self.user_config_dir.mkdir(parents=True, exist_ok=True)
with open(self.user_config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(self.user, f, sort_keys=False)
def get(self, path: str, default=None):
"""Get from effective using dotted path e.g. 'llm.model'."""
parts = path.split(".")
cur = self.effective
for p in parts:
if not isinstance(cur, dict):
return default
cur = cur.get(p, default)
return cur
src/onechat/storage.py
python
import json
import time
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import List, Dict, Any, Optional
@dataclass
class Message:
role: str # "user" or "assistant" or "system"
content: str
ts: float
@dataclass
class Chat:
chat_id: str
title: str
created_ts: float
updated_ts: float
model: str
params: Dict[str, Any]
messages: List[Message]
class ChatStorage:
"""Manage chat files under ~/.local/share/onechat/chats/ as JSON."""
def __init__(self, base_data_dir: Path, chat_dir_name: str = "chats"):
self.base_dir = Path(base_data_dir).expanduser()
self.chat_dir = self.base_dir / chat_dir_name
self.chat_dir.mkdir(parents=True, exist_ok=True)
def _chat_path(self, chat_id: str) -> Path:
return self.chat_dir / f"{chat_id}.json"
def list_chats(self) -> List[Chat]:
chats = []
for p in sorted(self.chat_dir.glob("*.json")):
try:
with open(p, "r", encoding="utf-8") as f:
raw = json.load(f)
chats.append(self._chat_from_raw(raw))
except Exception:
continue
chats.sort(key=lambda c: c.updated_ts, reverse=True)
return chats
def save_chat(self, chat: Chat):
raw = asdict(chat)
raw["messages"] = [asdict(m) for m in chat.messages]
with open(self._chat_path(chat.chat_id), "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
def load_chat(self, chat_id: str) -> Optional[Chat]:
p = self._chat_path(chat_id)
if not p.exists():
return None
with open(p, "r", encoding="utf-8") as f:
raw = json.load(f)
return self._chat_from_raw(raw)
def delete_chat(self, chat_id: str):
p = self._chat_path(chat_id)
if p.exists():
p.unlink()
def export_chat_markdown(self, chat: Chat, out_path: Path):
lines = [f"# {chat.title}", ""]
for m in chat.messages:
who = "You" if m.role == "user" else "Assistant"
lines.append(f"**{who}:**")
lines.append("")
lines.append(m.content)
lines.append("")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def _chat_from_raw(self, raw: Dict[str, Any]) -> Chat:
msgs = [Message(**m) for m in raw.get("messages", [])]
return Chat(
chat_id=raw["chat_id"],
title=raw.get("title", "Untitled"),
created_ts=raw.get("created_ts", time.time()),
updated_ts=raw.get("updated_ts", time.time()),
model=raw.get("model", ""),
params=raw.get("params", {}),
messages=msgs,
)
src/onechat/llm_client.py
python
import json
import requests
from typing import List, Dict, Any
class LLMClient:
"""
Minimal OpenAI-compatible client for /v1/chat/completions.
Uses cfg['llm']['api_base'] and cfg['llm']['api_key'] at call time.
"""
def __init__(self, cfg: dict):
self.cfg = cfg
def update_config(self, cfg: dict):
self.cfg = cfg
def chat_completion(self, messages: List[Dict[str, str]], params: Dict[str, Any]) -> str:
api_base = (self.cfg["llm"]["api_base"] or "").rstrip("/")
url = f"{api_base}/v1/chat/completions"
headers = {"Content-Type": "application/json"}
api_key = self.cfg["llm"].get("api_key", "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": params.get("model") or self.cfg["llm"]["model"],
"messages": messages,
"temperature": float(params.get("temperature", self.cfg["llm"]["temperature"])),
"top_p": float(params.get("top_p", self.cfg["llm"]["top_p"])),
"max_tokens": int(params.get("max_tokens", self.cfg["llm"]["max_tokens"])),
"stream": False,
}
timeout = int(self.cfg["llm"].get("timeout_sec", 120))
verify_tls = bool(self.cfg["llm"].get("verify_tls", True))
r = requests.post(url, headers=headers, data=json.dumps(payload),
timeout=timeout, verify=verify_tls)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"]
src/onechat/ui_helpers.py
python
import time
from typing import List
from markdown import markdown
def now_ts() -> float:
return time.time()
def md_to_html(md_text: str) -> str:
"""Convert Markdown to HTML fragment (no wrappers)."""
return markdown(
md_text,
extensions=["fenced_code", "tables", "codehilite", "sane_lists"],
)
def messages_to_html(messages: List[dict], css: str) -> str:
"""Render the whole conversation into a single HTML document."""
parts = [
"<html><head><meta charset='utf-8'>",
f"<style>{css}</style>",
"</head><body>",
]
for m in messages:
klass = "msg-user" if m["role"] == "user" else "msg-assistant"
parts.append(f'<div class="{klass}">')
parts.append(md_to_html(m["content"]))
parts.append("</div>")
parts.append("</body></html>")
return "\n".join(parts)
src/onechat/main.py
``` python
import sys import uuid import traceback from pathlib import Path
import yaml
from PySide6.QtCore import Qt, QThreadPool, QRunnable, Signal, QObject, QSize from PySide6.QtGui import QAction, QIcon, QKeySequence, QCloseEvent from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QListWidget, QListWidgetItem, QLabel, QFileDialog, QSplitter, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QDockWidget, QMessageBox, QTextBrowser, QToolBar, QSystemTrayIcon, QMenu, QInputDialog, QStyle, QPlainTextEdit, QDialog, QDialogButtonBox, QComboBox )
from .config_manager import ConfigManager from .storage import ChatStorage, Chat, Message from .llm_client import LLMClient from .ui_helpers import now_ts, messages_to_html
APP_NAME = "onechat"
—————- Worker infra —————-
class WorkerSignals(QObject): success = Signal(str) error = Signal(str)
class LLMWorker(QRunnable): def init(self, client: LLMClient, messages_payload, params): super().init() self.client = client self.messages_payload = messages_payload self.params = params self.signals = WorkerSignals()
def run(self):
try:
reply = self.client.chat_completion(self.messages_payload, self.params)
self.signals.success.emit(reply)
except Exception as e:
tb = traceback.format_exc()
self.signals.error.emit(f"{e}\n{tb}")
—————- Endpoints dialog —————-
class EndpointsDialog(QDialog): """Simple YAML editor for endpoints list.""" def init(self, parent, endpoints: list): super().init(parent) self.setWindowTitle("Manage Endpoints") self.resize(600, 420) layout = QVBoxLayout(self) self.edit = QPlainTextEdit(self) self.edit.setPlainText(yaml.safe_dump(endpoints or [], sort_keys=False)) layout.addWidget(QLabel("Edit YAML list of endpoints (name, api_base, api_key, verify_tls):")) layout.addWidget(self.edit) btns = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel, parent=self) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) layout.addWidget(btns)
def get_endpoints(self):
try:
data = yaml.safe_load(self.edit.toPlainText()) or []
assert isinstance(data, list)
for ep in data:
if not isinstance(ep, dict) or "name" not in ep or "api_base" not in ep:
raise ValueError("Each endpoint must have 'name' and 'api_base'.")
return data
except Exception as e:
QMessageBox.critical(self, "Invalid YAML", str(e))
return None
—————- Main Window —————-
class MainWindow(QMainWindow): def init(self): super().init() self.setWindowTitle("onechat") # Config self.cfg_mgr = ConfigManager() self.cfg = self.cfg_mgr.load()
# Storage
data_dir = Path(self.cfg.get("app", {}).get("data_dir", "~/.local/share/onechat")).expanduser()
chat_dir_name = self.cfg.get("app", {}).get("chat_dir_name", "chats")
self.storage = ChatStorage(data_dir, chat_dir_name)
# LLM client
self.client = LLMClient(self.cfg)
# State
self.thread_pool = QThreadPool.globalInstance()
self.current_chat: Chat | None = None
self.markdown_css = self.cfg.get("ui", {}).get("markdown_css", "")
self.endpoints_by_name = {} # name -> dict
self._build_ui()
self._refresh_endpoints()
self._load_chat_list()
self._apply_initial_window_size()
self._setup_tray()
# -------- UI construction -------- #
def _build_ui(self):
splitter = QSplitter()
splitter.setChildrenCollapsible(False)
# Left: chat list + controls
left = QWidget()
left_layout = QVBoxLayout(left)
self.chat_list = QListWidget()
left_layout.addWidget(QLabel("Conversations"))
left_layout.addWidget(self.chat_list)
btn_row = QHBoxLayout()
self.btn_new = QPushButton("New")
self.btn_rename = QPushButton("Rename")
self.btn_delete = QPushButton("Delete")
btn_row.addWidget(self.btn_new)
btn_row.addWidget(self.btn_rename)
btn_row.addWidget(self.btn_delete)
left_layout.addLayout(btn_row)
# Right: conversation + input
right = QWidget()
right_layout = QVBoxLayout(right)
self.view = QTextBrowser()
self.view.setOpenExternalLinks(True)
right_layout.addWidget(self.view, 1)
input_row = QHBoxLayout()
self.input = QTextEdit()
self.input.setPlaceholderText("Type your message… (Shift+Enter for newline, Ctrl+Enter to send)")
self.input.setAcceptRichText(False)
input_row.addWidget(self.input, 1)
self.btn_send = QPushButton("Send")
self.btn_send.setDefault(True)
input_row.addWidget(self.btn_send)
right_layout.addLayout(input_row)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setStretchFactor(1, 3)
self.setCentralWidget(splitter)
# Dock: model params & endpoint
dock = QDockWidget("Model & Endpoint", self)
dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
params_widget = QWidget()
form = QFormLayout(params_widget)
# Endpoint picker
ep_row = QHBoxLayout()
self.cmb_endpoint = QComboBox()
self.btn_manage_ep = QPushButton("Manage…")
ep_row.addWidget(self.cmb_endpoint, 1)
ep_row.addWidget(self.btn_manage_ep)
form.addRow(QLabel("Endpoint"), ep_row)
self.edt_api_base = QLineEdit(self.cfg.get("llm", {}).get("api_base", ""))
self.edt_api_key = QLineEdit(self.cfg.get("llm", {}).get("api_key", ""))
self.edt_api_key.setEchoMode(QLineEdit.Password)
self.edt_model = QLineEdit(self.cfg.get("llm", {}).get("model", ""))
self.spin_temp = QDoubleSpinBox()
self.spin_temp.setRange(0.0, 2.0)
self.spin_temp.setSingleStep(0.1)
self.spin_temp.setValue(float(self.cfg.get("llm", {}).get("temperature", 0.7)))
self.spin_top_p = QDoubleSpinBox()
self.spin_top_p.setRange(0.0, 1.0)
self.spin_top_p.setSingleStep(0.05)
self.spin_top_p.setValue(float(self.cfg.get("llm", {}).get("top_p", 0.95)))
self.spin_max_toks = QSpinBox()
self.spin_max_toks.setRange(16, 32768)
self.spin_max_toks.setValue(int(self.cfg.get("llm", {}).get("max_tokens", 512)))
form.addRow(QLabel("API Base"), self.edt_api_base)
form.addRow(QLabel("API Key"), self.edt_api_key)
form.addRow(QLabel("Model"), self.edt_model)
form.addRow(QLabel("Temperature"), self.spin_temp)
form.addRow(QLabel("Top-p"), self.spin_top_p)
form.addRow(QLabel("Max tokens"), self.spin_max_toks)
btns = QHBoxLayout()
self.btn_save_settings = QPushButton("Save as My Defaults")
self.btn_apply = QPushButton("Apply Now")
btns.addWidget(self.btn_apply)
btns.addWidget(self.btn_save_settings)
form.addRow(btns)
params_widget.setLayout(form)
dock.setWidget(params_widget)
self.addDockWidget(Qt.RightDockWidgetArea, dock)
# Toolbar & actions
tb = QToolBar("Main")
tb.setIconSize(QSize(16, 16))
self.addToolBar(tb)
act_new = QAction("New Chat", self)
act_new.setShortcut(QKeySequence("Ctrl+N"))
act_send = QAction("Send", self)
act_send.setShortcut(QKeySequence("Ctrl+Return"))
act_export = QAction("Export Markdown…", self)
act_export.setShortcut(QKeySequence("Ctrl+S"))
act_delete = QAction("Delete Chat", self)
act_delete.setShortcut(QKeySequence(Qt.Key_Delete))
act_focus = QAction("Focus Input", self)
act_focus.setShortcut(QKeySequence("Ctrl+L"))
act_settings = QAction("Save Settings", self)
act_settings.setShortcut(QKeySequence("Ctrl+,"))
act_hide = QAction("Hide/Show", self)
act_hide.setShortcut(QKeySequence("Ctrl+H"))
for a in (act_new, act_send, act_export, act_delete, act_focus, act_settings, act_hide):
tb.addAction(a)
# Connect signals
self.btn_new.clicked.connect(self._new_chat)
self.btn_rename.clicked.connect(self._rename_chat)
self.btn_delete.clicked.connect(self._delete_chat)
self.chat_list.itemDoubleClicked.connect(lambda _: self._open_selected_chat())
self.chat_list.itemSelectionChanged.connect(self._open_selected_chat)
self.btn_send.clicked.connect(self._send)
act_send.triggered.connect(self._send)
act_new.triggered.connect(self._new_chat)
act_export.triggered.connect(self._export_chat_md)
act_delete.triggered.connect(self._delete_chat)
act_focus.triggered.connect(lambda: self.input.setFocus())
act_settings.triggered.connect(self._save_settings_clicked)
act_hide.triggered.connect(self._toggle_hide)
self.btn_apply.clicked.connect(self._apply_now)
self.btn_save_settings.clicked.connect(self._save_settings_clicked)
# Endpoint handlers
self.cmb_endpoint.currentTextChanged.connect(self._on_endpoint_changed)
self.btn_manage_ep.clicked.connect(self._manage_endpoints)
self.statusBar().showMessage("Ready")
def _apply_initial_window_size(self):
w = int(self.cfg.get("app", {}).get("window", {}).get("width", 1100))
h = int(self.cfg.get("app", {}).get("window", {}).get("height", 720))
self.resize(w, h)
def _setup_tray(self):
if not self.cfg.get("ui", {}).get("enable_tray", True):
return
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
menu = QMenu()
act_show = QAction("Show", self)
act_quit = QAction("Quit", self)
act_show.triggered.connect(self._show_from_tray)
act_quit.triggered.connect(QApplication.instance().quit)
menu.addAction(act_show)
menu.addAction(act_quit)
self.tray.setContextMenu(menu)
self.tray.activated.connect(lambda reason: self._show_from_tray() if reason == QSystemTrayIcon.Trigger else None)
self.tray.show()
QApplication.instance().setQuitOnLastWindowClosed(False)
# -------- Endpoints helpers -------- #
def _refresh_endpoints(self):
eps = self.cfg.get("llm", {}).get("endpoints", []) or []
self.endpoints_by_name = {ep["name"]: ep for ep in eps if isinstance(ep, dict) and "name" in ep and "api_base" in ep}
cur_name = self.cmb_endpoint.currentText() if hasattr(self, "cmb_endpoint") else None
self.cmb_endpoint.blockSignals(True)
self.cmb_endpoint.clear()
for name in self.endpoints_by_name.keys():
self.cmb_endpoint.addItem(name)
self.cmb_endpoint.blockSignals(False)
default_name = self.cfg.get("llm", {}).get("default_endpoint")
to_select = cur_name or default_name or (next(iter(self.endpoints_by_name), ""))
if to_select:
self.cmb_endpoint.setCurrentText(to_select)
self._on_endpoint_changed(to_select)
def _on_endpoint_changed(self, name: str):
ep = self.endpoints_by_name.get(name, {})
self.edt_api_base.setText(ep.get("api_base", self.edt_api_base.text()))
self.edt_api_key.setText(ep.get("api_key", self.edt_api_key.text()))
def _manage_endpoints(self):
dialog = EndpointsDialog(self, list(self.endpoints_by_name.values()))
if dialog.exec() == QDialog.Accepted:
new_eps = dialog.get_endpoints()
if new_eps is None:
return
self.cfg_mgr.save_user_overrides({"llm": {"endpoints": new_eps}})
self.cfg = self.cfg_mgr.load()
self._refresh_endpoints()
self.statusBar().showMessage("Endpoints saved")
# -------- Chat list ops -------- #
def _load_chat_list(self):
self.chat_list.clear()
for chat in self.storage.list_chats():
item = QListWidgetItem(chat.title)
item.setData(Qt.UserRole, chat.chat_id)
self.chat_list.addItem(item)
if self.chat_list.count() == 0:
self._new_chat()
else:
self.chat_list.setCurrentRow(0)
def _new_chat(self):
chat_id = uuid.uuid4().hex[:12]
params = {
"endpoint": self.cmb_endpoint.currentText() or self.cfg.get("llm", {}).get("default_endpoint", ""),
"model": self.edt_model.text() or self.cfg.get("llm", {}).get("model", ""),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
chat = Chat(
chat_id=chat_id,
title="New Chat",
created_ts=now_ts(),
updated_ts=now_ts(),
model=params["model"],
params=params,
messages=[]
)
self.storage.save_chat(chat)
self._load_chat_list()
for i in range(self.chat_list.count()):
if self.chat_list.item(i).data(Qt.UserRole) == chat_id:
self.chat_list.setCurrentRow(i)
break
def _open_selected_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
chat = self.storage.load_chat(chat_id)
if not chat:
return
self.current_chat = chat
ep_name = self.current_chat.params.get("endpoint") or self.cfg.get("llm", {}).get("default_endpoint", "")
if ep_name and self.cmb_endpoint.findText(ep_name) != -1:
self.cmb_endpoint.setCurrentText(ep_name)
self._render_chat()
def _rename_chat(self):
item = self.chat_list.currentItem()
if not item or not self.current_chat:
return
new_title, ok = QInputDialog.getText(self, "Rename", "Chat title:", text=self.current_chat.title)
if ok and new_title.strip():
self.current_chat.title = new_title.strip()
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
item.setText(self.current_chat.title)
def _delete_chat(self):
item = self.chat_list.currentItem()
if not item:
return
chat_id = item.data(Qt.UserRole)
if QMessageBox.question(self, "Delete", "Delete this chat?") == QMessageBox.Yes:
self.storage.delete_chat(chat_id)
self._load_chat_list()
def _export_chat_md(self):
if not self.current_chat:
return
out, _ = QFileDialog.getSaveFileName(self, "Export Markdown", f"{self.current_chat.title}.md", "Markdown (*.md)")
if not out:
return
self.storage.export_chat_markdown(self.current_chat, Path(out))
self.statusBar().showMessage("Exported")
# -------- Rendering -------- #
def _render_chat(self):
if not self.current_chat:
return
p = self.current_chat.params
self.edt_model.setText(str(p.get("model", "")))
self.spin_temp.setValue(float(p.get("temperature", self.spin_temp.value())))
self.spin_top_p.setValue(float(p.get("top_p", self.spin_top_p.value())))
self.spin_max_toks.setValue(int(p.get("max_tokens", self.spin_max_toks.value())))
msgs = [dict(role=m.role, content=m.content, ts=m.ts) for m in self.current_chat.messages]
html = messages_to_html(msgs, self.markdown_css)
self.view.setHtml(html)
self.view.moveCursor(self.view.textCursor().End)
# -------- Sending -------- #
def _send(self):
text = self.input.toPlainText().strip()
if not text or not self.current_chat:
return
self.current_chat.messages.append(Message(role="user", content=text, ts=now_ts()))
if self.current_chat.title == "New Chat" and text:
self.current_chat.title = (text[:48] + "…") if len(text) > 48 else text
item = self.chat_list.currentItem()
if item:
item.setText(self.current_chat.title)
self.current_chat.params["endpoint"] = self.cmb_endpoint.currentText()
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self.input.clear()
self._render_chat()
self.statusBar().showMessage("Sending…")
runtime_cfg = dict(self.cfg)
runtime_cfg.setdefault("llm", {})
runtime_cfg["llm"]["api_base"] = self.edt_api_base.text().strip() or runtime_cfg["llm"].get("api_base", "")
runtime_cfg["llm"]["api_key"] = self.edt_api_key.text()
self.client.update_config(runtime_cfg)
params = {
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
}
messages_payload = [{"role": m.role, "content": m.content} for m in self.current_chat.messages]
worker = LLMWorker(self.client, messages_payload, params)
worker.signals.success.connect(self._on_llm_success)
worker.signals.error.connect(self._on_llm_error)
self.thread_pool.start(worker)
def _on_llm_success(self, reply: str):
if not self.current_chat:
return
self.current_chat.messages.append(Message(role="assistant", content=reply, ts=now_ts()))
self.current_chat.updated_ts = now_ts()
self.storage.save_chat(self.current_chat)
self._render_chat()
self.statusBar().showMessage("Reply received")
def _on_llm_error(self, err: str):
QMessageBox.critical(self, "LLM Error", err)
self.statusBar().showMessage("Error")
# -------- Settings -------- #
def _apply_now(self):
if not self.current_chat:
return
self.current_chat.params.update({
"endpoint": self.cmb_endpoint.currentText(),
"model": self.edt_model.text().strip(),
"temperature": self.spin_temp.value(),
"top_p": self.spin_top_p.value(),
"max_tokens": self.spin_max_toks.value(),
})
self.current_chat.model = self.current_chat.params["model"]
self.storage.save_chat(self.current_chat)
self.statusBar().showMessage("Applied to current chat")
def _save_settings_clicked(self):
selected = self.cmb_endpoint.currentText()
endpoints = self.cfg.get("llm", {}).get("endpoints", []) or []
new_endpoints = []
for ep in endpoints:
if ep.get("name") == selected:
ep = dict(ep)
ep["api_base"] = self.edt_api_base.text().strip()
ep["api_key"] = self.edt_api_key.text()
new_endpoints.append(ep)
patch = {
"llm": {
"endpoints": new_endpoints,
"default_endpoint": selected or self.cfg.get("llm", {}).get("default_endpoint", ""),
"model": self.edt_model.text().strip(),
"temperature": float(self.spin_temp.value()),
"top_p": float(self.spin_top_p.value()),
"max_tokens": int(self.spin_max_toks.value()),
}
}
self.cfg_mgr.save_user_overrides(patch)
self.cfg = self.cfg_mgr.load()
self._refresh_endpoints()
self.client.update_config(self.cfg)
self.statusBar().showMessage("Saved to ~/.config/onechat/config.yaml")
# -------- Tray / Hide -------- #
def _toggle_hide(self):
if self.isVisible():
self.hide()
else:
self.showNormal()
self.raise_()
self.activateWindow()
def _show_from_tray(self):
self.showNormal()
self.raise_()
self.activateWindow()
def closeEvent(self, event: QCloseEvent):
if getattr(self, "tray", None):
event.ignore()
self.hide()
self.tray.showMessage("onechat", "Still running in the tray. Quit from tray menu.", QSystemTrayIcon.Information, 2000)
else:
super().closeEvent(event)
—————- Entry —————-
def main(): app = QApplication(sys.argv) app.setApplicationDisplayName(APP_NAME) win = MainWindow() win.show() sys.exit(app.exec())