Files
sql-ai-gui/sql_ai_gui_local_ollama_qwen3.py

1794 lines
70 KiB
Python

#!/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"<pre><code>{html.escape(md_text)}</code></pre>"
else:
body = f"<pre><code>{html.escape(md_text)}</code></pre>"
css = self._build_render_css()
return f"""
<html>
<head>
<meta charset="utf-8">
<style>{css}</style>
</head>
<body>{body}</body>
</html>
"""
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())