Consolidated local ollama model logic

This commit is contained in:
2026-04-07 15:42:00 +02:00
parent 9d8aa88bd0
commit 7309541c24
9 changed files with 1171 additions and 527 deletions
+36 -59
View File
@@ -1,73 +1,50 @@
# JR SQL AI GUI (Ollama) # SQL AI native Ollama package v3
Schlanke, stabile GUI für **Arch Linux / Hyprland (Wayland)**, um ein Ollama-Modell (z.B. `jr-sql-expert:latest`) bequem zu nutzen. Dieses Paket konsolidiert dein Setup auf **lokales Ollama** statt Docker.
## Features ## Enthalten
- Zwei Bereiche: - `scripts/bootstrap-native.sh`
- **Prompt/Kontext** (links) - `scripts/update-native.sh`
- **Antwort** (rechts) inkl. **Markdown Rendering** - `scripts/selftest-native.sh`
- **Streaming** (Antwort läuft live ein) - `scripts/cleanup-docker-ollama.sh`
- Buttons: - `scripts/common.sh`
- **An AI senden** - `sql_ai_gui_local_ollama.py`
- **Antwort kopieren** (Markdown)
- **Copy SQL only** (extrahiert SQL aus ```sql```-Blöcken bzw. SQL-ähnlichen Code-Fences)
- **Modell aktualisieren (pull)** über Ollama API
- **Ollama Runtime updaten** (optional) via Docker (`docker pull` + `docker restart`)
## Voraussetzungen ## Wesentliche Änderungen
- Ein laufender Ollama Server (bei dir z.B. Docker Container auf `127.0.0.1:11434`) - kein `docker compose` mehr in Bootstrap/Update/Selftest
- Python 3.11+ empfohlen - Autostart der lokalen Ollama-Runtime über `systemctl --user start ollama.service` oder Fallback `ollama serve`
- gemeinsamer Lock gegen parallele Modellupdates
- robuste Verifikation über `/api/tags`
- GUI nutzt das lokale `ollama`-Binary für Modellverwaltung und Rebuild des Expert-Modells
- GUI nutzt die lokale Ollama-API für performantes Streaming
## Installation (Arch Linux) ## Erwartete Projektstruktur
### Systempakete (minimal) - `.env`
```bash - `Modelfile`
sudo pacman -S pyside6 python-requests - `bin/sqlai`
``` - `scripts/`
### Optional: venv (isoliert) ## Nützliche .env Werte
```bash
python -m venv .venv
source .venv/bin/activate
pip install PySide6 requests
```
## Start
```bash ```bash
python sql_ai_gui.py OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_BIN=ollama
BASE_MODEL=qwen2.5-coder:14b
EXPERT_MODEL=jr-sql-expert
EXTRA_MODELS="sqlcoder"
OLLAMA_AUTOSTART=1
OLLAMA_SYSTEMD_SERVICE=ollama.service
OLLAMA_SYSTEMD_SCOPE=auto
``` ```
Falls Qt/Wayland zickt (selten), erzwingen: ## Empfohlene Reihenfolge
```bash ```bash
QT_QPA_PLATFORM=wayland python sql_ai_gui.py ./scripts/cleanup-docker-ollama.sh
./scripts/update-native.sh
./scripts/selftest-native.sh
python ./sql_ai_gui_local_ollama.py
``` ```
## Konfiguration
Per Environment Variablen (optional):
- `OLLAMA_BASE_URL` (Default: `http://127.0.0.1:11434`)
- `OLLAMA_MODEL` (Default: `jr-sql-expert:latest`)
- `OLLAMA_DOCKER_CONTAINER` (Default: `ollama`)
Beispiel:
```bash
OLLAMA_MODEL="jr-sql-expert:latest" OLLAMA_BASE_URL="http://127.0.0.1:11434" python sql_ai_gui.py
```
## Sicherheit
- Standardmäßig wird nur auf `127.0.0.1` gearbeitet.
- Runtime-Update nutzt `docker`. Wenn dein User keine Docker-Rechte hat, wird das fehlschlagen.
## Repo Layout
- `sql_ai_gui.py` die App (single-file)
- `docs/` kurze Doku (Markdown)
- `requirements.txt` falls du lieber via pip installierst
## License
MIT (siehe LICENSE)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_OVERRIDE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT_OVERRIDE
# shellcheck disable=SC1091
source "${ROOT_OVERRIDE}/scripts/common.sh"
init_log bootstrap-native
acquire_lock jr-sql-ai-native-model.lock
print_header "bootstrap-native: START"
need_cmd curl
need_cmd python
load_env
need_cmd "$OLLAMA_BIN"
check_for_legacy_docker_ollama
[[ -n "$BASE_MODEL" ]] || fail "BASE_MODEL is empty" 50
log "bootstrap-native: ensuring local Ollama runtime is ready at ${OLLAMA_URL}"
ensure_ollama_ready "$OLLAMA_API_MAX_WAIT" "$OLLAMA_API_START_WAIT" || fail "Ollama API not reachable at ${OLLAMA_URL}" 51
ok "bootstrap-native: Ollama API reachable"
if fetch_version >/dev/null 2>&1; then
ok "bootstrap-native: Ollama version endpoint reachable"
fi
pull_configured_models
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
render_modelfile "$tmp"
log "bootstrap-native: building expert model ${EXPERT_MODEL}"
ollama_cmd create "${EXPERT_MODEL}" -f "$tmp"
verify_models_available
ok "bootstrap-native: base and expert models are available"
warmup_sqlai
print_footer "bootstrap-native: END status=OK log=${LOG_FILE}"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="${ROOT}/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/cleanup-docker-ollama-$(date -Iseconds).log"
exec > >(tee -a "$LOG_FILE") 2>&1
ts(){ date -Is; }
log(){ echo "[$(ts)] $*"; }
log "cleanup-docker-ollama: START ROOT=${ROOT}"
if ! command -v docker >/dev/null 2>&1; then
log "cleanup-docker-ollama: docker not installed; nothing to do"
exit 0
fi
if [[ -f "${ROOT}/docker-compose.yml" ]] && docker compose version >/dev/null 2>&1; then
log "cleanup-docker-ollama: docker compose down --remove-orphans"
docker compose -f "${ROOT}/docker-compose.yml" down --remove-orphans || true
fi
if docker ps -a --format '{{.Names}}' | grep -qx 'ollama'; then
log "cleanup-docker-ollama: removing container ollama"
docker rm -f ollama || true
else
log "cleanup-docker-ollama: container ollama not present"
fi
if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx 'ollama/ollama:latest'; then
log "cleanup-docker-ollama: removing image ollama/ollama:latest"
docker image rm -f ollama/ollama:latest || true
else
log "cleanup-docker-ollama: image ollama/ollama:latest not present"
fi
if [[ "${PURGE_OLLAMA_DOCKER_VOLUMES:-0}" == "1" ]]; then
mapfile -t volumes < <(docker volume ls --format '{{.Name}}' | grep -E 'ollama' || true)
if (( ${#volumes[@]} > 0 )); then
log "cleanup-docker-ollama: removing volumes: ${volumes[*]}"
docker volume rm "${volumes[@]}" || true
else
log "cleanup-docker-ollama: no ollama volumes found"
fi
else
log "cleanup-docker-ollama: keeping volumes (set PURGE_OLLAMA_DOCKER_VOLUMES=1 to remove them)"
fi
log "cleanup-docker-ollama: END log=${LOG_FILE}"
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${ROOT_OVERRIDE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
MODFILE="${MODFILE:-${ROOT}/Modelfile}"
SQLAI_BIN="${SQLAI_BIN:-${ROOT}/bin/sqlai}"
OLLAMA_BIN="${OLLAMA_BIN:-ollama}"
LOG_FILE=""
LOCK_FD=""
_ts_prefix(){ date -Is; }
ts(){ _ts_prefix; }
log(){ echo "[$(ts)] $*"; }
ok(){ log "OK $*"; }
warn(){ log "WARN $*"; }
fail(){
local msg="$1"
local code="${2:-1}"
log "FAIL $msg"
exit "$code"
}
need_cmd(){
command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" 10
}
init_log(){
local name="$1"
local log_dir="${ROOT}/logs"
mkdir -p "$log_dir"
LOG_FILE="${log_dir}/${name}-$(date -Iseconds).log"
export LOG_FILE
exec > >(tee -a "$LOG_FILE") 2>&1
}
acquire_lock(){
local name="${1:-jr-sql-ai-native.lock}"
local wait_seconds="${LOCK_WAIT_SECONDS:-600}"
local lock_dir="${ROOT}/.locks"
mkdir -p "$lock_dir"
local lock_path="${lock_dir}/${name}"
if command -v flock >/dev/null 2>&1; then
exec 8>"$lock_path"
flock -w "$wait_seconds" 8 || fail "could not acquire lock ${lock_path}" 11
LOCK_FD="8"
return 0
fi
local i
for ((i=0; i<wait_seconds; i++)); do
if mkdir "$lock_path.lock" 2>/dev/null; then
trap 'rmdir "'$lock_path'.lock" >/dev/null 2>&1 || true' EXIT
return 0
fi
sleep 1
done
fail "could not acquire lock ${lock_path}.lock" 11
}
load_env(){
if [[ ! -f "$ENV_FILE" && -f "${ROOT}/.env.example" ]]; then
cp -n "${ROOT}/.env.example" "$ENV_FILE" || true
fi
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
: "${OLLAMA_URL:=http://127.0.0.1:11434}"
: "${EXPERT_MODEL:=jr-sql-expert}"
: "${BASE_MODEL:=}"
: "${EXTRA_MODELS:=}"
: "${OLLAMA_BIN:=ollama}"
: "${OLLAMA_AUTOSTART:=1}"
: "${OLLAMA_SERVE_FALLBACK:=1}"
: "${OLLAMA_SYSTEMD_SERVICE:=ollama.service}"
: "${OLLAMA_SYSTEMD_SCOPE:=auto}"
: "${OLLAMA_API_MAX_WAIT:=120}"
: "${OLLAMA_API_START_WAIT:=45}"
export OLLAMA_URL EXPERT_MODEL BASE_MODEL EXTRA_MODELS OLLAMA_BIN OLLAMA_AUTOSTART OLLAMA_SERVE_FALLBACK OLLAMA_SYSTEMD_SERVICE OLLAMA_SYSTEMD_SCOPE OLLAMA_API_MAX_WAIT OLLAMA_API_START_WAIT
export OLLAMA_HOST="$OLLAMA_URL"
}
is_json(){
python -c 'import json,sys; json.load(sys.stdin)' >/dev/null 2>&1
}
http_get(){
local path="$1"
curl -fsS --connect-timeout 3 --max-time 20 "${OLLAMA_URL}${path}"
}
wait_for_ollama_api(){
local max_wait="${1:-120}"
local i
for ((i=1; i<=max_wait; i++)); do
if http_get "/api/tags" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
fetch_tags(){
http_get "/api/tags"
}
fetch_version(){
http_get "/api/version"
}
model_in_tags(){
local model="$1"
local tags="$2"
TAGS_JSON="$tags" python - "$model" <<'PY'
import json
import os
import sys
m = sys.argv[1]
obj = json.loads(os.environ['TAGS_JSON'])
names = {it.get('name') for it in obj.get('models', []) if it.get('name')}
ok = (
m in names or
f"{m}:latest" in names or
(m.endswith(':latest') and m[:-7] in names)
)
sys.exit(0 if ok else 1)
PY
}
render_modelfile(){
local out_file="$1"
[[ -f "$MODFILE" ]] || fail "Modelfile not found at $MODFILE" 20
[[ -n "$BASE_MODEL" ]] || fail "BASE_MODEL is empty" 21
python - "$MODFILE" "$out_file" "$BASE_MODEL" <<'PY'
from pathlib import Path
import sys
src = Path(sys.argv[1]).read_text(encoding='utf-8')
out = src.replace('${BASE_MODEL}', sys.argv[3])
Path(sys.argv[2]).write_text(out, encoding='utf-8')
PY
}
ollama_cmd(){
OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" "$@"
}
ollama_service_exists(){
local scope="$1"
if [[ "$scope" == "user" ]]; then
systemctl --user cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1
else
systemctl cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1
fi
}
start_ollama_via_systemd(){
command -v systemctl >/dev/null 2>&1 || return 1
if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "user" ]]; then
if ollama_service_exists user; then
log "starting Ollama via systemd user service ${OLLAMA_SYSTEMD_SERVICE}"
systemctl --user start "$OLLAMA_SYSTEMD_SERVICE"
return 0
fi
fi
if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "system" ]]; then
if ollama_service_exists system; then
log "starting Ollama via systemd system service ${OLLAMA_SYSTEMD_SERVICE}"
systemctl start "$OLLAMA_SYSTEMD_SERVICE"
return 0
fi
fi
return 1
}
start_ollama_fallback(){
[[ "$OLLAMA_SERVE_FALLBACK" == "1" ]] || return 1
if pgrep -fa '(^|/)ollama( |$).*serve' >/dev/null 2>&1; then
warn "ollama serve process already exists but API is not reachable yet"
return 0
fi
local serve_log="${ROOT}/logs/ollama-serve-fallback.log"
mkdir -p "${ROOT}/logs"
log "starting Ollama via detached fallback process (${OLLAMA_BIN} serve)"
nohup env OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" serve >>"$serve_log" 2>&1 &
return 0
}
ensure_ollama_ready(){
local max_wait="${1:-$OLLAMA_API_MAX_WAIT}"
local start_wait="${2:-$OLLAMA_API_START_WAIT}"
if wait_for_ollama_api 3; then
return 0
fi
[[ "$OLLAMA_AUTOSTART" == "1" ]] || return 1
if start_ollama_via_systemd; then
wait_for_ollama_api "$start_wait" && return 0
warn "Ollama service start issued, but API is still not reachable"
fi
if start_ollama_fallback; then
wait_for_ollama_api "$max_wait" && return 0
fi
return 1
}
iter_models(){
if [[ -n "$BASE_MODEL" ]]; then
printf '%s\n' "$BASE_MODEL"
fi
if [[ -n "$EXTRA_MODELS" ]]; then
printf '%s\n' "$EXTRA_MODELS" | tr ' ' '\n' | sed '/^$/d'
fi
}
pull_configured_models(){
local model
while IFS= read -r model; do
[[ -n "$model" ]] || continue
log "pulling model ${model}"
ollama_cmd pull "$model"
done < <(iter_models)
}
check_for_legacy_docker_ollama(){
if ! command -v docker >/dev/null 2>&1; then
return 0
fi
if docker ps --format '{{.Names}}' | grep -qx 'ollama'; then
fail "legacy docker container 'ollama' is still running; run cleanup-docker-ollama.sh first" 30
fi
if docker ps -a --format '{{.Names}}' | grep -qx 'ollama'; then
warn "legacy docker container 'ollama' still exists in stopped state; cleanup recommended"
fi
if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx 'ollama/ollama:latest'; then
warn "legacy docker image ollama/ollama:latest still exists; cleanup recommended"
fi
}
warmup_sqlai(){
if [[ -x "$SQLAI_BIN" ]]; then
log "warmup via ${SQLAI_BIN}"
"$SQLAI_BIN" ask --text "Warmup: reply with exactly 'OK'." --no-metrics || warn "warmup failed"
else
warn "${SQLAI_BIN} not executable; skipping warmup"
fi
}
verify_models_available(){
local tags
tags="$(fetch_tags)"
printf '%s' "$tags" | is_json || fail "/api/tags did not return valid JSON" 40
[[ -z "$BASE_MODEL" ]] || model_in_tags "$BASE_MODEL" "$tags" || fail "base model missing: ${BASE_MODEL}" 41
model_in_tags "$EXPERT_MODEL" "$tags" || fail "expert model missing: ${EXPERT_MODEL}" 42
}
print_header(){
echo "================================================================================"
log "$1 ROOT=$ROOT"
}
print_footer(){
log "$1"
echo "================================================================================"
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_OVERRIDE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT_OVERRIDE
# shellcheck disable=SC1091
source "${ROOT_OVERRIDE}/scripts/common.sh"
init_log selftest-native
acquire_lock jr-sql-ai-native-model.lock
print_header "selftest-native: START"
load_env
need_cmd curl
need_cmd python
need_cmd "$OLLAMA_BIN"
need_cmd grep
check_for_legacy_docker_ollama
[[ -x "$SQLAI_BIN" ]] || fail "bin/sqlai missing or not executable at ${SQLAI_BIN}" 70
log "selftest-native: ensuring local Ollama runtime is ready at ${OLLAMA_URL}"
ensure_ollama_ready "$OLLAMA_API_MAX_WAIT" "$OLLAMA_API_START_WAIT" || fail "Ollama API not reachable at ${OLLAMA_URL}" 71
ok "selftest-native: Ollama API reachable"
tags="$(fetch_tags)"
[[ -n "$tags" ]] || fail "/api/tags returned empty body" 72
printf '%s' "$tags" | is_json || fail "/api/tags did not return valid JSON" 73
ok "selftest-native: /api/tags returned valid JSON"
model_in_tags "$EXPERT_MODEL" "$tags" || fail "expert model missing: ${EXPERT_MODEL}" 74
ok "selftest-native: expert model present: ${EXPERT_MODEL}"
if [[ -n "$BASE_MODEL" ]]; then
model_in_tags "$BASE_MODEL" "$tags" || fail "base model missing: ${BASE_MODEL}" 75
ok "selftest-native: base model present: ${BASE_MODEL}"
fi
log "selftest-native: warmup request via sqlai"
set +e
"$SQLAI_BIN" ask --text "Warmup: reply with exactly 'OK'." --no-metrics >/dev/null 2>&1
rc=$?
set -e
[[ "$rc" -eq 0 ]] || warn "warmup failed rc=${rc}"
log "selftest-native: real query via sqlai"
set +e
out="$("$SQLAI_BIN" ask --text "Give a concise checklist to troubleshoot parameter sniffing in SQL Server 2022. Keep it technical." --no-metrics 2>&1)"
rc=$?
set -e
[[ "$rc" -eq 0 ]] || fail "real query failed rc=${rc}" 76
[[ -n "${out//[[:space:]]/}" ]] || fail "real query returned empty output" 77
ok "selftest-native: real query returned non-empty output"
print_footer "selftest-native: END status=OK log=${LOG_FILE}"
+1 -1
View File
@@ -8,7 +8,7 @@ export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland}"
# Optional: Wenn du einen venv nutzt # Optional: Wenn du einen venv nutzt
if [[ -x "$APP_DIR/.venv/bin/python" ]]; then if [[ -x "$APP_DIR/.venv/bin/python" ]]; then
exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui.py" exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui_local_ollama.py"
else else
exec python "$APP_DIR/sql_ai_gui.py" exec python "$APP_DIR/sql_ai_gui.py"
fi fi
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_OVERRIDE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT_OVERRIDE
# shellcheck disable=SC1091
source "${ROOT_OVERRIDE}/scripts/common.sh"
init_log update-native
acquire_lock jr-sql-ai-native-model.lock
print_header "update-native: START"
load_env
need_cmd curl
need_cmd python
need_cmd "$OLLAMA_BIN"
check_for_legacy_docker_ollama
[[ -n "$BASE_MODEL" ]] || fail "BASE_MODEL is empty" 60
log "update-native: ensuring local Ollama runtime is ready at ${OLLAMA_URL}"
ensure_ollama_ready "$OLLAMA_API_MAX_WAIT" "$OLLAMA_API_START_WAIT" || fail "Ollama API not reachable at ${OLLAMA_URL}" 61
ok "update-native: Ollama API reachable"
pull_configured_models
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
render_modelfile "$tmp"
log "update-native: rebuilding expert model ${EXPERT_MODEL}"
ollama_cmd create "${EXPERT_MODEL}" -f "$tmp"
verify_models_available
ok "update-native: base and expert models are available"
warmup_sqlai
print_footer "update-native: END status=OK log=${LOG_FILE}"
-467
View File
@@ -1,467 +0,0 @@
#!/usr/bin/env python3
"""
JR SQL AI GUI (Ollama) - lightweight Arch/Hyprland friendly GUI.
- Left: Prompt/context
- Right: Rendered Markdown answer + raw markdown
- Buttons: Send, Copy, Copy SQL only, Model pull
"""
import json
import os
import re
import sys
from dataclasses import dataclass
from typing import Optional, List
import requests
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QPlainTextEdit,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
QCheckBox,
QTextBrowser,
)
# -----------------------------
# Config (defaults)
# -----------------------------
DEFAULT_OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "jr-sql-expert:latest")
# -----------------------------
# Helpers
# -----------------------------
def is_docker_available() -> bool:
return shutil.which("docker") is not None
def human_error(e: Exception) -> str:
return f"{type(e).__name__}: {e}"
SQL_KW_RE = re.compile(
r"\\b(select|from|where|join|group|order|having|insert|update|delete|create|alter|drop|with|merge)\\b",
re.IGNORECASE,
)
FENCE_RE = re.compile(r"```(\\w+)?\\s*\\n(.*?)\\n```", re.DOTALL)
def extract_sql_blocks(markdown_text: str) -> List[str]:
"""
Extract SQL from markdown fenced code blocks.
Priority:
1) ```sql ... ```
2) any fenced block that looks like SQL (contains common keywords)
"""
blocks = []
for m in FENCE_RE.finditer(markdown_text):
lang = (m.group(1) or "").strip().lower()
body = (m.group(2) or "").strip()
if not body:
continue
if lang == "sql":
blocks.append(body)
elif lang in ("tsql", "t-sql", "mssql"):
blocks.append(body)
else:
if SQL_KW_RE.search(body):
blocks.append(body)
return blocks
def build_sql_only_text(blocks: List[str]) -> str:
if not blocks:
return ""
return "\\n\\n-- ----------------------------------------\\n\\n".join(blocks) + "\\n"
# -----------------------------
# Workers (threads)
# -----------------------------
@dataclass
class GenerateParams:
base_url: str
model: str
prompt: str
stream: bool = True
class GenerateWorker(QThread):
chunk = Signal(str) # streaming chunk
done = Signal(str) # full response
error = Signal(str)
def __init__(self, params: GenerateParams):
super().__init__()
self.params = params
def run(self) -> None:
try:
url = self.params.base_url.rstrip("/") + "/api/generate"
payload = {
"model": self.params.model,
"prompt": self.params.prompt,
"stream": self.params.stream,
}
with requests.post(url, json=payload, stream=self.params.stream, timeout=(5, 600)) as r:
r.raise_for_status()
if not self.params.stream:
data = r.json()
self.done.emit(data.get("response", ""))
return
full = []
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
obj = json.loads(line)
part = obj.get("response", "")
if part:
full.append(part)
self.chunk.emit(part)
if obj.get("done", False):
break
self.done.emit("".join(full))
except Exception as e:
self.error.emit(human_error(e))
class PullModelWorker(QThread):
status = Signal(str)
done = Signal()
error = Signal(str)
def __init__(self, base_url: str, model: str):
super().__init__()
self.base_url = base_url
self.model = model
def run(self) -> None:
try:
url = self.base_url.rstrip("/") + "/api/pull"
payload = {"name": self.model, "stream": True}
with requests.post(url, json=payload, stream=True, timeout=(5, 1800)) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
obj = json.loads(line)
st = obj.get("status")
total = obj.get("total")
completed = obj.get("completed")
if st and total and completed:
self.status.emit(f"{st}: {completed}/{total}")
elif st:
self.status.emit(st)
self.done.emit()
except Exception as e:
self.error.emit(human_error(e))
# -----------------------------
# Main Window
# -----------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("JR SQL AI GUI (Ollama)")
self._gen_worker: Optional[GenerateWorker] = None
self._pull_worker: Optional[PullModelWorker] = None
self._raw_markdown: str = ""
self._render_timer = QTimer(self)
self._render_timer.setInterval(250) # throttle UI updates
self._render_timer.timeout.connect(self._render_markdown_throttled)
root = QWidget()
self.setCentralWidget(root)
layout = QVBoxLayout(root)
# Top bar
top = QHBoxLayout()
layout.addLayout(top)
top.addWidget(QLabel("Ollama URL:"))
self.base_url = QLineEdit(DEFAULT_OLLAMA_BASE_URL)
self.base_url.setMinimumWidth(260)
top.addWidget(self.base_url, 2)
top.addWidget(QLabel("Model:"))
self.model = QComboBox()
self.model.setEditable(True)
self.model.addItem(DEFAULT_MODEL)
self.model.setCurrentText(DEFAULT_MODEL)
self.model.setMinimumWidth(220)
top.addWidget(self.model, 1)
self.btn_refresh_models = QPushButton("Models laden")
self.btn_refresh_models.clicked.connect(self.refresh_models)
top.addWidget(self.btn_refresh_models)
self.chk_stream = QCheckBox("Streaming")
self.chk_stream.setChecked(True)
top.addWidget(self.chk_stream)
# Split view
splitter = QSplitter(Qt.Horizontal)
layout.addWidget(splitter, 1)
# Left: prompt
left = QWidget()
left_l = QVBoxLayout(left)
left_l.addWidget(QLabel("Prompt / Kontext"))
self.prompt = QPlainTextEdit()
self.prompt.setPlaceholderText("Prompt + Kontext hier einfügen …")
self.prompt.setFont(QFont("Monospace", 10))
left_l.addWidget(self.prompt, 1)
btn_row = QHBoxLayout()
self.btn_send = QPushButton("An AI senden")
self.btn_send.clicked.connect(self.on_send)
btn_row.addWidget(self.btn_send)
self.btn_clear = QPushButton("Leeren")
self.btn_clear.clicked.connect(lambda: self.prompt.setPlainText(""))
btn_row.addWidget(self.btn_clear)
left_l.addLayout(btn_row)
# Right: response
right = QWidget()
right_l = QVBoxLayout(right)
right_l.addWidget(QLabel("Antwort (Markdown gerendert)"))
self.response_view = QTextBrowser()
self.response_view.setOpenExternalLinks(True)
self.response_view.setFont(QFont("Monospace", 10))
right_l.addWidget(self.response_view, 1)
self.response_raw = QPlainTextEdit()
self.response_raw.setReadOnly(True)
self.response_raw.setFont(QFont("Monospace", 10))
self.response_raw.setPlaceholderText("Raw Antwort (für Copy/Debug).")
self.response_raw.setMaximumHeight(140)
right_l.addWidget(self.response_raw)
right_btn_row = QHBoxLayout()
self.btn_copy = QPushButton("Antwort kopieren")
self.btn_copy.clicked.connect(self.copy_response)
right_btn_row.addWidget(self.btn_copy)
self.btn_copy_sql = QPushButton("Copy SQL only")
self.btn_copy_sql.clicked.connect(self.copy_sql_only)
right_btn_row.addWidget(self.btn_copy_sql)
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.on_close)
right_btn_row.addWidget(self.btn_close)
self.btn_model_pull = QPushButton("Modell aktualisieren (pull)")
self.btn_model_pull.clicked.connect(self.on_pull_model)
right_btn_row.addWidget(self.btn_model_pull)
right_l.addLayout(right_btn_row)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes([520, 760])
self.status = QStatusBar()
self.setStatusBar(self.status)
self.status.showMessage("Bereit.")
QTimer.singleShot(300, self.refresh_models)
# -------------- UI helpers --------------
def ui_busy(self, busy: bool) -> None:
for w in [self.btn_send, self.btn_model_pull, self.btn_refresh_models, self.btn_copy_sql, self.btn_close]:
w.setEnabled(not busy)
self.prompt.setEnabled(not busy)
self.base_url.setEnabled(not busy)
self.model.setEnabled(not busy)
self.chk_stream.setEnabled(not busy)
def msg_error(self, title: str, text: str) -> None:
QMessageBox.critical(self, title, text)
def msg_info(self, title: str, text: str) -> None:
QMessageBox.information(self, title, text)
# -------------- Model list --------------
def refresh_models(self) -> None:
base = self.base_url.text().strip().rstrip("/")
if not base:
return
try:
r = requests.get(base + "/api/tags", timeout=(3, 15))
r.raise_for_status()
data = r.json()
models = [m.get("name") for m in data.get("models", []) if m.get("name")]
if models:
current = self.model.currentText()
self.model.clear()
self.model.addItems(models)
if current in models:
self.model.setCurrentText(current)
else:
self.model.setCurrentIndex(0)
self.status.showMessage(f"{len(models)} Modelle geladen.", 2500)
else:
self.status.showMessage("Keine Modelle gefunden (api/tags leer).", 5000)
except Exception as e:
self.status.showMessage(f"Model-Liste konnte nicht geladen werden: {human_error(e)}", 8000)
# -------------- Send / Generate --------------
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 Model setzen.")
return
self._raw_markdown = ""
self.response_raw.setPlainText("")
self.response_view.setMarkdown("")
self.status.showMessage("Sende Anfrage …")
self.ui_busy(True)
params = GenerateParams(
base_url=base,
model=model,
prompt=prompt,
stream=self.chk_stream.isChecked(),
)
self._gen_worker = GenerateWorker(params)
self._gen_worker.chunk.connect(self._on_chunk)
self._gen_worker.done.connect(self._on_done)
self._gen_worker.error.connect(self._on_gen_error)
self._gen_worker.start()
if self.chk_stream.isChecked():
self._render_timer.start()
def _on_chunk(self, s: str) -> None:
self._raw_markdown += s
self.response_raw.setPlainText(self._raw_markdown)
self.response_raw.verticalScrollBar().setValue(self.response_raw.verticalScrollBar().maximum())
def _render_markdown_throttled(self) -> None:
if self._raw_markdown:
self.response_view.setMarkdown(self._raw_markdown)
def _on_done(self, full: str) -> None:
self._render_timer.stop()
if not self.chk_stream.isChecked():
self._raw_markdown = full
self.response_raw.setPlainText(full)
self.response_view.setMarkdown(self._raw_markdown)
self.status.showMessage("Fertig.", 2500)
self.ui_busy(False)
def _on_gen_error(self, err: str) -> None:
self._render_timer.stop()
self.ui_busy(False)
self.status.showMessage("Fehler.", 5000)
self.msg_error("Ollama Fehler", err)
# -------------- Copy actions --------------
def copy_response(self) -> None:
QApplication.clipboard().setText(self._raw_markdown or self.response_raw.toPlainText())
self.status.showMessage("Antwort (Markdown) in Clipboard kopiert.", 2500)
def copy_sql_only(self) -> None:
md = self._raw_markdown or self.response_raw.toPlainText()
blocks = extract_sql_blocks(md)
sql_text = build_sql_only_text(blocks)
if not sql_text:
self.msg_info("Kein SQL gefunden", "Ich habe in der Antwort keine SQL-Codeblöcke gefunden.")
return
QApplication.clipboard().setText(sql_text)
self.status.showMessage(f"SQL kopiert ({len(blocks)} Block/Blöcke).", 3000)
# -------------- Pull model --------------
def on_pull_model(self) -> None:
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 Model setzen.")
return
self.ui_busy(True)
self.status.showMessage(f"Pull: {model}")
self._pull_worker = PullModelWorker(base, model)
self._pull_worker.status.connect(lambda s: self.status.showMessage(f"Pull: {s}"))
self._pull_worker.done.connect(self._on_pull_done)
self._pull_worker.error.connect(self._on_pull_err)
self._pull_worker.start()
def _on_pull_done(self) -> None:
self.ui_busy(False)
self.status.showMessage("Model pull abgeschlossen.", 4000)
self.refresh_models()
def _on_pull_err(self, err: str) -> None:
self.ui_busy(False)
self.msg_error("Model pull fehlgeschlagen", err)
self.msg_error("Ollama Runtime Update fehlgeschlagen", err)
def on_close(self) -> None:
reply = QMessageBox.question(
self,
"Beenden",
"Wirklich beenden?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.close()
def main() -> int:
# For Wayland/Hyprland you can force:
# QT_QPA_PLATFORM=wayland python sql_ai_gui.py
app = QApplication(sys.argv)
app.setApplicationName("JR SQL AI GUI")
w = MainWindow()
w.resize(1250, 760)
w.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
+664
View File
@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
JR SQL AI GUI (local Ollama)
- Uses the local `ollama` CLI for model/runtime management.
- Uses the local Ollama HTTP API for fast streaming generation.
- Friendly to Arch Linux + Hyprland / Wayland.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, List, Optional
import requests
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QStatusBar,
QTextBrowser,
QVBoxLayout,
QWidget,
)
DEFAULT_OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
DEFAULT_OLLAMA_BIN = os.environ.get("OLLAMA_BIN", "ollama")
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "jr-sql-expert:latest")
DEFAULT_KEEP_ALIVE = os.environ.get("OLLAMA_KEEP_ALIVE", "10m")
DEFAULT_PROJECT_ROOT = Path(os.environ.get("JR_SQL_AI_ROOT", Path(__file__).resolve().parent))
DEFAULT_MODEFILE = Path(os.environ.get("MODEFILE", DEFAULT_PROJECT_ROOT / "Modelfile"))
DEFAULT_EXPERT_MODEL = os.environ.get("EXPERT_MODEL", "jr-sql-expert")
DEFAULT_BASE_MODEL = os.environ.get("BASE_MODEL", "")
def load_env_file(env_file: Path) -> None:
if not env_file.is_file():
return
for raw_line in env_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
load_env_file(DEFAULT_PROJECT_ROOT / ".env")
DEFAULT_EXPERT_MODEL = os.environ.get("EXPERT_MODEL", DEFAULT_EXPERT_MODEL)
DEFAULT_BASE_MODEL = os.environ.get("BASE_MODEL", DEFAULT_BASE_MODEL)
DEFAULT_OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL)
DEFAULT_OLLAMA_BIN = os.environ.get("OLLAMA_BIN", DEFAULT_OLLAMA_BIN)
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", DEFAULT_MODEL)
SQL_KW_RE = re.compile(
r"\b(select|from|where|join|group|order|having|insert|update|delete|create|alter|drop|with|merge)\b",
re.IGNORECASE,
)
FENCE_RE = re.compile(r"```(\w+)?\s*\n(.*?)\n```", re.DOTALL)
def human_error(e: Exception) -> str:
return f"{type(e).__name__}: {e}"
def extract_sql_blocks(markdown_text: str) -> List[str]:
blocks: List[str] = []
for match in FENCE_RE.finditer(markdown_text):
lang = (match.group(1) or "").strip().lower()
body = (match.group(2) or "").strip()
if not body:
continue
if lang in {"sql", "tsql", "t-sql", "mssql"}:
blocks.append(body)
elif SQL_KW_RE.search(body):
blocks.append(body)
return blocks
def build_sql_only_text(blocks: List[str]) -> str:
if not blocks:
return ""
return "\n\n-- ----------------------------------------\n\n".join(blocks) + "\n"
def cli_env(base_url: str) -> dict[str, str]:
env = os.environ.copy()
env["OLLAMA_HOST"] = base_url.strip()
return env
def run_cli(
ollama_bin: str,
base_url: str,
args: List[str],
timeout: int = 60,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[ollama_bin, *args],
env=cli_env(base_url),
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
def list_models_via_cli(ollama_bin: str, base_url: str) -> List[str]:
proc = run_cli(ollama_bin, base_url, ["ls"], timeout=20)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or proc.stdout or "ollama ls failed").strip())
models: List[str] = []
for raw in (proc.stdout or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("name ") or line.lower() == "name":
continue
first = line.split()[0]
if first and first != "NAME":
models.append(first)
if not models:
raise RuntimeError("Keine Modelle aus 'ollama ls' geparst.")
return models
def check_api_ready(base_url: str, timeout: float = 2.5) -> bool:
try:
r = requests.get(base_url.rstrip("/") + "/api/tags", timeout=(timeout, timeout))
r.raise_for_status()
data = r.json()
return isinstance(data, dict)
except Exception:
return False
def maybe_start_runtime(ollama_bin: str, base_url: str) -> bool:
if check_api_ready(base_url):
return True
service = os.environ.get("OLLAMA_SYSTEMD_SERVICE", "ollama.service")
for cmd in (["systemctl", "--user", "start", service], ["systemctl", "start", service]):
try:
subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False)
for _ in range(15):
if check_api_ready(base_url):
return True
time.sleep(1)
except Exception:
pass
try:
subprocess.Popen(
[ollama_bin, "serve"],
env=cli_env(base_url),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
for _ in range(20):
if check_api_ready(base_url):
return True
time.sleep(1)
except Exception:
pass
return check_api_ready(base_url)
def render_modelfile(template_path: Path, base_model: str) -> str:
if not template_path.is_file():
raise FileNotFoundError(f"Modelfile nicht gefunden: {template_path}")
if not base_model.strip():
raise ValueError("BASE_MODEL ist leer.")
return template_path.read_text(encoding="utf-8").replace("${BASE_MODEL}", base_model.strip())
@dataclass
class GenerateParams:
base_url: str
model: str
prompt: str
stream: bool = True
keep_alive: str = DEFAULT_KEEP_ALIVE
class GenerateWorker(QThread):
chunk = Signal(str)
done = Signal(str, dict)
error = Signal(str)
def __init__(self, params: GenerateParams):
super().__init__()
self.params = params
def run(self) -> None:
try:
session = requests.Session()
url = self.params.base_url.rstrip("/") + "/api/generate"
payload = {
"model": self.params.model,
"prompt": self.params.prompt,
"stream": self.params.stream,
"keep_alive": self.params.keep_alive,
}
with session.post(url, json=payload, stream=self.params.stream, timeout=(5, 1800)) as response:
response.raise_for_status()
if not self.params.stream:
data = response.json()
self.done.emit(data.get("response", ""), data)
return
full: List[str] = []
last_obj: dict = {}
for raw_line in response.iter_lines(decode_unicode=True):
if not raw_line:
continue
try:
obj = json.loads(raw_line)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Ungültiger Streaming-JSON-Chunk: {exc}") from exc
last_obj = obj
part = obj.get("response", "") or ""
if part:
full.append(part)
self.chunk.emit(part)
if obj.get("done", False):
break
self.done.emit("".join(full), last_obj)
except Exception as exc:
self.error.emit(human_error(exc))
class CommandWorker(QThread):
status = Signal(str)
done = Signal(str)
error = Signal(str)
def __init__(
self,
program: str,
args: List[str],
env: dict[str, str],
cwd: Optional[str] = None,
label: str = "command",
):
super().__init__()
self.program = program
self.args = args
self.env = env
self.cwd = cwd
self.label = label
def run(self) -> None:
try:
proc = subprocess.Popen(
[self.program, *self.args],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
cwd=self.cwd,
env=self.env,
)
assert proc.stdout is not None
lines: List[str] = []
for raw_line in proc.stdout:
line = raw_line.strip()
if line:
lines.append(line)
self.status.emit(line)
rc = proc.wait()
joined = "\n".join(lines)
if rc != 0:
raise RuntimeError(joined or f"{self.label} failed with exit code {rc}")
self.done.emit(joined)
except Exception as exc:
self.error.emit(human_error(exc))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("JR SQL AI GUI (local Ollama)")
self._gen_worker: Optional[GenerateWorker] = None
self._cmd_worker: Optional[CommandWorker] = None
self._raw_markdown = ""
self._last_metrics: dict = {}
self._render_timer = QTimer(self)
self._render_timer.setInterval(200)
self._render_timer.timeout.connect(self._render_markdown_throttled)
root = QWidget()
self.setCentralWidget(root)
layout = QVBoxLayout(root)
top = QHBoxLayout()
layout.addLayout(top)
top.addWidget(QLabel("Ollama URL:"))
self.base_url = QLineEdit(DEFAULT_OLLAMA_BASE_URL)
self.base_url.setMinimumWidth(260)
top.addWidget(self.base_url, 2)
top.addWidget(QLabel("Ollama Bin:"))
self.ollama_bin = QLineEdit(DEFAULT_OLLAMA_BIN)
self.ollama_bin.setMinimumWidth(140)
top.addWidget(self.ollama_bin, 1)
top.addWidget(QLabel("Model:"))
self.model = QComboBox()
self.model.setEditable(True)
self.model.addItem(DEFAULT_MODEL)
self.model.setCurrentText(DEFAULT_MODEL)
self.model.setMinimumWidth(220)
top.addWidget(self.model, 1)
self.btn_runtime = QPushButton("Runtime prüfen")
self.btn_runtime.clicked.connect(self.ensure_runtime)
top.addWidget(self.btn_runtime)
self.btn_refresh_models = QPushButton("Modelle laden")
self.btn_refresh_models.clicked.connect(self.refresh_models)
top.addWidget(self.btn_refresh_models)
self.chk_stream = QCheckBox("Streaming")
self.chk_stream.setChecked(True)
top.addWidget(self.chk_stream)
splitter = QSplitter(Qt.Horizontal)
layout.addWidget(splitter, 1)
left = QWidget()
left_l = QVBoxLayout(left)
left_l.addWidget(QLabel("Prompt / Kontext"))
self.prompt = QPlainTextEdit()
self.prompt.setPlaceholderText("Prompt + Kontext hier einfügen …")
self.prompt.setFont(QFont("Monospace", 10))
left_l.addWidget(self.prompt, 1)
btn_row = QHBoxLayout()
self.btn_send = QPushButton("An AI senden")
self.btn_send.clicked.connect(self.on_send)
btn_row.addWidget(self.btn_send)
self.btn_clear = QPushButton("Leeren")
self.btn_clear.clicked.connect(lambda: self.prompt.setPlainText(""))
btn_row.addWidget(self.btn_clear)
left_l.addLayout(btn_row)
right = QWidget()
right_l = QVBoxLayout(right)
right_l.addWidget(QLabel("Antwort (Markdown gerendert)"))
self.response_view = QTextBrowser()
self.response_view.setOpenExternalLinks(True)
self.response_view.setFont(QFont("Monospace", 10))
right_l.addWidget(self.response_view, 1)
self.response_raw = QPlainTextEdit()
self.response_raw.setReadOnly(True)
self.response_raw.setFont(QFont("Monospace", 10))
self.response_raw.setPlaceholderText("Raw Antwort (für Copy/Debug).")
self.response_raw.setMaximumHeight(180)
right_l.addWidget(self.response_raw)
right_btn_row = QHBoxLayout()
self.btn_copy = QPushButton("Antwort kopieren")
self.btn_copy.clicked.connect(self.copy_response)
right_btn_row.addWidget(self.btn_copy)
self.btn_copy_sql = QPushButton("Copy SQL only")
self.btn_copy_sql.clicked.connect(self.copy_sql_only)
right_btn_row.addWidget(self.btn_copy_sql)
self.btn_model_pull = QPushButton("Modell aktualisieren")
self.btn_model_pull.clicked.connect(self.on_pull_model)
right_btn_row.addWidget(self.btn_model_pull)
self.btn_rebuild_expert = QPushButton("Expert neu bauen")
self.btn_rebuild_expert.clicked.connect(self.on_rebuild_expert)
right_btn_row.addWidget(self.btn_rebuild_expert)
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.on_close)
right_btn_row.addWidget(self.btn_close)
right_l.addLayout(right_btn_row)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes([520, 820])
self.status = QStatusBar()
self.setStatusBar(self.status)
self.status.showMessage("Bereit.")
QTimer.singleShot(250, self.ensure_runtime)
QTimer.singleShot(600, self.refresh_models)
def msg_error(self, title: str, text: str) -> None:
QMessageBox.critical(self, title, text)
def msg_info(self, title: str, text: str) -> None:
QMessageBox.information(self, title, text)
def ui_busy(self, busy: bool) -> None:
for widget in [
self.btn_send,
self.btn_model_pull,
self.btn_rebuild_expert,
self.btn_refresh_models,
self.btn_runtime,
self.btn_copy_sql,
]:
widget.setEnabled(not busy)
self.prompt.setEnabled(not busy)
self.base_url.setEnabled(not busy)
self.ollama_bin.setEnabled(not busy)
self.model.setEnabled(not busy)
self.chk_stream.setEnabled(not busy)
self.btn_close.setEnabled(True)
def current_base_url(self) -> str:
return self.base_url.text().strip().rstrip("/")
def current_ollama_bin(self) -> str:
return self.ollama_bin.text().strip() or DEFAULT_OLLAMA_BIN
def ensure_runtime(self) -> None:
base = self.current_base_url()
ollama_bin = self.current_ollama_bin()
if maybe_start_runtime(ollama_bin, base):
self.status.showMessage("Lokale Ollama Runtime bereit.", 3000)
else:
self.status.showMessage("Ollama Runtime nicht erreichbar.", 6000)
def refresh_models(self) -> None:
base = self.current_base_url()
ollama_bin = self.current_ollama_bin()
if not base:
return
try:
self.ensure_runtime()
models = list_models_via_cli(ollama_bin, base)
current = self.model.currentText().strip()
self.model.clear()
self.model.addItems(models)
if current and current in models:
self.model.setCurrentText(current)
elif DEFAULT_MODEL in models:
self.model.setCurrentText(DEFAULT_MODEL)
else:
self.model.setCurrentIndex(0)
self.status.showMessage(f"{len(models)} Modelle geladen (CLI).", 2500)
except Exception as exc:
self.status.showMessage(f"Modelle konnten nicht geladen werden: {human_error(exc)}", 8000)
def on_send(self) -> None:
prompt = self.prompt.toPlainText().strip()
base = self.current_base_url()
model = self.model.currentText().strip()
if not prompt:
self.msg_info("Hinweis", "Bitte erst einen Prompt/Kontext eingeben.")
return
if not base or not model:
self.msg_info("Hinweis", "Bitte Ollama URL und Modell setzen.")
return
if not check_api_ready(base):
self.ensure_runtime()
if not check_api_ready(base):
self.msg_error("Ollama nicht erreichbar", "Die lokale Ollama API ist nicht erreichbar.")
return
self._raw_markdown = ""
self._last_metrics = {}
self._temp_modelfile: Optional[Path] = None
self.response_raw.setPlainText("")
self.response_view.setMarkdown("")
self.status.showMessage("Sende Anfrage …")
self.ui_busy(True)
self._gen_worker = GenerateWorker(
GenerateParams(
base_url=base,
model=model,
prompt=prompt,
stream=self.chk_stream.isChecked(),
keep_alive=DEFAULT_KEEP_ALIVE,
)
)
self._gen_worker.chunk.connect(self._on_chunk)
self._gen_worker.done.connect(self._on_done)
self._gen_worker.error.connect(self._on_gen_error)
self._gen_worker.start()
if self.chk_stream.isChecked():
self._render_timer.start()
def _on_chunk(self, chunk: str) -> None:
self._raw_markdown += chunk
self.response_raw.setPlainText(self._raw_markdown)
self.response_raw.verticalScrollBar().setValue(self.response_raw.verticalScrollBar().maximum())
def _render_markdown_throttled(self) -> None:
if self._raw_markdown:
self.response_view.setMarkdown(self._raw_markdown)
def _on_done(self, full: str, meta: dict) -> None:
self._render_timer.stop()
if not self.chk_stream.isChecked():
self._raw_markdown = full
self.response_raw.setPlainText(full)
self.response_view.setMarkdown(self._raw_markdown)
self._last_metrics = meta or {}
self.ui_busy(False)
eval_count = meta.get("eval_count")
eval_duration = meta.get("eval_duration")
load_duration = meta.get("load_duration")
if eval_count and eval_duration:
tps = eval_count / max(eval_duration / 1_000_000_000, 1e-9)
load_ms = (load_duration or 0) / 1_000_000
self.status.showMessage(f"Fertig. {eval_count} Tokens, {tps:.1f} tok/s, Load {load_ms:.0f} ms", 6000)
else:
self.status.showMessage("Fertig.", 2500)
def _on_gen_error(self, err: str) -> None:
self._render_timer.stop()
self.ui_busy(False)
self.status.showMessage("Fehler.", 5000)
self.msg_error("Ollama Fehler", err)
def copy_response(self) -> None:
QApplication.clipboard().setText(self._raw_markdown or self.response_raw.toPlainText())
self.status.showMessage("Antwort (Markdown) in die Zwischenablage kopiert.", 2500)
def copy_sql_only(self) -> None:
md = self._raw_markdown or self.response_raw.toPlainText()
blocks = extract_sql_blocks(md)
sql_text = build_sql_only_text(blocks)
if not sql_text:
self.msg_info("Kein SQL gefunden", "In der Antwort wurden keine SQL-Codeblöcke gefunden.")
return
QApplication.clipboard().setText(sql_text)
self.status.showMessage(f"SQL kopiert ({len(blocks)} Block/Blöcke).", 3000)
def run_command(self, args: List[str], label: str, done_message: str, on_done: Optional[Callable[[str], None]] = None) -> None:
base = self.current_base_url()
ollama_bin = self.current_ollama_bin()
if not maybe_start_runtime(ollama_bin, base):
self.msg_error("Ollama nicht erreichbar", "Die lokale Ollama Runtime konnte nicht gestartet werden.")
return
self.ui_busy(True)
self.status.showMessage(f"{label}")
self._cmd_worker = CommandWorker(
program=ollama_bin,
args=args,
env=cli_env(base),
cwd=str(DEFAULT_PROJECT_ROOT),
label=label,
)
self._cmd_worker.status.connect(lambda s: self.status.showMessage(f"{label}: {s[-140:]}"))
self._cmd_worker.done.connect(lambda out: self._on_cmd_done(done_message, out, on_done))
self._cmd_worker.error.connect(self._on_cmd_error)
self._cmd_worker.start()
def _on_cmd_done(self, done_message: str, output: str, on_done: Optional[Callable[[str], None]]) -> None:
self.ui_busy(False)
self.status.showMessage(done_message, 5000)
if on_done is not None:
on_done(output)
def _on_cmd_error(self, err: str) -> None:
self.ui_busy(False)
if self._temp_modelfile is not None:
try:
self._temp_modelfile.unlink(missing_ok=True)
finally:
self._temp_modelfile = None
self.msg_error("Ollama Kommando fehlgeschlagen", err)
def on_pull_model(self) -> None:
model = self.model.currentText().strip()
if not model:
self.msg_info("Hinweis", "Bitte ein Modell setzen.")
return
self.run_command(["pull", model], f"pull {model}", "Modell aktualisiert.", lambda _: self.refresh_models())
def on_rebuild_expert(self) -> None:
base_model = os.environ.get("BASE_MODEL", DEFAULT_BASE_MODEL).strip()
expert_model = os.environ.get("EXPERT_MODEL", DEFAULT_EXPERT_MODEL).strip()
if not base_model:
self.msg_error("BASE_MODEL fehlt", "Setze BASE_MODEL in deiner Umgebung oder .env, bevor du das Expert-Modell neu baust.")
return
try:
modelfile_text = render_modelfile(DEFAULT_MODEFILE, base_model)
except Exception as exc:
self.msg_error("Modelfile Fehler", human_error(exc))
return
tmp_path = DEFAULT_PROJECT_ROOT / ".tmp.gui.Modelfile"
tmp_path.write_text(modelfile_text, encoding="utf-8")
self._temp_modelfile = tmp_path
def finalize(_: str) -> None:
try:
tmp_path.unlink(missing_ok=True)
finally:
self._temp_modelfile = None
self.refresh_models()
self.run_command(
["create", expert_model, "-f", str(tmp_path)],
f"create {expert_model}",
f"Expert-Modell {expert_model} neu gebaut.",
finalize,
)
def on_close(self) -> None:
if self._gen_worker and self._gen_worker.isRunning():
reply = QMessageBox.question(
self,
"Beenden",
"Es läuft noch eine Generierung. Trotzdem beenden?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self.close()
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("JR SQL AI GUI")
window = MainWindow()
window.resize(1360, 820)
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())