Consolidated local ollama model logic
This commit is contained in:
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