From 79f58e82bc7eadf186396176f1707914a862a60d Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 9 Apr 2026 15:29:20 +0200 Subject: [PATCH] Updated UI, now with new base model --- scripts/sql-ai-gui_gwen3 | 14 + sql_ai_gui_local_ollama_qwen3.py | 1793 ++++++++++++++++++++++++++++++ 2 files changed, 1807 insertions(+) create mode 100755 scripts/sql-ai-gui_gwen3 create mode 100644 sql_ai_gui_local_ollama_qwen3.py diff --git a/scripts/sql-ai-gui_gwen3 b/scripts/sql-ai-gui_gwen3 new file mode 100755 index 0000000..f0ed2b8 --- /dev/null +++ b/scripts/sql-ai-gui_gwen3 @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Wayland bevorzugen (Hyprland) +export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland}" + +# Optional: Wenn du einen venv nutzt +if [[ -x "$APP_DIR/.venv/bin/python" ]]; then + exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui_local_ollama_qwen3.py" +else + exec python "$APP_DIR/sql_ai_gui_local_ollama_qwen3.py" +fi diff --git a/sql_ai_gui_local_ollama_qwen3.py b/sql_ai_gui_local_ollama_qwen3.py new file mode 100644 index 0000000..117a987 --- /dev/null +++ b/sql_ai_gui_local_ollama_qwen3.py @@ -0,0 +1,1793 @@ +#!/usr/bin/env python3 +""" +JR SQL AI GUI (local Ollama, Qwen3-ready version v10) + +Highlights: +- Qwen3-oriented defaults and preset button +- /api/chat and /api/generate support +- context / temperature / keep_alive controls +- structured output support (json or JSON schema) +- external system-prompt file management +- keeps local Ollama maintenance flow (pull, create, warmup) +""" +from __future__ import annotations + +import html +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 Any, Callable, List, Optional, Tuple + +import requests + +try: + import markdown as mdlib +except Exception: + mdlib = None + +try: + from pygments.formatters import HtmlFormatter +except Exception: + HtmlFormatter = None +from PySide6.QtCore import Qt, QThread, QTimer, Signal +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|exec|declare|begin|commit|rollback|truncate)\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; +} +""" + +DEFAULT_SYSTEM_PROMPT = """Du bist ein lokaler SQL- und Repo-Assistent auf Basis von Ollama. +Arbeite präzise, konservativ und produktionsnah. +Lege Annahmen offen. +Wenn sinnvoll, liefere konkrete SQL-Blöcke. +Wenn strukturierter Output verlangt wird, liefere gültiges JSON ohne Markdown-Hülle. +""" + +RENDER_STYLE_CSS = """ +body { + background: #2b2b2b; + color: #e8e8e8; + font-family: "Segoe UI", "Inter", sans-serif; + font-size: 14px; + line-height: 1.5; + padding: 12px 14px; +} +h1, h2, h3, h4 { + color: #ffffff; + margin-top: 1.0em; + margin-bottom: 0.45em; +} +p, li { + color: #e8e8e8; +} +ul, ol { + margin-top: 0.35em; + margin-bottom: 0.55em; +} +pre { + background: #1f1f1f; + border: 1px solid #3b3b3b; + border-radius: 10px; + padding: 12px; + overflow-x: auto; +} +code { + font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace; + font-size: 13px; +} +p code, li code, td code { + background: #1f1f1f; + border: 1px solid #3b3b3b; + border-radius: 6px; + padding: 1px 5px; +} +blockquote { + border-left: 4px solid #4a90e2; + margin: 10px 0; + padding-left: 12px; + color: #d0d0d0; +} +table { + border-collapse: collapse; + margin-top: 10px; + width: 100%; +} +th, td { + border: 1px solid #555; + padding: 6px 8px; + text-align: left; +} +th { + background: #33373d; +} +a { + color: #7db7ff; +} +hr { + border: none; + border-top: 1px solid #4a4a4a; + margin: 16px 0; +} +.codehilite { + border-radius: 10px; +} +""" + +QWEN3_EXPERT_TEMPLATE = '''FROM ${BASE_MODEL} + +PARAMETER num_ctx 65536 +PARAMETER temperature 0.2 + +SYSTEM """ +Du bist ein SQL Server 2022 Experte und Repo-Assistent. +Arbeite präzise, konservativ und produktionsnah. +Wenn du SQL ausgibst, liefere standardmäßig: +- Kurzfazit +- Risiken +- konkrete SQL-Blöcke +- Validierungs-Checks +- Rollback-/Safety-Hinweise bei DDL +""" +''' + + + +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 read_text_if_exists(path: Path) -> str: + if path.is_file(): + try: + return path.read_text(encoding="utf-8") + except Exception: + return "" + return "" + + +def write_text_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def extract_sql_blocks(markdown_text: str) -> List[str]: + blocks: List[str] = [] + for m in FENCE_RE.finditer(markdown_text or ""): + 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) + if not blocks and SQL_KW_RE.search(markdown_text or ""): + blocks.append((markdown_text or "").strip()) + return blocks + + +def build_sql_only_text(blocks: List[str]) -> str: + if not blocks: + return "" + return "\n\n-- ----------------------------------------\n\n".join(blocks) + "\n" + + +def extract_sql_from_json(obj: Any) -> List[str]: + blocks: List[str] = [] + if isinstance(obj, dict): + for key, value in obj.items(): + key_lower = key.lower() + if key_lower in {"sql", "query", "statement", "ddl", "dml"} and isinstance(value, str): + if value.strip(): + blocks.append(value.strip()) + elif key_lower in {"sql_blocks", "queries", "statements", "steps_sql"} and isinstance(value, list): + for item in value: + if isinstance(item, str) and item.strip(): + blocks.append(item.strip()) + else: + blocks.extend(extract_sql_from_json(value)) + elif isinstance(obj, list): + for item in obj: + blocks.extend(extract_sql_from_json(item)) + elif isinstance(obj, str): + blocks.extend(extract_sql_blocks(obj)) + return blocks + + +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() + for path in [start, *start.parents]: + 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]: + 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 ...)" + + +def parse_json_file(path: Path) -> Optional[Any]: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +@dataclass +class ResolvedDefaults: + ollama_url: str + ollama_bin: str + base_model: str + expert_model: str + extra_models: str + inference_model: str + modelfile_path: Path + api_mode: str + num_ctx: str + temperature: str + keep_alive: str + response_format: str + schema_path: Path + system_prompt_path: Path + system_prompt_text: str + 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() + default_system_path = (root / ".sqlai-system-prompt.txt").resolve() + default_schema_path = (root / "schemas" / "sqlai-response.schema.json").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", "") + api_mode = pick("OLLAMA_API_MODE", "chat") + num_ctx = pick("OLLAMA_NUM_CTX", "65536") + temperature = pick("OLLAMA_TEMPERATURE", "0.2") + keep_alive = pick("OLLAMA_KEEP_ALIVE", "30m") + response_format = pick("OLLAMA_RESPONSE_FORMAT", "text") + + modelfile_raw = pick("MODELFILE_PATH", "") + modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) + + schema_raw = pick("OLLAMA_SCHEMA_PATH", "") + schema_path = resolve_path_from_value(root, schema_raw, default_schema_path) + + system_raw = pick("OLLAMA_SYSTEM_FILE", "") + system_path = resolve_path_from_value(root, system_raw, default_system_path) + + 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") + ) + + system_prompt_text = read_text_if_exists(system_path) or DEFAULT_SYSTEM_PROMPT + + 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, + api_mode=api_mode, + num_ctx=num_ctx, + temperature=temperature, + keep_alive=keep_alive, + response_format=response_format, + schema_path=schema_path, + system_prompt_path=system_path, + system_prompt_text=system_prompt_text, + 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 + api_mode: str + num_ctx: str + temperature: str + keep_alive: str + response_format: str + schema_path: Path + system_prompt_path: Path + system_prompt_text: 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, + api_mode=defaults.api_mode, + num_ctx=defaults.num_ctx, + temperature=defaults.temperature, + keep_alive=defaults.keep_alive, + response_format=defaults.response_format, + schema_path=defaults.schema_path, + system_prompt_path=defaults.system_prompt_path, + system_prompt_text=defaults.system_prompt_text, + ) + + def extra_model_list(self) -> List[str]: + return [item for item in shlex.split(self.extra_models) if item.strip()] + + +@dataclass +class GenerateParams: + base_url: str + model: str + user_prompt: str + system_prompt: str + api_mode: str + stream: bool + keep_alive: str + num_ctx: Optional[int] + temperature: Optional[float] + response_format: str + schema_path: Optional[Path] + + +class GenerateWorker(QThread): + chunk = Signal(str) + done = Signal(str, str) # assistant_text, raw_json + error = Signal(str) + + def __init__(self, params: GenerateParams): + super().__init__() + self.params = params + + def _format_value(self) -> Optional[Any]: + if self.params.schema_path and self.params.schema_path.is_file(): + parsed = parse_json_file(self.params.schema_path) + if parsed is not None: + return parsed + if self.params.response_format.lower() == "json": + return "json" + return None + + def _payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": self.params.model, + "stream": self.params.stream, + "keep_alive": self.params.keep_alive, + } + options: dict[str, Any] = {} + if self.params.num_ctx: + options["num_ctx"] = self.params.num_ctx + if self.params.temperature is not None: + options["temperature"] = self.params.temperature + if options: + payload["options"] = options + fmt = self._format_value() + if fmt is not None: + payload["format"] = fmt + if self.params.api_mode == "chat": + payload["messages"] = [ + {"role": "system", "content": self.params.system_prompt}, + {"role": "user", "content": self.params.user_prompt}, + ] + else: + payload["prompt"] = ( + f"{self.params.system_prompt}\n\n" + f"---BEGIN INPUT---\n{self.params.user_prompt}\n---END INPUT---" + ) + return payload + + def run(self) -> None: + try: + endpoint = self.params.base_url.rstrip("/") + f"/api/{self.params.api_mode}" + payload = self._payload() + with requests.post(endpoint, json=payload, stream=self.params.stream, timeout=(5, 3600)) as r: + r.raise_for_status() + if not self.params.stream: + data = r.json() + text = self._extract_content(data) + self.done.emit(text, json.dumps(data, ensure_ascii=False, indent=2)) + return + + full: list[str] = [] + last_obj: dict[str, Any] = {} + for line in r.iter_lines(decode_unicode=True): + if not line: + continue + obj = json.loads(line) + last_obj = obj + part = self._extract_content(obj) + if part: + full.append(part) + self.chunk.emit(part) + if obj.get("done", False): + break + self.done.emit("".join(full), json.dumps(last_obj, ensure_ascii=False, indent=2)) + except Exception as e: + self.error.emit(human_error(e)) + + def _extract_content(self, obj: dict[str, Any]) -> str: + if self.params.api_mode == "chat": + return ((obj.get("message") or {}).get("content") or "") + return obj.get("response", "") or "" + + +@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 + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("JR SQL AI GUI (local Ollama, Qwen3-ready)") + self._gen_worker: Optional[GenerateWorker] = None + self._cmd_worker: Optional[CommandWorker] = None + self._temp_modelfile: Optional[Path] = None + self._raw_markdown = "" + self._last_json = "" + self._last_detected_kind = "" + 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(150) + 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) + + self._build_config_group(layout) + self._build_advanced_group(layout) + self._build_main_splitter(layout) + + 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 _build_config_group(self, parent_layout: QVBoxLayout) -> None: + config_group = QGroupBox("Konfiguration") + config_layout = QGridLayout(config_group) + parent_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. qwen3-coder:30b") + 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) + + def _build_advanced_group(self, parent_layout: QVBoxLayout) -> None: + group = QGroupBox("Inference / Qwen3") + grid = QGridLayout(group) + parent_layout.addWidget(group) + + row = 0 + grid.addWidget(QLabel("Antwortmodell:"), row, 0) + 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) + grid.addWidget(self.model, row, 1) + self.btn_refresh_models = QPushButton("Modelle laden") + self.btn_refresh_models.clicked.connect(self.refresh_models) + grid.addWidget(self.btn_refresh_models, row, 2) + self.btn_apply_qwen3 = QPushButton("Qwen3 Preset") + self.btn_apply_qwen3.clicked.connect(self.apply_qwen3_preset) + grid.addWidget(self.btn_apply_qwen3, row, 3) + row += 1 + + grid.addWidget(QLabel("API-Modus:"), row, 0) + self.api_mode_combo = QComboBox() + self.api_mode_combo.addItems(["chat", "generate"]) + self.api_mode_combo.setCurrentText(self.config.api_mode) + grid.addWidget(self.api_mode_combo, row, 1) + grid.addWidget(QLabel("num_ctx:"), row, 2) + self.num_ctx_edit = QLineEdit(self.config.num_ctx) + grid.addWidget(self.num_ctx_edit, row, 3) + row += 1 + + grid.addWidget(QLabel("Temperature:"), row, 0) + self.temperature_edit = QLineEdit(self.config.temperature) + grid.addWidget(self.temperature_edit, row, 1) + grid.addWidget(QLabel("Keep Alive:"), row, 2) + self.keep_alive_edit = QLineEdit(self.config.keep_alive) + grid.addWidget(self.keep_alive_edit, row, 3) + row += 1 + + grid.addWidget(QLabel("Response-Format:"), row, 0) + self.response_format_combo = QComboBox() + self.response_format_combo.addItems(["text", "json", "schema"]) + self.response_format_combo.setCurrentText(self.config.response_format if self.config.response_format in {"text", "json", "schema"} else "text") + grid.addWidget(self.response_format_combo, row, 1) + grid.addWidget(QLabel("Schema-Datei:"), row, 2) + self.schema_path_edit = QLineEdit(str(self.config.schema_path)) + grid.addWidget(self.schema_path_edit, row, 3) + self.btn_browse_schema = QPushButton("Schema") + self.btn_browse_schema.clicked.connect(self.on_browse_schema) + grid.addWidget(self.btn_browse_schema, row, 4) + row += 1 + + grid.addWidget(QLabel("System-Prompt Datei:"), row, 0) + self.system_prompt_path_edit = QLineEdit(str(self.config.system_prompt_path)) + grid.addWidget(self.system_prompt_path_edit, row, 1, 1, 3) + self.btn_browse_system_prompt = QPushButton("Prompt-Datei") + self.btn_browse_system_prompt.clicked.connect(self.on_browse_system_prompt) + grid.addWidget(self.btn_browse_system_prompt, row, 4) + row += 1 + + grid.addWidget(QLabel("System-Prompt:"), row, 0) + self.system_prompt_edit = QPlainTextEdit() + self.system_prompt_edit.setPlainText(self.config.system_prompt_text) + self.system_prompt_edit.setMaximumHeight(120) + self.system_prompt_edit.setFont(QFont("Monospace", 10)) + grid.addWidget(self.system_prompt_edit, row, 1, 1, 3) + btns = QVBoxLayout() + self.btn_default_system_prompt = QPushButton("Default Prompt") + self.btn_default_system_prompt.clicked.connect(lambda: self.system_prompt_edit.setPlainText(DEFAULT_SYSTEM_PROMPT)) + btns.addWidget(self.btn_default_system_prompt) + self.chk_stream = QCheckBox("Streaming") + self.chk_stream.setChecked(True) + btns.addWidget(self.chk_stream) + self.chk_warmup = QCheckBox("Warmup nach Build/Update") + self.chk_warmup.setChecked(False) + btns.addWidget(self.chk_warmup) + btns.addStretch(1) + btnw = QWidget() + btnw.setLayout(btns) + grid.addWidget(btnw, row, 4) + + def _build_main_splitter(self, parent_layout: QVBoxLayout) -> None: + splitter = QSplitter(Qt.Horizontal) + parent_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) + + response_header = QHBoxLayout() + response_header.addWidget(QLabel("Antwort")) + response_header.addStretch(1) + response_header.addWidget(QLabel("Ansicht:")) + self.view_mode_combo = QComboBox() + self.view_mode_combo.addItems(["Auto", "Lesemodus", "Markdown/Code", "Raw JSON"]) + self.view_mode_combo.setCurrentText("Auto") + self.view_mode_combo.setToolTip("Auto folgt dem Response-Format. Lesemodus wandelt JSON in lesbare Abschnitte um. Markdown/Code zeigt Codeblöcke. Raw JSON zeigt formatiertes JSON.") + self.view_mode_combo.currentTextChanged.connect(lambda _text: self._render_response(self._raw_markdown)) + response_header.addWidget(self.view_mode_combo) + self.lbl_response_meta = QLabel("Erkannt: -") + self.lbl_response_meta.setStyleSheet("color: #9ccfd8;") + response_header.addWidget(self.lbl_response_meta) + right_l.addLayout(response_header) + + 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("Extrahierte Antwort / JSON / Debug.") + self.response_raw.setMaximumHeight(180) + 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_json = QPushButton("Copy JSON only") + self.btn_copy_json.clicked.connect(self.copy_json_only) + right_btn_row.addWidget(self.btn_copy_json) + 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, 900]) + + 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, + self.num_ctx_edit, + self.temperature_edit, + self.keep_alive_edit, + self.schema_path_edit, + self.system_prompt_path_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) + self.api_mode_combo.currentTextChanged.connect(self._on_inference_model_changed) + self.response_format_combo.currentTextChanged.connect(self._on_inference_model_changed) + self.system_prompt_edit.textChanged.connect(lambda: self._set_config_dirty(True, "System-Prompt geändert.")) + + 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 Ä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, Qwen3-ready) *") + 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, Qwen3-ready)") + + 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 + self._set_config_dirty(True, "Inference-Konfiguration geändert.") + + 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) + act_preset = QAction("Qwen3 Preset anwenden", self) + act_preset.triggered.connect(self.apply_qwen3_preset) + actions_menu.addAction(act_preset) + 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) + + 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", + api_mode=self.api_mode_combo.currentText().strip() or "chat", + num_ctx=self.num_ctx_edit.text().strip() or "65536", + temperature=self.temperature_edit.text().strip() or "0.2", + keep_alive=self.keep_alive_edit.text().strip() or "30m", + response_format=self.response_format_combo.currentText().strip() or "text", + schema_path=Path(self.schema_path_edit.text().strip() or (root / "schemas" / "sqlai-response.schema.json")).resolve(), + system_prompt_path=Path(self.system_prompt_path_edit.text().strip() or (root / ".sqlai-system-prompt.txt")).resolve(), + system_prompt_text=self.system_prompt_edit.toPlainText().strip() or DEFAULT_SYSTEM_PROMPT, + ) + + 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(): + self.base_url.setText(defaults.ollama_url) + if not self.ollama_bin.text().strip(): + 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(): + self.modelfile_edit.setText(str(defaults.modelfile_path)) + if not self.schema_path_edit.text().strip(): + self.schema_path_edit.setText(str(defaults.schema_path)) + if not self.system_prompt_path_edit.text().strip(): + self.system_prompt_path_edit.setText(str(defaults.system_prompt_path)) + if not self.model.currentText().strip(): + self.model.setCurrentText(defaults.inference_model) + if not self.num_ctx_edit.text().strip(): + self.num_ctx_edit.setText(defaults.num_ctx) + if not self.temperature_edit.text().strip(): + self.temperature_edit.setText(defaults.temperature) + if not self.keep_alive_edit.text().strip(): + self.keep_alive_edit.setText(defaults.keep_alive) + if not self.system_prompt_edit.toPlainText().strip(): + self.system_prompt_edit.setPlainText(defaults.system_prompt_text) + 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 fehlt. Bitte in .env oder GUI setzen." + 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 fehlt. Bitte in .env oder GUI setzen." + 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._set_config_dirty(True, "Modelfile-Pfad geändert.") + + def on_browse_schema(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Schema-Datei wählen", self.project_root_edit.text().strip() or str(Path.cwd()), "JSON (*.json)") + if path: + self.schema_path_edit.setText(path) + self.response_format_combo.setCurrentText("schema") + self._set_config_dirty(True, "Schema-Datei geändert.") + + def on_browse_system_prompt(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "System-Prompt-Datei wählen", self.project_root_edit.text().strip() or str(Path.cwd())) + if path: + self.system_prompt_path_edit.setText(path) + text = read_text_if_exists(Path(path)) + if text: + self.system_prompt_edit.setPlainText(text) + self._set_config_dirty(True, "System-Prompt-Datei geändert.") + + 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) + self.api_mode_combo.setCurrentText(self.config.api_mode) + self.num_ctx_edit.setText(self.config.num_ctx) + self.temperature_edit.setText(self.config.temperature) + self.keep_alive_edit.setText(self.config.keep_alive) + self.response_format_combo.setCurrentText(self.config.response_format) + self.schema_path_edit.setText(str(self.config.schema_path)) + self.system_prompt_path_edit.setText(str(self.config.system_prompt_path)) + self.system_prompt_edit.setPlainText(self.config.system_prompt_text) + 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_text_file(cfg.system_prompt_path, cfg.system_prompt_text.strip() + "\n") + 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, + "OLLAMA_API_MODE": cfg.api_mode, + "OLLAMA_NUM_CTX": cfg.num_ctx, + "OLLAMA_TEMPERATURE": cfg.temperature, + "OLLAMA_KEEP_ALIVE": cfg.keep_alive, + "OLLAMA_RESPONSE_FORMAT": cfg.response_format, + "OLLAMA_SCHEMA_PATH": str(cfg.schema_path), + "OLLAMA_SYSTEM_FILE": str(cfg.system_prompt_path), + }, + ) + self.apply_default_hints() + self._set_config_dirty(False) + self.status.showMessage(f".env gespeichert: {cfg.env_path}", 3500) + + def apply_qwen3_preset(self) -> None: + self.base_model_edit.setText("qwen3-coder:30b") + self.expert_model_edit.setText("jr-sql-expert") + self.extra_models_edit.setText("qwen3:14b qwen2.5-coder:14b") + self.api_mode_combo.setCurrentText("chat") + self.num_ctx_edit.setText("65536") + self.temperature_edit.setText("0.2") + self.keep_alive_edit.setText("30m") + self.response_format_combo.setCurrentText("text") + self.model.setCurrentText("jr-sql-expert:latest") + if not self.modelfile_edit.text().strip(): + self.modelfile_edit.setText(str((Path(self.project_root_edit.text().strip() or ".") / "Modelfile").resolve())) + if not self.system_prompt_edit.toPlainText().strip(): + self.system_prompt_edit.setPlainText(DEFAULT_SYSTEM_PROMPT) + self._set_config_dirty(True, "Qwen3 Preset angewendet.") + + def ui_busy(self, busy: bool) -> None: + widgets = [ + self.btn_send, self.btn_refresh_models, self.btn_copy_json, 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_browse_schema, self.btn_browse_system_prompt, + self.btn_clear, self.btn_close, self.btn_apply_qwen3, + ] + 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, + self.api_mode_combo, self.num_ctx_edit, self.temperature_edit, + self.keep_alive_edit, self.response_format_combo, self.schema_path_edit, + self.system_prompt_path_edit, self.system_prompt_edit, + ]: + 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()) + + 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) + elif current: + self.model.addItem(current) + self.model.setCurrentText(current) + except Exception as e: + self.status.showMessage(f"Model-Liste konnte nicht geladen werden: {human_error(e)}", 8000) + + def _parse_int(self, raw: str) -> Optional[int]: + try: + return int(raw.strip()) + except Exception: + return None + + def _parse_float(self, raw: str) -> Optional[float]: + try: + return float(raw.strip()) + except Exception: + return None + + def _looks_like_json(self, text: str) -> bool: + text = (text or "").strip() + if not text: + return False + if not ((text.startswith("{") and text.endswith("}")) or (text.startswith("[") and text.endswith("]"))): + return False + try: + json.loads(text) + return True + except Exception: + return False + + def _looks_like_sql(self, text: str) -> bool: + candidate = (text or "").strip() + if not candidate: + return False + if "```" in candidate: + return False + lines = [line.strip() for line in candidate.splitlines() if line.strip()] + if not lines: + return False + signal = sum(1 for line in lines if SQL_KW_RE.search(line)) + if signal >= 2: + return True + head = lines[0].lower() + return bool(re.match(r"^(select|with|insert|update|delete|create|alter|drop|merge|exec|declare|begin)\b", head)) + + def _pretty_json_block(self, text: str) -> str: + parsed = json.loads(text) + pretty = json.dumps(parsed, ensure_ascii=False, indent=2) + return f"```json\n{pretty}\n```" + + def _pretty_json_text(self, text: str) -> str: + parsed = json.loads(text) + return json.dumps(parsed, ensure_ascii=False, indent=2) + + def _detect_response_kind(self, text: str) -> str: + candidate = (text or "").strip() + if not candidate: + return "none" + if self._looks_like_json(candidate): + return "json" + if self._looks_like_sql(candidate): + return "sql" + if "```" in candidate or re.search(r"^\s{0,3}(#|[-*]\s|\d+\.\s)", candidate, re.MULTILINE): + return "markdown" + return "text" + + def _kind_label(self, kind: str) -> str: + labels = { + "none": "-", + "json": "JSON", + "sql": "SQL", + "markdown": "Markdown", + "text": "Text", + } + return labels.get(kind, kind) + + def _structured_json_candidate(self, assistant_text: str) -> str: + primary = (assistant_text or "").strip() + if self._looks_like_json(primary): + return primary + secondary = (self._raw_markdown or "").strip() + if self._looks_like_json(secondary): + return secondary + return "" + + def _effective_view_mode(self, assistant_text: str) -> str: + requested = self.view_mode_combo.currentText().strip() or "Auto" + if requested != "Auto": + return requested + response_format = self.response_format_combo.currentText().strip() + if response_format in {"json", "schema"} and self._structured_json_candidate(assistant_text): + return "Raw JSON" + return "Lesemodus" + + def _normalize_response_for_read_mode(self, assistant_text: str) -> str: + text = (assistant_text or "").strip() + if not text: + return "" + + json_candidate = self._structured_json_candidate(text) + if json_candidate: + try: + parsed = json.loads(json_candidate) + rendered = self._json_to_markdown(parsed) + if rendered.strip(): + return rendered + return self._pretty_json_block(json_candidate) + except Exception: + return f"```json\n{json_candidate}\n```" + + if self._looks_like_sql(text): + return f"```sql\n{text}\n```" + + return text + + def _titleize_key(self, key: str) -> str: + text = (key or "").strip().replace("_", " ").replace("-", " ") + text = re.sub(r"\s+", " ", text) + if not text: + return "Wert" + words = [word if word.isupper() else word.capitalize() for word in text.split(" ")] + return " ".join(words) + + def _render_scalar_markdown(self, value: Any, key_name: str = "") -> str: + if value is None: + return "—" + if isinstance(value, bool): + return "Ja" if value else "Nein" + if isinstance(value, (int, float)): + return str(value) + + text = str(value).strip() + if not text: + return "—" + + key_lower = key_name.lower() + if key_lower in {"sql", "query", "statement", "ddl", "dml", "code"} or self._looks_like_sql(text): + return f"```sql\n{text}\n```" + if self._looks_like_json(text): + try: + parsed = json.loads(text) + return self._json_to_markdown(parsed) + except Exception: + return f"```json\n{text}\n```" + if "\n" in text: + return text + return text + + def _json_list_to_markdown(self, items: list[Any], level: int = 2, parent_key: str = "") -> str: + if not items: + return "- —" + + if all(not isinstance(item, (dict, list)) for item in items): + lines: list[str] = [] + for item in items: + rendered = self._render_scalar_markdown(item, parent_key) + if "```" in rendered: + lines.append(rendered) + else: + lines.append(f"- {rendered}") + return "\n".join(lines) + + parts: list[str] = [] + heading_level = "#" * min(level, 6) + for idx, item in enumerate(items, 1): + if isinstance(item, dict): + label = item.get("title") or item.get("name") or item.get("label") or f"Eintrag {idx}" + parts.append(f"{heading_level} {label}") + parts.append(self._json_to_markdown(item, level + 1)) + elif isinstance(item, list): + parts.append(f"{heading_level} Eintrag {idx}") + parts.append(self._json_list_to_markdown(item, level + 1, parent_key)) + else: + rendered = self._render_scalar_markdown(item, parent_key) + if "```" in rendered: + parts.append(rendered) + else: + parts.append(f"- {rendered}") + return "\n\n".join(part for part in parts if part.strip()) + + def _json_to_markdown(self, obj: Any, level: int = 2) -> str: + if isinstance(obj, dict): + parts: list[str] = [] + for key, value in obj.items(): + title = self._titleize_key(str(key)) + if isinstance(value, dict): + parts.append(f"{'#' * min(level, 6)} {title}") + parts.append(self._json_to_markdown(value, level + 1)) + continue + if isinstance(value, list): + parts.append(f"{'#' * min(level, 6)} {title}") + parts.append(self._json_list_to_markdown(value, level + 1, str(key))) + continue + + rendered = self._render_scalar_markdown(value, str(key)) + if "```" in rendered or "\n" in rendered: + parts.append(f"**{title}:**\n\n{rendered}") + else: + parts.append(f"**{title}:** {rendered}") + return "\n\n".join(part for part in parts if part.strip()) + + if isinstance(obj, list): + return self._json_list_to_markdown(obj, level) + + return self._render_scalar_markdown(obj) + + def _normalize_response_for_markdown(self, assistant_text: str) -> str: + text = (assistant_text or "").strip() + if not text: + return "" + + json_candidate = self._structured_json_candidate(text) + if json_candidate: + try: + return self._pretty_json_block(json_candidate) + except Exception: + return f"```json\n{json_candidate}\n```" + + if self._looks_like_sql(text): + return f"```sql\n{text}\n```" + + return text + + def _build_render_css(self) -> str: + pygments_css = "" + if HtmlFormatter is not None: + try: + pygments_css = HtmlFormatter(style="native").get_style_defs(".codehilite") + except Exception: + pygments_css = "" + return RENDER_STYLE_CSS + "\n" + pygments_css + + def _markdown_to_html(self, md_text: str) -> str: + if mdlib is not None: + try: + body = mdlib.markdown( + md_text, + extensions=["fenced_code", "tables", "sane_lists", "nl2br", "codehilite"], + extension_configs={ + "codehilite": { + "css_class": "codehilite", + "guess_lang": False, + "linenums": False, + } + }, + ) + except Exception: + body = f"
{html.escape(md_text)}
" + else: + body = f"
{html.escape(md_text)}
" + + css = self._build_render_css() + return f""" + + + + + + {body} + + """ + + 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 + response_format = self.response_format_combo.currentText().strip() + schema_path = Path(self.schema_path_edit.text().strip()) if self.schema_path_edit.text().strip() else None + if response_format == "schema" and (schema_path is None or not schema_path.is_file()): + self.msg_error("Schema fehlt", "Für Response-Format 'schema' muss eine gültige JSON-Schema-Datei gesetzt sein.") + return + + self._raw_markdown = "" + self._last_json = "" + self._last_detected_kind = "" + self.response_raw.setPlainText("") + self.response_view.setHtml("") + self.lbl_response_meta.setText("Erkannt: -") + self.status.showMessage("Sende Anfrage …") + self.ui_busy(True) + + self._gen_worker = GenerateWorker( + GenerateParams( + base_url=base, + model=model, + user_prompt=prompt, + system_prompt=self.system_prompt_edit.toPlainText().strip() or DEFAULT_SYSTEM_PROMPT, + api_mode=self.api_mode_combo.currentText().strip() or "chat", + stream=self.chk_stream.isChecked(), + keep_alive=self.keep_alive_edit.text().strip() or "30m", + num_ctx=self._parse_int(self.num_ctx_edit.text()), + temperature=self._parse_float(self.temperature_edit.text()), + response_format=response_format, + schema_path=schema_path if response_format == "schema" else None, + ) + ) + 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 _render_response(self, assistant_text: str) -> None: + effective_mode = self._effective_view_mode(assistant_text) + structured_candidate = self._structured_json_candidate(assistant_text) + detected_kind = self._detect_response_kind(structured_candidate or assistant_text) + self._last_detected_kind = detected_kind + self.lbl_response_meta.setText(f"Erkannt: {self._kind_label(detected_kind)} | Ansicht: {effective_mode}") + + if effective_mode == "Raw JSON": + candidate = structured_candidate or (assistant_text or "").strip() + if not candidate: + self.response_view.setHtml("") + return + if self._looks_like_json(candidate): + try: + normalized = self._pretty_json_block(candidate) + except Exception: + normalized = f"```json\n{candidate}\n```" + else: + normalized = f"```text\n{candidate}\n```" + elif effective_mode == "Markdown/Code": + normalized = self._normalize_response_for_markdown(assistant_text) + else: + normalized = self._normalize_response_for_read_mode(assistant_text) + + if not normalized: + self.response_view.setHtml("") + return + html_doc = self._markdown_to_html(normalized) + self.response_view.setHtml(html_doc) + + 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._render_response(self._raw_markdown) + + def _on_done(self, assistant_text: str, raw_json: str) -> None: + self._render_timer.stop() + self._raw_markdown = assistant_text + self._last_json = raw_json + if self.response_format_combo.currentText().strip() in {"json", "schema"}: + structured = self._structured_json_candidate(assistant_text) + self.response_raw.setPlainText(structured or assistant_text or raw_json) + else: + self.response_raw.setPlainText(assistant_text) + self._render_response(assistant_text) + 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) + + def copy_response(self) -> None: + QApplication.clipboard().setText(self._raw_markdown or self.response_raw.toPlainText()) + self.status.showMessage("Antwort in Clipboard kopiert.", 2500) + + def copy_json_only(self) -> None: + candidate = self._structured_json_candidate(self._raw_markdown) + if not candidate: + self.msg_info("Kein JSON gefunden", "Ich habe in der letzten Antwort kein strukturiertes JSON gefunden.") + return + try: + text = self._pretty_json_text(candidate) + except Exception: + text = candidate + QApplication.clipboard().setText(text) + self.status.showMessage("JSON in Clipboard kopiert.", 2500) + + def copy_sql_only(self) -> None: + blocks: List[str] = [] + if self._raw_markdown: + blocks.extend(extract_sql_blocks(self._raw_markdown)) + if not blocks and self._last_json: + try: + parsed = json.loads(self._last_json) + blocks.extend(extract_sql_from_json(parsed)) + except Exception: + pass + if not blocks: + self.msg_info("Kein SQL gefunden", "Ich habe in der Antwort keine SQL-Codeblöcke oder SQL-Felder gefunden.") + return + QApplication.clipboard().setText(build_sql_only_text(blocks)) + self.status.showMessage(f"SQL kopiert ({len(blocks)} Block/Blöcke).", 3000) + + 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 _prepare_temp_modelfile(self, cfg: AppConfig) -> Path: + text = cfg.modelfile_path.read_text(encoding="utf-8") + text = text.replace("${BASE_MODEL}", cfg.base_model).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) + self._temp_modelfile = Path(path) + return self._temp_modelfile + + 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_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 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 + 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 (Qwen3-ready)\n\n" + "- lokale Ollama API für Antworten\n" + "- chat/generate umschaltbar\n" + "- num_ctx, temperature, keep_alive steuerbar\n" + "- structured outputs per json oder schema\n" + "- getrennte Steuerung für Base-/Expert-Modell\n", + ) + + 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(1580, 1040) + w.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main())