Initial version of gui for our T-SQL AI framework.
This commit is contained in:
534
sql_ai_gui.py
Executable file
534
sql_ai_gui.py
Executable file
@@ -0,0 +1,534 @@
|
||||
#!/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, Ollama runtime update (Docker)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
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")
|
||||
DEFAULT_DOCKER_CONTAINER_NAME = os.environ.get("OLLAMA_DOCKER_CONTAINER", "ollama")
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
def is_docker_available() -> bool:
|
||||
return shutil.which("docker") is not None
|
||||
|
||||
|
||||
def run_cmd(cmd: list[str], timeout: int = 600) -> tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
class UpdateOllamaDockerWorker(QThread):
|
||||
status = Signal(str)
|
||||
done = Signal()
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, container_name: str):
|
||||
super().__init__()
|
||||
self.container_name = container_name
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
if not is_docker_available():
|
||||
raise RuntimeError("docker not found in PATH")
|
||||
|
||||
self.status.emit("docker pull ollama/ollama:latest …")
|
||||
code, out, err = run_cmd(["docker", "pull", "ollama/ollama:latest"], timeout=1800)
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or out.strip() or f"docker pull failed (code {code})")
|
||||
|
||||
self.status.emit(f"Restarting container '{self.container_name}' …")
|
||||
code, out, err = run_cmd(["docker", "restart", self.container_name], timeout=120)
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or out.strip() or f"docker restart failed (code {code})")
|
||||
|
||||
self.status.emit("Done.")
|
||||
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._update_worker: Optional[UpdateOllamaDockerWorker] = 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_model_pull = QPushButton("Modell aktualisieren (pull)")
|
||||
self.btn_model_pull.clicked.connect(self.on_pull_model)
|
||||
right_btn_row.addWidget(self.btn_model_pull)
|
||||
|
||||
self.btn_runtime_update = QPushButton("Ollama Runtime updaten")
|
||||
self.btn_runtime_update.clicked.connect(self.on_update_runtime)
|
||||
self.btn_runtime_update.setEnabled(is_docker_available())
|
||||
right_btn_row.addWidget(self.btn_runtime_update)
|
||||
|
||||
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_runtime_update, self.btn_copy_sql]:
|
||||
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)
|
||||
|
||||
# -------------- Update runtime (Docker) --------------
|
||||
def on_update_runtime(self) -> None:
|
||||
if not is_docker_available():
|
||||
self.msg_info("Nicht verfügbar", "docker ist nicht im PATH gefunden.")
|
||||
return
|
||||
|
||||
msg = (
|
||||
"Das führt aus:\n"
|
||||
" docker pull ollama/ollama:latest\n"
|
||||
" docker restart ollama\n\n"
|
||||
"Hinweis: Du brauchst Docker-Rechte (docker group).\n"
|
||||
"Fortfahren?"
|
||||
)
|
||||
if QMessageBox.question(self, "Ollama updaten?", msg) != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self.ui_busy(True)
|
||||
self.status.showMessage("Ollama Runtime Update …")
|
||||
|
||||
self._update_worker = UpdateOllamaDockerWorker(DEFAULT_DOCKER_CONTAINER_NAME)
|
||||
self._update_worker.status.connect(lambda s: self.status.showMessage(s))
|
||||
self._update_worker.done.connect(self._on_update_done)
|
||||
self._update_worker.error.connect(self._on_update_err)
|
||||
self._update_worker.start()
|
||||
|
||||
def _on_update_done(self) -> None:
|
||||
self.ui_busy(False)
|
||||
self.status.showMessage("Ollama Runtime Update fertig.", 5000)
|
||||
QTimer.singleShot(700, self.refresh_models)
|
||||
|
||||
def _on_update_err(self, err: str) -> None:
|
||||
self.ui_busy(False)
|
||||
self.msg_error("Ollama Runtime Update fehlgeschlagen", err)
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user