diff --git a/sql_ai_gui_local_ollama.py b/sql_ai_gui_local_ollama.py index 5ecd0ac..9c2a810 100755 --- a/sql_ai_gui_local_ollama.py +++ b/sql_ai_gui_local_ollama.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -JR SQL AI GUI (local Ollama, resilient version v5) +JR SQL AI GUI (local Ollama, resilient version v9) Improvements over v4: - auto-prefill BASE_MODEL / EXPERT_MODEL / EXTRA_MODELS from .env, .env.example and Modelfile @@ -173,6 +173,16 @@ def detect_project_root(start: Path) -> Path: return start +def resolve_path_from_value(root: Path, raw_value: str, default: Path) -> Path: + value = (raw_value or '').strip() + if not value: + return default.resolve() + p = Path(value).expanduser() + if not p.is_absolute(): + p = (root / p) + return p.resolve() + + def parse_modelfile_for_base(modelfile_path: Path) -> Tuple[str, str]: """ Returns: (base_model, source_hint) @@ -215,7 +225,7 @@ def resolve_defaults(root: Path, explicit_modelfile: Optional[Path] = None, curr env_example_path = root / ".env.example" env_data = read_env_file(env_path) env_example_data = read_env_file(env_example_path) - modelfile_path = explicit_modelfile.resolve() if explicit_modelfile else (root / "Modelfile").resolve() + default_modelfile_path = explicit_modelfile.resolve() if explicit_modelfile else (root / "Modelfile").resolve() notes: List[str] = [] @@ -238,6 +248,29 @@ def resolve_defaults(root: Path, explicit_modelfile: Optional[Path] = None, curr expert_model = pick("EXPERT_MODEL", "jr-sql-expert") extra_models = pick("EXTRA_MODELS", "") + modelfile_raw = "" + modelfile_source = "" + if explicit_modelfile and str(explicit_modelfile).strip(): + modelfile_path = explicit_modelfile.resolve() + modelfile_source = "GUI" + elif env_data.get("MODELFILE_PATH", "").strip(): + modelfile_raw = env_data.get("MODELFILE_PATH", "").strip() + modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) + modelfile_source = ".env" + elif env_example_data.get("MODELFILE_PATH", "").strip(): + modelfile_raw = env_example_data.get("MODELFILE_PATH", "").strip() + modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) + modelfile_source = ".env.example" + elif os.environ.get("MODELFILE_PATH", "").strip(): + modelfile_raw = os.environ.get("MODELFILE_PATH", "").strip() + modelfile_path = resolve_path_from_value(root, modelfile_raw, default_modelfile_path) + modelfile_source = "Umgebung" + else: + modelfile_path = default_modelfile_path + + if modelfile_source: + notes.append(f"MODELFILE_PATH aus {modelfile_source}") + if not base_model: modelfile_base, modelfile_note = parse_modelfile_for_base(modelfile_path) if modelfile_base: @@ -248,6 +281,7 @@ def resolve_defaults(root: Path, explicit_modelfile: Optional[Path] = None, curr inference_model = ( current_inference_model.strip() + or pick("DEFAULT_INFERENCE_MODEL", "") or os.environ.get("OLLAMA_MODEL", "").strip() or (f"{expert_model}:latest" if expert_model else "jr-sql-expert:latest") ) @@ -413,6 +447,8 @@ class MainWindow(QMainWindow): self._cmd_worker: Optional[CommandWorker] = None self._temp_modelfile: Optional[Path] = None self._raw_markdown = "" + self._config_dirty = False + self._suspend_dirty_tracking = False self.project_root = detect_project_root(Path(__file__).resolve().parent) self.config = AppConfig.load(self.project_root) @@ -478,6 +514,11 @@ class MainWindow(QMainWindow): self.lbl_defaults_hint = QLabel("") self.lbl_defaults_hint.setWordWrap(True) config_layout.addWidget(self.lbl_defaults_hint, row, 0, 1, 5) + row += 1 + + self.lbl_config_state = QLabel("") + self.lbl_config_state.setWordWrap(True) + config_layout.addWidget(self.lbl_config_state, row, 0, 1, 5) top = QHBoxLayout() layout.addLayout(top) @@ -579,9 +620,54 @@ class MainWindow(QMainWindow): self.setStatusBar(self.status) self.status.showMessage("Bereit.") + self._connect_config_change_tracking() self.apply_default_hints() + self._set_config_dirty(False) QTimer.singleShot(300, self.refresh_models) + def _connect_config_change_tracking(self) -> None: + fields = [ + self.project_root_edit, + self.base_url, + self.ollama_bin, + self.base_model_edit, + self.expert_model_edit, + self.extra_models_edit, + self.modelfile_edit, + ] + for field in fields: + field.textEdited.connect(self._on_config_field_edited) + self.model.lineEdit().textEdited.connect(self._on_config_field_edited) + self.model.currentTextChanged.connect(self._on_inference_model_changed) + + def _set_config_dirty(self, dirty: bool, reason: str = "") -> None: + if self._suspend_dirty_tracking: + return + self._config_dirty = dirty + if dirty: + self.lbl_config_state.setText( + "Konfiguration geändert und noch nicht gespeichert. Klicke auf '.env speichern', um die Änderungen dauerhaft zu übernehmen." + ) + self.lbl_config_state.setStyleSheet("color: #f6c177; font-weight: 600;") + self.btn_save_env.setText(".env speichern *") + self.setWindowTitle("JR SQL AI GUI (local Ollama) *") + if reason: + self.status.showMessage(reason, 5000) + else: + self.lbl_config_state.setText("Konfiguration ist gespeichert bzw. entspricht dem geladenen Stand.") + self.lbl_config_state.setStyleSheet("color: #9ccfd8;") + self.btn_save_env.setText(".env speichern") + self.setWindowTitle("JR SQL AI GUI (local Ollama)") + + def _on_config_field_edited(self, _text: str) -> None: + self._set_config_dirty(True, "Ungespeicherte Konfigurationsänderungen vorhanden.") + + def _on_inference_model_changed(self, text: str) -> None: + if self._suspend_dirty_tracking: + return + if text.strip(): + self._set_config_dirty(True, "Antwortmodell geändert. Speichere die .env, wenn dies der neue Standard sein soll.") + def _apply_dark_menu_style(self) -> None: menu_bar = self.menuBar() menu_bar.setNativeMenuBar(False) @@ -666,20 +752,24 @@ class MainWindow(QMainWindow): cfg = self.current_config() defaults = resolve_defaults(cfg.project_root, cfg.modelfile_path, cfg.default_inference_model) - if not self.base_url.text().strip() and defaults.ollama_url: - self.base_url.setText(defaults.ollama_url) - if not self.ollama_bin.text().strip() and defaults.ollama_bin: - self.ollama_bin.setText(defaults.ollama_bin) - if not self.base_model_edit.text().strip() and defaults.base_model: - self.base_model_edit.setText(defaults.base_model) - if not self.expert_model_edit.text().strip() and defaults.expert_model: - self.expert_model_edit.setText(defaults.expert_model) - if not self.extra_models_edit.text().strip() and defaults.extra_models: - self.extra_models_edit.setText(defaults.extra_models) - if not self.modelfile_edit.text().strip() and defaults.modelfile_path: - self.modelfile_edit.setText(str(defaults.modelfile_path)) - if not self.model.currentText().strip() and defaults.inference_model: - self.model.setCurrentText(defaults.inference_model) + self._suspend_dirty_tracking = True + try: + if not self.base_url.text().strip() and defaults.ollama_url: + self.base_url.setText(defaults.ollama_url) + if not self.ollama_bin.text().strip() and defaults.ollama_bin: + self.ollama_bin.setText(defaults.ollama_bin) + if not self.base_model_edit.text().strip() and defaults.base_model: + self.base_model_edit.setText(defaults.base_model) + if not self.expert_model_edit.text().strip() and defaults.expert_model: + self.expert_model_edit.setText(defaults.expert_model) + if not self.extra_models_edit.text().strip() and defaults.extra_models: + self.extra_models_edit.setText(defaults.extra_models) + if not self.modelfile_edit.text().strip() and defaults.modelfile_path: + self.modelfile_edit.setText(str(defaults.modelfile_path)) + if not self.model.currentText().strip() and defaults.inference_model: + self.model.setCurrentText(defaults.inference_model) + finally: + self._suspend_dirty_tracking = False self.apply_default_hints() @@ -721,20 +811,25 @@ class MainWindow(QMainWindow): if path: self.modelfile_edit.setText(path) self.autofill_missing_fields_from_disk() + self._set_config_dirty(True, "Modelfile-Pfad geändert. Speichere die .env, um den neuen Pfad dauerhaft zu übernehmen.") def reload_config_from_disk(self) -> None: root = Path(self.project_root_edit.text().strip() or self.project_root).resolve() self.config = AppConfig.load(root) - self.project_root_edit.setText(str(self.config.project_root)) - self.base_url.setText(self.config.ollama_url) - self.ollama_bin.setText(self.config.ollama_bin) - self.base_model_edit.setText(self.config.base_model) - self.expert_model_edit.setText(self.config.expert_model) - self.extra_models_edit.setText(self.config.extra_models) - self.modelfile_edit.setText(str(self.config.modelfile_path)) - if not self.model.currentText().strip(): + self._suspend_dirty_tracking = True + try: + self.project_root_edit.setText(str(self.config.project_root)) + self.base_url.setText(self.config.ollama_url) + self.ollama_bin.setText(self.config.ollama_bin) + self.base_model_edit.setText(self.config.base_model) + self.expert_model_edit.setText(self.config.expert_model) + self.extra_models_edit.setText(self.config.extra_models) + self.modelfile_edit.setText(str(self.config.modelfile_path)) self.model.setCurrentText(self.config.default_inference_model) + finally: + self._suspend_dirty_tracking = False self.apply_default_hints() + self._set_config_dirty(False) self.status.showMessage("Konfiguration neu geladen.", 2500) self.refresh_models() @@ -749,9 +844,12 @@ class MainWindow(QMainWindow): "BASE_MODEL": cfg.base_model, "EXPERT_MODEL": cfg.expert_model, "EXTRA_MODELS": cfg.extra_models, + "MODELFILE_PATH": str(cfg.modelfile_path), + "DEFAULT_INFERENCE_MODEL": cfg.default_inference_model, }, ) self.apply_default_hints() + self._set_config_dirty(False) self.status.showMessage(f".env gespeichert: {cfg.env_path}", 3500) # ----------- UI helpers ----------- @@ -1077,6 +1175,19 @@ class MainWindow(QMainWindow): self.msg_info("Bitte warten", "Es läuft gerade eine Wartungsaktion. Schließe das Fenster danach erneut.") event.ignore() return + if self._config_dirty: + answer = QMessageBox.question( + self, + "Ungespeicherte Konfiguration", + "Es gibt ungespeicherte Konfigurationsänderungen. Möchtest du sie vor dem Beenden in die .env schreiben?", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if answer == QMessageBox.Cancel: + event.ignore() + return + if answer == QMessageBox.Yes: + self.save_env() event.accept()