From 6b35ba3cf2b88fa9c9c83334a0c2e4bdd2aceb50 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Wed, 8 Apr 2026 13:57:52 +0200 Subject: [PATCH] Updated UI, with defaults set. --- scripts/sql-ai-gui | 2 +- sql_ai_gui_local_ollama.py | 1173 ++++++++++++++++++++++++------------ 2 files changed, 802 insertions(+), 373 deletions(-) diff --git a/scripts/sql-ai-gui b/scripts/sql-ai-gui index 14fb7a6..bf2e65c 100755 --- a/scripts/sql-ai-gui +++ b/scripts/sql-ai-gui @@ -10,5 +10,5 @@ export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland}" if [[ -x "$APP_DIR/.venv/bin/python" ]]; then exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui_local_ollama.py" else - exec python "$APP_DIR/sql_ai_gui.py" + exec python "$APP_DIR/sql_ai_gui_local_ollama.py" fi diff --git a/sql_ai_gui_local_ollama.py b/sql_ai_gui_local_ollama.py index bb3c564..5ecd0ac 100755 --- a/sql_ai_gui_local_ollama.py +++ b/sql_ai_gui_local_ollama.py @@ -1,90 +1,149 @@ #!/usr/bin/env python3 """ -JR SQL AI GUI (local Ollama) +JR SQL AI GUI (local Ollama, resilient version v5) -- Uses the local `ollama` CLI for model/runtime management. -- Uses the local Ollama HTTP API for fast streaming generation. -- Friendly to Arch Linux + Hyprland / Wayland. +Improvements over v4: +- auto-prefill BASE_MODEL / EXPERT_MODEL / EXTRA_MODELS from .env, .env.example and Modelfile +- fills missing GUI fields before maintenance actions, so buttons can usually be used directly +- clearer validation messages with source hints +- keeps inference model separate from maintenance config """ from __future__ import annotations import json import os import re +import shlex +import shutil import subprocess import sys -import time +import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Callable, Iterable, List, Optional +from typing import Callable, List, Optional, Tuple import requests from PySide6.QtCore import Qt, QThread, Signal, QTimer -from PySide6.QtGui import QFont +from PySide6.QtGui import QAction, QFont, QKeySequence from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, + QFileDialog, + QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, - QPlainTextEdit, QPushButton, + QPlainTextEdit, QSplitter, QStatusBar, - QTextBrowser, QVBoxLayout, QWidget, + QTextBrowser, ) -DEFAULT_OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434") -DEFAULT_OLLAMA_BIN = os.environ.get("OLLAMA_BIN", "ollama") -DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "jr-sql-expert:latest") -DEFAULT_KEEP_ALIVE = os.environ.get("OLLAMA_KEEP_ALIVE", "10m") -DEFAULT_PROJECT_ROOT = Path(os.environ.get("JR_SQL_AI_ROOT", Path(__file__).resolve().parent)) -DEFAULT_MODEFILE = Path(os.environ.get("MODEFILE", DEFAULT_PROJECT_ROOT / "Modelfile")) -DEFAULT_EXPERT_MODEL = os.environ.get("EXPERT_MODEL", "jr-sql-expert") -DEFAULT_BASE_MODEL = os.environ.get("BASE_MODEL", "") - - -def load_env_file(env_file: Path) -> None: - if not env_file.is_file(): - return - for raw_line in env_file.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) - - -load_env_file(DEFAULT_PROJECT_ROOT / ".env") -DEFAULT_EXPERT_MODEL = os.environ.get("EXPERT_MODEL", DEFAULT_EXPERT_MODEL) -DEFAULT_BASE_MODEL = os.environ.get("BASE_MODEL", DEFAULT_BASE_MODEL) -DEFAULT_OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL) -DEFAULT_OLLAMA_BIN = os.environ.get("OLLAMA_BIN", DEFAULT_OLLAMA_BIN) -DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", DEFAULT_MODEL) - +ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") SQL_KW_RE = re.compile( r"\b(select|from|where|join|group|order|having|insert|update|delete|create|alter|drop|with|merge)\b", re.IGNORECASE, ) FENCE_RE = re.compile(r"```(\w+)?\s*\n(.*?)\n```", re.DOTALL) +FROM_RE = re.compile(r"^\s*FROM\s+(.+?)\s*$", re.IGNORECASE | re.MULTILINE) +VAR_RE = re.compile(r"\$\{?BASE_MODEL\}?", re.IGNORECASE) + + +MENU_STYLE_SHEET = """ +QMenuBar { + background-color: #2b2f34; + color: #f1f3f5; + border: 1px solid #3a3f46; +} +QMenuBar::item { + background: transparent; + color: #f1f3f5; + padding: 5px 10px; + margin: 1px 2px; + border-radius: 4px; +} +QMenuBar::item:selected { + background: #3a4250; + color: #ffffff; +} +QMenuBar::item:pressed { + background: #465062; + color: #ffffff; +} +QMenu { + background-color: #25292e; + color: #f1f3f5; + border: 1px solid #3a3f46; + padding: 4px; +} +QMenu::item { + background-color: transparent; + color: #f1f3f5; + padding: 6px 28px 6px 12px; + border-radius: 4px; +} +QMenu::item:selected { + background-color: #3a4250; + color: #ffffff; +} +QMenu::item:disabled { + color: #8a9099; +} +QMenu::separator { + height: 1px; + background: #454b54; + margin: 6px 8px; +} +""" + + +# ----------------------------- +# Helpers +# ----------------------------- +def strip_ansi(text: str) -> str: + return ANSI_RE.sub("", text or "") def human_error(e: Exception) -> str: - return f"{type(e).__name__}: {e}" + return strip_ansi(f"{type(e).__name__}: {e}") + + +def read_env_file(env_path: Path) -> dict[str, str]: + data: dict[str, str] = {} + if not env_path.is_file(): + return data + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + data[key] = value + return data + + +def write_env_file(env_path: Path, updates: dict[str, str]) -> None: + current = read_env_file(env_path) + current.update({k: v for k, v in updates.items() if v is not None}) + lines = [f"{k}={v}" for k, v in current.items()] + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def extract_sql_blocks(markdown_text: str) -> List[str]: blocks: List[str] = [] - for match in FENCE_RE.finditer(markdown_text): - lang = (match.group(1) or "").strip().lower() - body = (match.group(2) or "").strip() + for m in FENCE_RE.finditer(markdown_text): + lang = (m.group(1) or "").strip().lower() + body = (m.group(2) or "").strip() if not body: continue if lang in {"sql", "tsql", "t-sql", "mssql"}: @@ -100,109 +159,157 @@ def build_sql_only_text(blocks: List[str]) -> str: return "\n\n-- ----------------------------------------\n\n".join(blocks) + "\n" -def cli_env(base_url: str) -> dict[str, str]: - env = os.environ.copy() - env["OLLAMA_HOST"] = base_url.strip() - return env +def looks_like_registry_pull_error(text: str) -> bool: + normalized = strip_ansi(text).lower() + return "pull model manifest" in normalized and "file does not exist" in normalized -def run_cli( - ollama_bin: str, - base_url: str, - args: List[str], - timeout: int = 60, -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ollama_bin, *args], - env=cli_env(base_url), - text=True, - capture_output=True, - timeout=timeout, - check=False, +def detect_project_root(start: Path) -> Path: + start = start.resolve() + candidates = [start, *start.parents] + for path in candidates: + if (path / ".env").exists() or (path / ".env.example").exists() or (path / "Modelfile").exists() or (path / "scripts").is_dir(): + return path + return start + + +def parse_modelfile_for_base(modelfile_path: Path) -> Tuple[str, str]: + """ + Returns: (base_model, source_hint) + source_hint is empty when nothing was found. + """ + if not modelfile_path.is_file(): + return "", "" + try: + text = modelfile_path.read_text(encoding="utf-8") + except Exception: + return "", "" + + m = FROM_RE.search(text) + if not m: + return "", "" + + raw = m.group(1).strip().strip('"').strip("'") + if not raw: + return "", "" + if VAR_RE.search(raw): + return "", f"{modelfile_path} enthält nur einen BASE_MODEL-Platzhalter" + return raw, f"{modelfile_path} (FROM ...)" + + +@dataclass +class ResolvedDefaults: + ollama_url: str + ollama_bin: str + base_model: str + expert_model: str + extra_models: str + inference_model: str + modelfile_path: Path + source_notes: List[str] + + + +def resolve_defaults(root: Path, explicit_modelfile: Optional[Path] = None, current_inference_model: str = "") -> ResolvedDefaults: + env_path = root / ".env" + env_example_path = root / ".env.example" + env_data = read_env_file(env_path) + env_example_data = read_env_file(env_example_path) + modelfile_path = explicit_modelfile.resolve() if explicit_modelfile else (root / "Modelfile").resolve() + + notes: List[str] = [] + + def pick(key: str, default: str = "") -> str: + if key in env_data and env_data[key].strip(): + notes.append(f"{key} aus .env") + return env_data[key].strip() + if key in env_example_data and env_example_data[key].strip(): + notes.append(f"{key} aus .env.example") + return env_example_data[key].strip() + value = os.environ.get(key, "").strip() + if value: + notes.append(f"{key} aus Umgebung") + return value + return default + + ollama_url = pick("OLLAMA_URL", os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")) + ollama_bin = pick("OLLAMA_BIN", "ollama") + base_model = pick("BASE_MODEL", "") + expert_model = pick("EXPERT_MODEL", "jr-sql-expert") + extra_models = pick("EXTRA_MODELS", "") + + if not base_model: + modelfile_base, modelfile_note = parse_modelfile_for_base(modelfile_path) + if modelfile_base: + base_model = modelfile_base + notes.append(f"BASE_MODEL aus {modelfile_note}") + elif modelfile_note: + notes.append(modelfile_note) + + inference_model = ( + current_inference_model.strip() + or os.environ.get("OLLAMA_MODEL", "").strip() + or (f"{expert_model}:latest" if expert_model else "jr-sql-expert:latest") + ) + + return ResolvedDefaults( + ollama_url=ollama_url, + ollama_bin=ollama_bin, + base_model=base_model, + expert_model=expert_model, + extra_models=extra_models, + inference_model=inference_model, + modelfile_path=modelfile_path, + source_notes=notes, ) -def list_models_via_cli(ollama_bin: str, base_url: str) -> List[str]: - proc = run_cli(ollama_bin, base_url, ["ls"], timeout=20) - if proc.returncode != 0: - raise RuntimeError((proc.stderr or proc.stdout or "ollama ls failed").strip()) +@dataclass +class AppConfig: + project_root: Path + env_path: Path + modelfile_path: Path + ollama_url: str + ollama_bin: str + base_model: str + expert_model: str + extra_models: str + default_inference_model: str - models: List[str] = [] - for raw in (proc.stdout or "").splitlines(): - line = raw.strip() - if not line or line.lower().startswith("name ") or line.lower() == "name": - continue - first = line.split()[0] - if first and first != "NAME": - models.append(first) - if not models: - raise RuntimeError("Keine Modelle aus 'ollama ls' geparst.") - return models - - -def check_api_ready(base_url: str, timeout: float = 2.5) -> bool: - try: - r = requests.get(base_url.rstrip("/") + "/api/tags", timeout=(timeout, timeout)) - r.raise_for_status() - data = r.json() - return isinstance(data, dict) - except Exception: - return False - - -def maybe_start_runtime(ollama_bin: str, base_url: str) -> bool: - if check_api_ready(base_url): - return True - - service = os.environ.get("OLLAMA_SYSTEMD_SERVICE", "ollama.service") - for cmd in (["systemctl", "--user", "start", service], ["systemctl", "start", service]): - try: - subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False) - for _ in range(15): - if check_api_ready(base_url): - return True - time.sleep(1) - except Exception: - pass - - try: - subprocess.Popen( - [ollama_bin, "serve"], - env=cli_env(base_url), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, + @classmethod + def load(cls, root: Path) -> "AppConfig": + defaults = resolve_defaults(root) + return cls( + project_root=root, + env_path=root / ".env", + modelfile_path=defaults.modelfile_path, + ollama_url=defaults.ollama_url, + ollama_bin=defaults.ollama_bin, + base_model=defaults.base_model, + expert_model=defaults.expert_model, + extra_models=defaults.extra_models, + default_inference_model=defaults.inference_model, ) - for _ in range(20): - if check_api_ready(base_url): - return True - time.sleep(1) - except Exception: - pass - return check_api_ready(base_url) - - -def render_modelfile(template_path: Path, base_model: str) -> str: - if not template_path.is_file(): - raise FileNotFoundError(f"Modelfile nicht gefunden: {template_path}") - if not base_model.strip(): - raise ValueError("BASE_MODEL ist leer.") - return template_path.read_text(encoding="utf-8").replace("${BASE_MODEL}", base_model.strip()) + def extra_model_list(self) -> List[str]: + return [item for item in shlex.split(self.extra_models) if item.strip()] +# ----------------------------- +# Workers +# ----------------------------- @dataclass class GenerateParams: base_url: str model: str prompt: str stream: bool = True - keep_alive: str = DEFAULT_KEEP_ALIVE + keep_alive: str = "30m" class GenerateWorker(QThread): chunk = Signal(str) - done = Signal(str, dict) + done = Signal(str) error = Signal(str) def __init__(self, params: GenerateParams): @@ -211,7 +318,6 @@ class GenerateWorker(QThread): def run(self) -> None: try: - session = requests.Session() url = self.params.base_url.rstrip("/") + "/api/generate" payload = { "model": self.params.model, @@ -219,126 +325,170 @@ class GenerateWorker(QThread): "stream": self.params.stream, "keep_alive": self.params.keep_alive, } - - with session.post(url, json=payload, stream=self.params.stream, timeout=(5, 1800)) as response: - response.raise_for_status() - + with requests.post(url, json=payload, stream=self.params.stream, timeout=(5, 3600)) as r: + r.raise_for_status() if not self.params.stream: - data = response.json() - self.done.emit(data.get("response", ""), data) + data = r.json() + self.done.emit(data.get("response", "")) return - full: List[str] = [] - last_obj: dict = {} - for raw_line in response.iter_lines(decode_unicode=True): - if not raw_line: + full: list[str] = [] + for line in r.iter_lines(decode_unicode=True): + if not line: continue - try: - obj = json.loads(raw_line) - except json.JSONDecodeError as exc: - raise RuntimeError(f"Ungültiger Streaming-JSON-Chunk: {exc}") from exc - last_obj = obj - part = obj.get("response", "") or "" + obj = json.loads(line) + part = obj.get("response", "") if part: full.append(part) self.chunk.emit(part) if obj.get("done", False): break + self.done.emit("".join(full)) + except Exception as e: + self.error.emit(human_error(e)) - self.done.emit("".join(full), last_obj) - except Exception as exc: - self.error.emit(human_error(exc)) + +@dataclass +class CommandStep: + title: str + argv: list[str] + cwd: Optional[str] = None + env: Optional[dict[str, str]] = None class CommandWorker(QThread): - status = Signal(str) - done = Signal(str) + log = Signal(str) + done = Signal() error = Signal(str) - def __init__( - self, - program: str, - args: List[str], - env: dict[str, str], - cwd: Optional[str] = None, - label: str = "command", - ): + def __init__(self, steps: List[CommandStep], cleanup: Optional[Callable[[], None]] = None): super().__init__() - self.program = program - self.args = args - self.env = env - self.cwd = cwd - self.label = label + self.steps = steps + self.cleanup = cleanup + + def _emit_line(self, text: str) -> None: + cleaned = strip_ansi(text.rstrip("\n\r")) + if cleaned: + self.log.emit(cleaned) def run(self) -> None: try: - proc = subprocess.Popen( - [self.program, *self.args], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - cwd=self.cwd, - env=self.env, - ) - assert proc.stdout is not None - lines: List[str] = [] - for raw_line in proc.stdout: - line = raw_line.strip() - if line: - lines.append(line) - self.status.emit(line) - rc = proc.wait() - joined = "\n".join(lines) - if rc != 0: - raise RuntimeError(joined or f"{self.label} failed with exit code {rc}") - self.done.emit(joined) - except Exception as exc: - self.error.emit(human_error(exc)) + for step in self.steps: + self._emit_line(f"==> {step.title}") + proc = subprocess.Popen( + step.argv, + cwd=step.cwd, + env=step.env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert proc.stdout is not None + for line in proc.stdout: + self._emit_line(line) + rc = proc.wait() + if rc != 0: + raise RuntimeError(f"Command failed ({rc}): {' '.join(step.argv)}") + self.done.emit() + except Exception as e: + self.error.emit(human_error(e)) + finally: + if self.cleanup is not None: + try: + self.cleanup() + except Exception: + pass +# ----------------------------- +# Main Window +# ----------------------------- class MainWindow(QMainWindow): - def __init__(self): + def __init__(self) -> None: super().__init__() self.setWindowTitle("JR SQL AI GUI (local Ollama)") self._gen_worker: Optional[GenerateWorker] = None self._cmd_worker: Optional[CommandWorker] = None + self._temp_modelfile: Optional[Path] = None self._raw_markdown = "" - self._last_metrics: dict = {} + + self.project_root = detect_project_root(Path(__file__).resolve().parent) + self.config = AppConfig.load(self.project_root) self._render_timer = QTimer(self) self._render_timer.setInterval(200) self._render_timer.timeout.connect(self._render_markdown_throttled) + self._build_menu_bar() + self._apply_dark_menu_style() + root = QWidget() self.setCentralWidget(root) layout = QVBoxLayout(root) + config_group = QGroupBox("Konfiguration") + config_layout = QGridLayout(config_group) + layout.addWidget(config_group) + + row = 0 + config_layout.addWidget(QLabel("Projekt-Root:"), row, 0) + self.project_root_edit = QLineEdit(str(self.config.project_root)) + config_layout.addWidget(self.project_root_edit, row, 1, 1, 3) + self.btn_browse_root = QPushButton("Wählen") + self.btn_browse_root.clicked.connect(self.on_browse_root) + config_layout.addWidget(self.btn_browse_root, row, 4) + row += 1 + + config_layout.addWidget(QLabel("Ollama URL:"), row, 0) + self.base_url = QLineEdit(self.config.ollama_url) + config_layout.addWidget(self.base_url, row, 1) + config_layout.addWidget(QLabel("Ollama Binärdatei:"), row, 2) + self.ollama_bin = QLineEdit(self.config.ollama_bin) + config_layout.addWidget(self.ollama_bin, row, 3) + self.btn_reload_config = QPushButton("Neu laden") + self.btn_reload_config.clicked.connect(self.reload_config_from_disk) + config_layout.addWidget(self.btn_reload_config, row, 4) + row += 1 + + config_layout.addWidget(QLabel("Base Model:"), row, 0) + self.base_model_edit = QLineEdit(self.config.base_model) + self.base_model_edit.setPlaceholderText("z. B. qwen2.5-coder:14b") + config_layout.addWidget(self.base_model_edit, row, 1) + config_layout.addWidget(QLabel("Expert Model:"), row, 2) + self.expert_model_edit = QLineEdit(self.config.expert_model) + config_layout.addWidget(self.expert_model_edit, row, 3) + self.btn_save_env = QPushButton(".env speichern") + self.btn_save_env.clicked.connect(self.save_env) + config_layout.addWidget(self.btn_save_env, row, 4) + row += 1 + + config_layout.addWidget(QLabel("Extra Models:"), row, 0) + self.extra_models_edit = QLineEdit(self.config.extra_models) + config_layout.addWidget(self.extra_models_edit, row, 1) + config_layout.addWidget(QLabel("Modelfile:"), row, 2) + self.modelfile_edit = QLineEdit(str(self.config.modelfile_path)) + config_layout.addWidget(self.modelfile_edit, row, 3) + self.btn_browse_modelfile = QPushButton("Datei") + self.btn_browse_modelfile.clicked.connect(self.on_browse_modelfile) + config_layout.addWidget(self.btn_browse_modelfile, row, 4) + row += 1 + + self.lbl_defaults_hint = QLabel("") + self.lbl_defaults_hint.setWordWrap(True) + config_layout.addWidget(self.lbl_defaults_hint, row, 0, 1, 5) + top = QHBoxLayout() layout.addLayout(top) - top.addWidget(QLabel("Ollama URL:")) - self.base_url = QLineEdit(DEFAULT_OLLAMA_BASE_URL) - self.base_url.setMinimumWidth(260) - top.addWidget(self.base_url, 2) - - top.addWidget(QLabel("Ollama Bin:")) - self.ollama_bin = QLineEdit(DEFAULT_OLLAMA_BIN) - self.ollama_bin.setMinimumWidth(140) - top.addWidget(self.ollama_bin, 1) - - top.addWidget(QLabel("Model:")) + top.addWidget(QLabel("Antwortmodell:")) self.model = QComboBox() self.model.setEditable(True) - self.model.addItem(DEFAULT_MODEL) - self.model.setCurrentText(DEFAULT_MODEL) - self.model.setMinimumWidth(220) - top.addWidget(self.model, 1) - - self.btn_runtime = QPushButton("Runtime prüfen") - self.btn_runtime.clicked.connect(self.ensure_runtime) - top.addWidget(self.btn_runtime) + self.model.addItem(self.config.default_inference_model) + self.model.setCurrentText(self.config.default_inference_model) + self.model.setMinimumWidth(280) + top.addWidget(self.model, 2) self.btn_refresh_models = QPushButton("Modelle laden") self.btn_refresh_models.clicked.connect(self.refresh_models) @@ -348,31 +498,32 @@ class MainWindow(QMainWindow): self.chk_stream.setChecked(True) top.addWidget(self.chk_stream) + self.chk_warmup = QCheckBox("Warmup nach Build/Update") + self.chk_warmup.setChecked(False) + top.addWidget(self.chk_warmup) + splitter = QSplitter(Qt.Horizontal) layout.addWidget(splitter, 1) left = QWidget() left_l = QVBoxLayout(left) - left_l.addWidget(QLabel("Prompt / Kontext")) self.prompt = QPlainTextEdit() self.prompt.setPlaceholderText("Prompt + Kontext hier einfügen …") self.prompt.setFont(QFont("Monospace", 10)) left_l.addWidget(self.prompt, 1) - btn_row = QHBoxLayout() + send_row = QHBoxLayout() self.btn_send = QPushButton("An AI senden") self.btn_send.clicked.connect(self.on_send) - btn_row.addWidget(self.btn_send) - + send_row.addWidget(self.btn_send) self.btn_clear = QPushButton("Leeren") self.btn_clear.clicked.connect(lambda: self.prompt.setPlainText("")) - btn_row.addWidget(self.btn_clear) - left_l.addLayout(btn_row) + send_row.addWidget(self.btn_clear) + left_l.addLayout(send_row) right = QWidget() right_l = QVBoxLayout(right) - right_l.addWidget(QLabel("Antwort (Markdown gerendert)")) self.response_view = QTextBrowser() self.response_view.setOpenExternalLinks(True) @@ -383,9 +534,16 @@ class MainWindow(QMainWindow): self.response_raw.setReadOnly(True) self.response_raw.setFont(QFont("Monospace", 10)) self.response_raw.setPlaceholderText("Raw Antwort (für Copy/Debug).") - self.response_raw.setMaximumHeight(180) + self.response_raw.setMaximumHeight(140) right_l.addWidget(self.response_raw) + right_l.addWidget(QLabel("Wartungs-/Kommando-Log")) + self.command_log = QPlainTextEdit() + self.command_log.setReadOnly(True) + self.command_log.setFont(QFont("Monospace", 10)) + self.command_log.setMaximumHeight(180) + right_l.addWidget(self.command_log) + right_btn_row = QHBoxLayout() self.btn_copy = QPushButton("Antwort kopieren") self.btn_copy.clicked.connect(self.copy_response) @@ -395,120 +553,301 @@ class MainWindow(QMainWindow): self.btn_copy_sql.clicked.connect(self.copy_sql_only) right_btn_row.addWidget(self.btn_copy_sql) - self.btn_model_pull = QPushButton("Modell aktualisieren") - self.btn_model_pull.clicked.connect(self.on_pull_model) - right_btn_row.addWidget(self.btn_model_pull) + self.btn_update_base = QPushButton("Basismodell aktualisieren") + self.btn_update_base.clicked.connect(self.on_update_base_model) + right_btn_row.addWidget(self.btn_update_base) self.btn_rebuild_expert = QPushButton("Expert neu bauen") self.btn_rebuild_expert.clicked.connect(self.on_rebuild_expert) right_btn_row.addWidget(self.btn_rebuild_expert) - self.btn_close = QPushButton("Close") - self.btn_close.clicked.connect(self.on_close) + self.btn_update_all = QPushButton("Alles aktualisieren") + self.btn_update_all.clicked.connect(self.on_update_all) + right_btn_row.addWidget(self.btn_update_all) + + self.btn_close = QPushButton("Beenden") + self.btn_close.clicked.connect(self.on_close_requested) right_btn_row.addWidget(self.btn_close) right_l.addLayout(right_btn_row) + splitter.addWidget(left) splitter.addWidget(right) - splitter.setSizes([520, 820]) + splitter.setSizes([520, 840]) self.status = QStatusBar() self.setStatusBar(self.status) self.status.showMessage("Bereit.") - QTimer.singleShot(250, self.ensure_runtime) - QTimer.singleShot(600, self.refresh_models) + self.apply_default_hints() + QTimer.singleShot(300, self.refresh_models) + + def _apply_dark_menu_style(self) -> None: + menu_bar = self.menuBar() + menu_bar.setNativeMenuBar(False) + app = QApplication.instance() + if app is not None: + current = app.styleSheet() or "" + if MENU_STYLE_SHEET not in current: + app.setStyleSheet((current + "\n" + MENU_STYLE_SHEET).strip()) + menu_bar.update() + + def _build_menu_bar(self) -> None: + menu_bar = self.menuBar() + menu_bar.setNativeMenuBar(False) + + file_menu = menu_bar.addMenu("&Datei") + + act_reload = QAction("Konfiguration neu laden", self) + act_reload.setShortcut(QKeySequence.Refresh) + act_reload.triggered.connect(self.reload_config_from_disk) + file_menu.addAction(act_reload) + + act_save_env = QAction(".env speichern", self) + act_save_env.setShortcut(QKeySequence.Save) + act_save_env.triggered.connect(self.save_env) + file_menu.addAction(act_save_env) + + file_menu.addSeparator() + + act_exit = QAction("Beenden", self) + act_exit.setShortcut(QKeySequence.Quit) + act_exit.triggered.connect(self.on_close_requested) + file_menu.addAction(act_exit) + + actions_menu = menu_bar.addMenu("&Aktionen") + + act_refresh_models = QAction("Modelle laden", self) + act_refresh_models.triggered.connect(self.refresh_models) + actions_menu.addAction(act_refresh_models) + + actions_menu.addSeparator() + + act_update_base = QAction("Basismodell aktualisieren", self) + act_update_base.triggered.connect(self.on_update_base_model) + actions_menu.addAction(act_update_base) + + act_rebuild_expert = QAction("Expert neu bauen", self) + act_rebuild_expert.triggered.connect(self.on_rebuild_expert) + actions_menu.addAction(act_rebuild_expert) + + act_update_all = QAction("Alles aktualisieren", self) + act_update_all.triggered.connect(self.on_update_all) + actions_menu.addAction(act_update_all) + + help_menu = menu_bar.addMenu("&Hilfe") + act_about = QAction("Über", self) + act_about.triggered.connect(self.show_about_dialog) + help_menu.addAction(act_about) + + # ----------- config helpers ----------- + def current_config(self) -> AppConfig: + root = Path(self.project_root_edit.text().strip() or ".").resolve() + return AppConfig( + project_root=root, + env_path=root / ".env", + modelfile_path=Path(self.modelfile_edit.text().strip() or (root / "Modelfile")).resolve(), + ollama_url=self.base_url.text().strip() or "http://127.0.0.1:11434", + ollama_bin=self.ollama_bin.text().strip() or "ollama", + base_model=self.base_model_edit.text().strip(), + expert_model=self.expert_model_edit.text().strip() or "jr-sql-expert", + extra_models=self.extra_models_edit.text().strip(), + default_inference_model=self.model.currentText().strip() or "jr-sql-expert:latest", + ) + + def apply_default_hints(self) -> None: + cfg = self.current_config() + defaults = resolve_defaults(cfg.project_root, cfg.modelfile_path, cfg.default_inference_model) + self.lbl_defaults_hint.setText( + "Vorbelegung: " + (", ".join(defaults.source_notes) if defaults.source_notes else "keine Dateiwerte gefunden") + ) + + def autofill_missing_fields_from_disk(self) -> None: + cfg = self.current_config() + defaults = resolve_defaults(cfg.project_root, cfg.modelfile_path, cfg.default_inference_model) + + if not self.base_url.text().strip() and defaults.ollama_url: + self.base_url.setText(defaults.ollama_url) + if not self.ollama_bin.text().strip() and defaults.ollama_bin: + self.ollama_bin.setText(defaults.ollama_bin) + if not self.base_model_edit.text().strip() and defaults.base_model: + self.base_model_edit.setText(defaults.base_model) + if not self.expert_model_edit.text().strip() and defaults.expert_model: + self.expert_model_edit.setText(defaults.expert_model) + if not self.extra_models_edit.text().strip() and defaults.extra_models: + self.extra_models_edit.setText(defaults.extra_models) + if not self.modelfile_edit.text().strip() and defaults.modelfile_path: + self.modelfile_edit.setText(str(defaults.modelfile_path)) + if not self.model.currentText().strip() and defaults.inference_model: + self.model.setCurrentText(defaults.inference_model) + + self.apply_default_hints() + + def validate_runtime(self, cfg: AppConfig) -> Optional[str]: + if shutil.which(cfg.ollama_bin) is None: + return f"Ollama-Binärdatei nicht gefunden: {cfg.ollama_bin}" + return None + + def validate_rebuild(self, cfg: AppConfig) -> Optional[str]: + if not cfg.base_model: + return ( + "BASE_MODEL konnte nicht vorbelegt werden. Bitte setze BASE_MODEL in .env, .env.example " + "oder trage es im GUI ein. Falls dein Modelfile nur FROM ${BASE_MODEL} nutzt, braucht die GUI " + "zusätzlich diesen Wert." + ) + if not cfg.expert_model: + return "EXPERT_MODEL darf nicht leer sein." + if not cfg.modelfile_path.is_file(): + return f"Modelfile nicht gefunden: {cfg.modelfile_path}" + return None + + def validate_update_base(self, cfg: AppConfig) -> Optional[str]: + if not cfg.base_model: + return ( + "BASE_MODEL konnte nicht vorbelegt werden. Bitte setze BASE_MODEL in .env, .env.example " + "oder trage es im GUI ein." + ) + return None + + def on_browse_root(self) -> None: + path = QFileDialog.getExistingDirectory(self, "Projekt-Root wählen", self.project_root_edit.text().strip() or str(Path.cwd())) + if not path: + return + self.project_root_edit.setText(path) + self.reload_config_from_disk() + + def on_browse_modelfile(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Modelfile wählen", self.project_root_edit.text().strip() or str(Path.cwd())) + if path: + self.modelfile_edit.setText(path) + self.autofill_missing_fields_from_disk() + + def reload_config_from_disk(self) -> None: + root = Path(self.project_root_edit.text().strip() or self.project_root).resolve() + self.config = AppConfig.load(root) + self.project_root_edit.setText(str(self.config.project_root)) + self.base_url.setText(self.config.ollama_url) + self.ollama_bin.setText(self.config.ollama_bin) + self.base_model_edit.setText(self.config.base_model) + self.expert_model_edit.setText(self.config.expert_model) + self.extra_models_edit.setText(self.config.extra_models) + self.modelfile_edit.setText(str(self.config.modelfile_path)) + if not self.model.currentText().strip(): + self.model.setCurrentText(self.config.default_inference_model) + self.apply_default_hints() + self.status.showMessage("Konfiguration neu geladen.", 2500) + self.refresh_models() + + def save_env(self) -> None: + cfg = self.current_config() + cfg.project_root.mkdir(parents=True, exist_ok=True) + write_env_file( + cfg.env_path, + { + "OLLAMA_URL": cfg.ollama_url, + "OLLAMA_BIN": cfg.ollama_bin, + "BASE_MODEL": cfg.base_model, + "EXPERT_MODEL": cfg.expert_model, + "EXTRA_MODELS": cfg.extra_models, + }, + ) + self.apply_default_hints() + self.status.showMessage(f".env gespeichert: {cfg.env_path}", 3500) + + # ----------- UI helpers ----------- + def ui_busy(self, busy: bool) -> None: + widgets = [ + self.btn_send, + self.btn_refresh_models, + self.btn_copy_sql, + self.btn_update_base, + self.btn_rebuild_expert, + self.btn_update_all, + self.btn_browse_root, + self.btn_reload_config, + self.btn_save_env, + self.btn_browse_modelfile, + self.btn_clear, + self.btn_close, + ] + for w in widgets: + w.setEnabled(not busy) + for w in [ + self.prompt, + self.base_url, + self.model, + self.chk_stream, + self.chk_warmup, + self.project_root_edit, + self.base_model_edit, + self.expert_model_edit, + self.extra_models_edit, + self.modelfile_edit, + self.ollama_bin, + ]: + w.setEnabled(not busy) def msg_error(self, title: str, text: str) -> None: - QMessageBox.critical(self, title, text) + QMessageBox.critical(self, title, strip_ansi(text)) def msg_info(self, title: str, text: str) -> None: - QMessageBox.information(self, title, text) + QMessageBox.information(self, title, strip_ansi(text)) - def ui_busy(self, busy: bool) -> None: - for widget in [ - self.btn_send, - self.btn_model_pull, - self.btn_rebuild_expert, - self.btn_refresh_models, - self.btn_runtime, - self.btn_copy_sql, - ]: - widget.setEnabled(not busy) - self.prompt.setEnabled(not busy) - self.base_url.setEnabled(not busy) - self.ollama_bin.setEnabled(not busy) - self.model.setEnabled(not busy) - self.chk_stream.setEnabled(not busy) - self.btn_close.setEnabled(True) - - def current_base_url(self) -> str: - return self.base_url.text().strip().rstrip("/") - - def current_ollama_bin(self) -> str: - return self.ollama_bin.text().strip() or DEFAULT_OLLAMA_BIN - - def ensure_runtime(self) -> None: - base = self.current_base_url() - ollama_bin = self.current_ollama_bin() - if maybe_start_runtime(ollama_bin, base): - self.status.showMessage("Lokale Ollama Runtime bereit.", 3000) - else: - self.status.showMessage("Ollama Runtime nicht erreichbar.", 6000) + def append_command_log(self, text: str) -> None: + self.command_log.appendPlainText(strip_ansi(text)) + sb = self.command_log.verticalScrollBar() + sb.setValue(sb.maximum()) + # ----------- Model list ----------- def refresh_models(self) -> None: - base = self.current_base_url() - ollama_bin = self.current_ollama_bin() + base = self.base_url.text().strip().rstrip("/") if not base: return try: - self.ensure_runtime() - models = list_models_via_cli(ollama_bin, base) + r = requests.get(base + "/api/tags", timeout=(3, 20)) + r.raise_for_status() + data = r.json() + models = [m.get("name") for m in data.get("models", []) if m.get("name")] current = self.model.currentText().strip() self.model.clear() - self.model.addItems(models) - if current and current in models: - self.model.setCurrentText(current) - elif DEFAULT_MODEL in models: - self.model.setCurrentText(DEFAULT_MODEL) + if models: + self.model.addItems(models) + if current in models: + self.model.setCurrentText(current) + elif self.config.default_inference_model in models: + self.model.setCurrentText(self.config.default_inference_model) + else: + self.model.setCurrentIndex(0) + self.status.showMessage(f"{len(models)} Modelle geladen.", 2500) else: - self.model.setCurrentIndex(0) - self.status.showMessage(f"{len(models)} Modelle geladen (CLI).", 2500) - except Exception as exc: - self.status.showMessage(f"Modelle konnten nicht geladen werden: {human_error(exc)}", 8000) + if current: + self.model.addItem(current) + self.model.setCurrentText(current) + self.status.showMessage("Keine Modelle gefunden (api/tags leer).", 5000) + except Exception as e: + self.status.showMessage(f"Model-Liste konnte nicht geladen werden: {human_error(e)}", 8000) + # ----------- Inference ----------- def on_send(self) -> None: prompt = self.prompt.toPlainText().strip() - base = self.current_base_url() - model = self.model.currentText().strip() if not prompt: self.msg_info("Hinweis", "Bitte erst einen Prompt/Kontext eingeben.") return + + base = self.base_url.text().strip() + model = self.model.currentText().strip() if not base or not model: - self.msg_info("Hinweis", "Bitte Ollama URL und Modell setzen.") - return - if not check_api_ready(base): - self.ensure_runtime() - if not check_api_ready(base): - self.msg_error("Ollama nicht erreichbar", "Die lokale Ollama API ist nicht erreichbar.") + self.msg_info("Hinweis", "Bitte Ollama URL und Antwortmodell setzen.") return self._raw_markdown = "" - self._last_metrics = {} - self._temp_modelfile: Optional[Path] = None self.response_raw.setPlainText("") self.response_view.setMarkdown("") self.status.showMessage("Sende Anfrage …") self.ui_busy(True) self._gen_worker = GenerateWorker( - GenerateParams( - base_url=base, - model=model, - prompt=prompt, - stream=self.chk_stream.isChecked(), - keep_alive=DEFAULT_KEEP_ALIVE, - ) + GenerateParams(base_url=base, model=model, prompt=prompt, stream=self.chk_stream.isChecked()) ) self._gen_worker.chunk.connect(self._on_chunk) self._gen_worker.done.connect(self._on_done) @@ -517,146 +856,236 @@ class MainWindow(QMainWindow): if self.chk_stream.isChecked(): self._render_timer.start() - def _on_chunk(self, chunk: str) -> None: - self._raw_markdown += chunk + def _on_chunk(self, s: str) -> None: + self._raw_markdown += s self.response_raw.setPlainText(self._raw_markdown) - self.response_raw.verticalScrollBar().setValue(self.response_raw.verticalScrollBar().maximum()) + sb = self.response_raw.verticalScrollBar() + sb.setValue(sb.maximum()) def _render_markdown_throttled(self) -> None: if self._raw_markdown: self.response_view.setMarkdown(self._raw_markdown) - def _on_done(self, full: str, meta: dict) -> None: + def _on_done(self, full: str) -> None: self._render_timer.stop() if not self.chk_stream.isChecked(): self._raw_markdown = full self.response_raw.setPlainText(full) self.response_view.setMarkdown(self._raw_markdown) - self._last_metrics = meta or {} + self.status.showMessage("Fertig.", 2500) self.ui_busy(False) - eval_count = meta.get("eval_count") - eval_duration = meta.get("eval_duration") - load_duration = meta.get("load_duration") - if eval_count and eval_duration: - tps = eval_count / max(eval_duration / 1_000_000_000, 1e-9) - load_ms = (load_duration or 0) / 1_000_000 - self.status.showMessage(f"Fertig. {eval_count} Tokens, {tps:.1f} tok/s, Load {load_ms:.0f} ms", 6000) - else: - self.status.showMessage("Fertig.", 2500) - def _on_gen_error(self, err: str) -> None: self._render_timer.stop() self.ui_busy(False) self.status.showMessage("Fehler.", 5000) self.msg_error("Ollama Fehler", err) + # ----------- Clipboard ----------- def copy_response(self) -> None: QApplication.clipboard().setText(self._raw_markdown or self.response_raw.toPlainText()) - self.status.showMessage("Antwort (Markdown) in die Zwischenablage kopiert.", 2500) + self.status.showMessage("Antwort (Markdown) in Clipboard kopiert.", 2500) def copy_sql_only(self) -> None: md = self._raw_markdown or self.response_raw.toPlainText() blocks = extract_sql_blocks(md) sql_text = build_sql_only_text(blocks) if not sql_text: - self.msg_info("Kein SQL gefunden", "In der Antwort wurden keine SQL-Codeblöcke gefunden.") + self.msg_info("Kein SQL gefunden", "Ich habe in der Antwort keine SQL-Codeblöcke gefunden.") return QApplication.clipboard().setText(sql_text) self.status.showMessage(f"SQL kopiert ({len(blocks)} Block/Blöcke).", 3000) - def run_command(self, args: List[str], label: str, done_message: str, on_done: Optional[Callable[[str], None]] = None) -> None: - base = self.current_base_url() - ollama_bin = self.current_ollama_bin() - if not maybe_start_runtime(ollama_bin, base): - self.msg_error("Ollama nicht erreichbar", "Die lokale Ollama Runtime konnte nicht gestartet werden.") - return + # ----------- Command orchestration ----------- + def start_command_worker(self, steps: List[CommandStep], title: str, cleanup: Optional[Callable[[], None]] = None) -> None: + self.command_log.setPlainText("") self.ui_busy(True) - self.status.showMessage(f"{label} …") - self._cmd_worker = CommandWorker( - program=ollama_bin, - args=args, - env=cli_env(base), - cwd=str(DEFAULT_PROJECT_ROOT), - label=label, - ) - self._cmd_worker.status.connect(lambda s: self.status.showMessage(f"{label}: {s[-140:]}")) - self._cmd_worker.done.connect(lambda out: self._on_cmd_done(done_message, out, on_done)) + self.status.showMessage(title) + self._cmd_worker = CommandWorker(steps, cleanup=cleanup) + self._cmd_worker.log.connect(self.append_command_log) + self._cmd_worker.done.connect(self._on_cmd_done) self._cmd_worker.error.connect(self._on_cmd_error) self._cmd_worker.start() - def _on_cmd_done(self, done_message: str, output: str, on_done: Optional[Callable[[str], None]]) -> None: - self.ui_busy(False) - self.status.showMessage(done_message, 5000) - if on_done is not None: - on_done(output) + def build_warmup_step(self, cfg: AppConfig) -> Optional[CommandStep]: + sqlai = cfg.project_root / "bin" / "sqlai" + if self.chk_warmup.isChecked() and sqlai.is_file() and os.access(sqlai, os.X_OK): + return CommandStep( + title="Warmup", + argv=[str(sqlai), "ask", "--text", "Warmup: reply with exactly 'OK'.", "--no-metrics"], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + return None - def _on_cmd_error(self, err: str) -> None: - self.ui_busy(False) - if self._temp_modelfile is not None: - try: - self._temp_modelfile.unlink(missing_ok=True) - finally: - self._temp_modelfile = None - self.msg_error("Ollama Kommando fehlgeschlagen", err) - - def on_pull_model(self) -> None: - model = self.model.currentText().strip() - if not model: - self.msg_info("Hinweis", "Bitte ein Modell setzen.") + def on_update_base_model(self) -> None: + self.autofill_missing_fields_from_disk() + cfg = self.current_config() + msg = self.validate_runtime(cfg) or self.validate_update_base(cfg) + if msg: + self.msg_error("Konfiguration unvollständig", msg) return - self.run_command(["pull", model], f"pull {model}", "Modell aktualisiert.", lambda _: self.refresh_models()) + + steps = [ + CommandStep( + title=f"Base Model pull: {cfg.base_model}", + argv=[cfg.ollama_bin, "pull", cfg.base_model], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ] + for model in cfg.extra_model_list(): + steps.append( + CommandStep( + title=f"Extra Model pull: {model}", + argv=[cfg.ollama_bin, "pull", model], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ) + warmup = self.build_warmup_step(cfg) + if warmup: + steps.append(warmup) + self.start_command_worker(steps, "Basismodell wird aktualisiert …") + + def _prepare_temp_modelfile(self, cfg: AppConfig) -> Path: + text = cfg.modelfile_path.read_text(encoding="utf-8") + text = text.replace("${BASE_MODEL}", cfg.base_model) + text = text.replace("$BASE_MODEL", cfg.base_model) + fd, path = tempfile.mkstemp(prefix="jr-sql-expert-", suffix=".Modelfile") + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + temp_path = Path(path) + self._temp_modelfile = temp_path + return temp_path + + def _cleanup_temp_modelfile(self) -> None: + temp = self._temp_modelfile + self._temp_modelfile = None + if temp is not None: + try: + temp.unlink(missing_ok=True) + except Exception: + pass def on_rebuild_expert(self) -> None: - base_model = os.environ.get("BASE_MODEL", DEFAULT_BASE_MODEL).strip() - expert_model = os.environ.get("EXPERT_MODEL", DEFAULT_EXPERT_MODEL).strip() - if not base_model: - self.msg_error("BASE_MODEL fehlt", "Setze BASE_MODEL in deiner Umgebung oder .env, bevor du das Expert-Modell neu baust.") - return - try: - modelfile_text = render_modelfile(DEFAULT_MODEFILE, base_model) - except Exception as exc: - self.msg_error("Modelfile Fehler", human_error(exc)) + self.autofill_missing_fields_from_disk() + cfg = self.current_config() + msg = self.validate_runtime(cfg) or self.validate_rebuild(cfg) + if msg: + self.msg_error("Konfiguration unvollständig", msg) return - tmp_path = DEFAULT_PROJECT_ROOT / ".tmp.gui.Modelfile" - tmp_path.write_text(modelfile_text, encoding="utf-8") - self._temp_modelfile = tmp_path + temp_modelfile = self._prepare_temp_modelfile(cfg) + steps = [ + CommandStep( + title=f"Expert Model build: {cfg.expert_model}", + argv=[cfg.ollama_bin, "create", cfg.expert_model, "-f", str(temp_modelfile)], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ] + warmup = self.build_warmup_step(cfg) + if warmup: + steps.append(warmup) + self.start_command_worker(steps, "Expert-Modell wird neu gebaut …", cleanup=self._cleanup_temp_modelfile) - def finalize(_: str) -> None: - try: - tmp_path.unlink(missing_ok=True) - finally: - self._temp_modelfile = None - self.refresh_models() + def on_update_all(self) -> None: + self.autofill_missing_fields_from_disk() + cfg = self.current_config() + msg = self.validate_runtime(cfg) or self.validate_rebuild(cfg) + if msg: + self.msg_error("Konfiguration unvollständig", msg) + return - self.run_command( - ["create", expert_model, "-f", str(tmp_path)], - f"create {expert_model}", - f"Expert-Modell {expert_model} neu gebaut.", - finalize, + temp_modelfile = self._prepare_temp_modelfile(cfg) + steps = [ + CommandStep( + title=f"Base Model pull: {cfg.base_model}", + argv=[cfg.ollama_bin, "pull", cfg.base_model], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ] + for model in cfg.extra_model_list(): + steps.append( + CommandStep( + title=f"Extra Model pull: {model}", + argv=[cfg.ollama_bin, "pull", model], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ) + steps.append( + CommandStep( + title=f"Expert Model build: {cfg.expert_model}", + argv=[cfg.ollama_bin, "create", cfg.expert_model, "-f", str(temp_modelfile)], + cwd=str(cfg.project_root), + env=os.environ.copy(), + ) + ) + warmup = self.build_warmup_step(cfg) + if warmup: + steps.append(warmup) + self.start_command_worker(steps, "Update + Rebuild läuft …", cleanup=self._cleanup_temp_modelfile) + + def _on_cmd_done(self) -> None: + self._cleanup_temp_modelfile() + self.ui_busy(False) + self.refresh_models() + self.status.showMessage("Wartungsaktion abgeschlossen.", 4000) + + def _on_cmd_error(self, err: str) -> None: + self._cleanup_temp_modelfile() + self.ui_busy(False) + self.status.showMessage("Wartungsaktion fehlgeschlagen.", 5000) + + log_text = self.command_log.toPlainText() + err_clean = strip_ansi(err) + if looks_like_registry_pull_error(log_text) or looks_like_registry_pull_error(err_clean): + self.msg_error( + "Modell konnte nicht gezogen werden", + "Das gewählte Modell ist offenbar kein remote pullbares Registry-Modell. " + "Für dein lokales Expert-Modell nutze 'Expert neu bauen' oder 'Alles aktualisieren'.", + ) + return + + if "BASE_MODEL" in err_clean and "not set" in err_clean.lower(): + self.msg_error( + "BASE_MODEL fehlt", + "Setze BASE_MODEL in der .env, .env.example oder direkt im GUI und speichere die Konfiguration.", + ) + return + + self.msg_error("Ollama Wartung fehlgeschlagen", err_clean) + + def show_about_dialog(self) -> None: + self.msg_info( + "Über JR SQL AI GUI", + "JR SQL AI GUI\n\n" + "- lokale Ollama API für Antworten\n" + "- lokales ollama-Binary für Modellwartung\n" + "- getrennte Steuerung für Base-/Expert-Modell\n" + "- Menüleiste mit Datei- und Wartungsaktionen", ) - def on_close(self) -> None: - if self._gen_worker and self._gen_worker.isRunning(): - reply = QMessageBox.question( - self, - "Beenden", - "Es läuft noch eine Generierung. Trotzdem beenden?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return + def on_close_requested(self) -> None: self.close() + def closeEvent(self, event) -> None: # type: ignore[override] + if self._cmd_worker is not None and self._cmd_worker.isRunning(): + self.msg_info("Bitte warten", "Es läuft gerade eine Wartungsaktion. Schließe das Fenster danach erneut.") + event.ignore() + return + event.accept() + def main() -> int: app = QApplication(sys.argv) app.setApplicationName("JR SQL AI GUI") - window = MainWindow() - window.resize(1360, 820) - window.show() + w = MainWindow() + w.resize(1500, 940) + w.show() return app.exec()