1794 lines
70 KiB
Python
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())
|