diff --git a/README.md b/README.md index 0bb4dfa..93a4a43 100644 --- a/README.md +++ b/README.md @@ -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: - - **Prompt/Kontext** (links) - - **Antwort** (rechts) inkl. **Markdown Rendering** -- **Streaming** (Antwort läuft live ein) -- Buttons: - - **An AI senden** - - **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`) +- `scripts/bootstrap-native.sh` +- `scripts/update-native.sh` +- `scripts/selftest-native.sh` +- `scripts/cleanup-docker-ollama.sh` +- `scripts/common.sh` +- `sql_ai_gui_local_ollama.py` -## Voraussetzungen +## Wesentliche Änderungen -- Ein laufender Ollama Server (bei dir z.B. Docker Container auf `127.0.0.1:11434`) -- Python 3.11+ empfohlen +- kein `docker compose` mehr in Bootstrap/Update/Selftest +- 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) -```bash -sudo pacman -S pyside6 python-requests -``` +- `.env` +- `Modelfile` +- `bin/sqlai` +- `scripts/` -### Optional: venv (isoliert) -```bash -python -m venv .venv -source .venv/bin/activate -pip install PySide6 requests -``` - -## Start +## Nützliche .env Werte ```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 -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) diff --git a/scripts/bootstrap-native.sh b/scripts/bootstrap-native.sh new file mode 100755 index 0000000..609873c --- /dev/null +++ b/scripts/bootstrap-native.sh @@ -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}" diff --git a/scripts/cleanup-docker-ollama.sh b/scripts/cleanup-docker-ollama.sh new file mode 100755 index 0000000..f9eb2ec --- /dev/null +++ b/scripts/cleanup-docker-ollama.sh @@ -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}" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..9b750ed --- /dev/null +++ b/scripts/common.sh @@ -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/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 "================================================================================" +} diff --git a/scripts/selftest-native.sh b/scripts/selftest-native.sh new file mode 100755 index 0000000..ce96da1 --- /dev/null +++ b/scripts/selftest-native.sh @@ -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}" diff --git a/scripts/sql-ai-gui b/scripts/sql-ai-gui index 4ded80e..14fb7a6 100755 --- a/scripts/sql-ai-gui +++ b/scripts/sql-ai-gui @@ -8,7 +8,7 @@ export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland}" # Optional: Wenn du einen venv nutzt if [[ -x "$APP_DIR/.venv/bin/python" ]]; then - exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui.py" + exec "$APP_DIR/.venv/bin/python" "$APP_DIR/sql_ai_gui_local_ollama.py" else exec python "$APP_DIR/sql_ai_gui.py" fi diff --git a/scripts/update-native.sh b/scripts/update-native.sh new file mode 100755 index 0000000..2b0adc8 --- /dev/null +++ b/scripts/update-native.sh @@ -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}" diff --git a/sql_ai_gui.py b/sql_ai_gui.py deleted file mode 100644 index 872911c..0000000 --- a/sql_ai_gui.py +++ /dev/null @@ -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()) diff --git a/sql_ai_gui_local_ollama.py b/sql_ai_gui_local_ollama.py new file mode 100755 index 0000000..bb3c564 --- /dev/null +++ b/sql_ai_gui_local_ollama.py @@ -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())