Consolidated local ollama model logic
This commit is contained in:
@@ -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)
|
||||
|
||||
Executable
+41
@@ -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}"
|
||||
Executable
+51
@@ -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}"
|
||||
Executable
+287
@@ -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 "================================================================================"
|
||||
}
|
||||
Executable
+54
@@ -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
@@ -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
|
||||
|
||||
Executable
+37
@@ -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
@@ -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())
|
||||
Executable
+664
@@ -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())
|
||||
Reference in New Issue
Block a user