#!/usr/bin/env python3 """ JR SQL AI GUI (local Ollama, resilient version v9) 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 tempfile from dataclasses import dataclass from pathlib import Path from typing import Callable, List, Optional, Tuple import requests from PySide6.QtCore import Qt, QThread, Signal, QTimer from PySide6.QtGui import QAction, QFont, QKeySequence from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QPushButton, QPlainTextEdit, QSplitter, QStatusBar, QVBoxLayout, QWidget, QTextBrowser, ) 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 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 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"}: blocks.append(body) elif SQL_KW_RE.search(body): blocks.append(body) return blocks def build_sql_only_text(blocks: List[str]) -> str: if not blocks: return "" return "\n\n-- ----------------------------------------\n\n".join(blocks) + "\n" 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 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 resolve_path_from_value(root: Path, raw_value: str, default: Path) -> Path: value = (raw_value or '').strip() if not value: return default.resolve() p = Path(value).expanduser() if not p.is_absolute(): p = (root / p) return p.resolve() 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) default_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", "") modelfile_raw = "" modelfile_source = "" if explicit_modelfile and str(explicit_modelfile).strip(): modelfile_path = explicit_modelfile.resolve() modelfile_source = "GUI" elif env_data.get("MODELFILE_PATH", "").strip(): modelfile_raw = env_data.get("MODELFILE_PATH", "").strip() modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) modelfile_source = ".env" elif env_example_data.get("MODELFILE_PATH", "").strip(): modelfile_raw = env_example_data.get("MODELFILE_PATH", "").strip() modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) modelfile_source = ".env.example" elif os.environ.get("MODELFILE_PATH", "").strip(): modelfile_raw = os.environ.get("MODELFILE_PATH", "").strip() modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) modelfile_source = "Umgebung" else: modelfile_path = default_modelfile_path if modelfile_source: notes.append(f"MODELFILE_PATH aus {modelfile_source}") 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 pick("DEFAULT_INFERENCE_MODEL", "") 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, ) @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 @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, ) 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 = "30m" class GenerateWorker(QThread): chunk = Signal(str) done = Signal(str) error = Signal(str) def __init__(self, params: GenerateParams): super().__init__() self.params = params def run(self) -> None: try: url = self.params.base_url.rstrip("/") + "/api/generate" payload = { "model": self.params.model, "prompt": self.params.prompt, "stream": self.params.stream, "keep_alive": self.params.keep_alive, } 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 = r.json() self.done.emit(data.get("response", "")) return full: list[str] = [] for line in r.iter_lines(decode_unicode=True): if not line: continue 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)) @dataclass class CommandStep: title: str argv: list[str] cwd: Optional[str] = None env: Optional[dict[str, str]] = None class CommandWorker(QThread): log = Signal(str) done = Signal() error = Signal(str) def __init__(self, steps: List[CommandStep], cleanup: Optional[Callable[[], None]] = None): super().__init__() 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: 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) -> 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._config_dirty = False self._suspend_dirty_tracking = False 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) row += 1 self.lbl_config_state = QLabel("") self.lbl_config_state.setWordWrap(True) config_layout.addWidget(self.lbl_config_state, row, 0, 1, 5) top = QHBoxLayout() layout.addLayout(top) top.addWidget(QLabel("Antwortmodell:")) self.model = QComboBox() self.model.setEditable(True) 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) top.addWidget(self.btn_refresh_models) self.chk_stream = QCheckBox("Streaming") 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) send_row = QHBoxLayout() self.btn_send = QPushButton("An AI senden") self.btn_send.clicked.connect(self.on_send) send_row.addWidget(self.btn_send) self.btn_clear = QPushButton("Leeren") self.btn_clear.clicked.connect(lambda: self.prompt.setPlainText("")) 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) self.response_view.setFont(QFont("Monospace", 10)) right_l.addWidget(self.response_view, 1) self.response_raw = QPlainTextEdit() 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(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) right_btn_row.addWidget(self.btn_copy) self.btn_copy_sql = QPushButton("Copy SQL only") self.btn_copy_sql.clicked.connect(self.copy_sql_only) right_btn_row.addWidget(self.btn_copy_sql) 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_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, 840]) self.status = QStatusBar() self.setStatusBar(self.status) self.status.showMessage("Bereit.") self._connect_config_change_tracking() self.apply_default_hints() self._set_config_dirty(False) QTimer.singleShot(300, self.refresh_models) def _connect_config_change_tracking(self) -> None: fields = [ self.project_root_edit, self.base_url, self.ollama_bin, self.base_model_edit, self.expert_model_edit, self.extra_models_edit, self.modelfile_edit, ] for field in fields: field.textEdited.connect(self._on_config_field_edited) self.model.lineEdit().textEdited.connect(self._on_config_field_edited) self.model.currentTextChanged.connect(self._on_inference_model_changed) def _set_config_dirty(self, dirty: bool, reason: str = "") -> None: if self._suspend_dirty_tracking: return self._config_dirty = dirty if dirty: self.lbl_config_state.setText( "Konfiguration geändert und noch nicht gespeichert. Klicke auf '.env speichern', um die Änderungen dauerhaft zu übernehmen." ) self.lbl_config_state.setStyleSheet("color: #f6c177; font-weight: 600;") self.btn_save_env.setText(".env speichern *") self.setWindowTitle("JR SQL AI GUI (local Ollama) *") if reason: self.status.showMessage(reason, 5000) else: self.lbl_config_state.setText("Konfiguration ist gespeichert bzw. entspricht dem geladenen Stand.") self.lbl_config_state.setStyleSheet("color: #9ccfd8;") self.btn_save_env.setText(".env speichern") self.setWindowTitle("JR SQL AI GUI (local Ollama)") def _on_config_field_edited(self, _text: str) -> None: self._set_config_dirty(True, "Ungespeicherte Konfigurationsänderungen vorhanden.") def _on_inference_model_changed(self, text: str) -> None: if self._suspend_dirty_tracking: return if text.strip(): self._set_config_dirty(True, "Antwortmodell geändert. Speichere die .env, wenn dies der neue Standard sein soll.") 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) self._suspend_dirty_tracking = True try: 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) finally: self._suspend_dirty_tracking = False 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() self._set_config_dirty(True, "Modelfile-Pfad geändert. Speichere die .env, um den neuen Pfad dauerhaft zu übernehmen.") 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._suspend_dirty_tracking = True try: 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)) self.model.setCurrentText(self.config.default_inference_model) finally: self._suspend_dirty_tracking = False self.apply_default_hints() self._set_config_dirty(False) 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, "MODELFILE_PATH": str(cfg.modelfile_path), "DEFAULT_INFERENCE_MODEL": cfg.default_inference_model, }, ) self.apply_default_hints() self._set_config_dirty(False) 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, strip_ansi(text)) def msg_info(self, title: str, text: str) -> None: QMessageBox.information(self, title, strip_ansi(text)) 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.base_url.text().strip().rstrip("/") if not base: return try: 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() 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: 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() 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 Antwortmodell setzen.") return self._raw_markdown = "" 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()) ) self._gen_worker.chunk.connect(self._on_chunk) self._gen_worker.done.connect(self._on_done) self._gen_worker.error.connect(self._on_gen_error) self._gen_worker.start() if self.chk_stream.isChecked(): self._render_timer.start() def _on_chunk(self, s: str) -> None: self._raw_markdown += s self.response_raw.setPlainText(self._raw_markdown) 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) -> 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.status.showMessage("Fertig.", 2500) self.ui_busy(False) 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 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", "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) # ----------- 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(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 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_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 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: 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 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 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 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_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 if self._config_dirty: answer = QMessageBox.question( self, "Ungespeicherte Konfiguration", "Es gibt ungespeicherte Konfigurationsänderungen. Möchtest du sie vor dem Beenden in die .env schreiben?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes, ) if answer == QMessageBox.Cancel: event.ignore() return if answer == QMessageBox.Yes: self.save_env() event.accept() def main() -> int: app = QApplication(sys.argv) app.setApplicationName("JR SQL AI GUI") w = MainWindow() w.resize(1500, 940) w.show() return app.exec() if __name__ == "__main__": raise SystemExit(main())