diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index c0e27e0..1293155 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -111,6 +111,10 @@ Rootserver: wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück. - Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI- Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden. +- Sitzungen, Suche, Umbenennen, Markdownanzeige, Qt-Streamingworker, sicherer + Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet. +- `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`; + `never` erreicht weiterhin keinen Cloudfreigabedialog. ## Aktuelle Architektur @@ -191,8 +195,8 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 94 bestanden; zusätzlich Runtime-Adapter, Qt-freier Controller, - Secretmaskierung sowie neuer und bestehender CLI-Parserpfad abgedeckt +- Unit-Tests: 97 bestanden; zusätzlich GUI-Streamingereignisse, atomarer Abbruch + und alle drei Ergebnisse des Datenschutzdialogs abgedeckt - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -248,8 +252,6 @@ Abnahmestatus: - Chatkomfort-Merge nach `main`: `cbba102` - 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht -- konfliktfreier Merge nach `main`: `564a3fd` -- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert - CLI-Verdrahtung: `af9426e` - Startskript: `141ff2e` @@ -287,8 +289,8 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Auf `feat/desktop-ui` als Nächstes Freigabedialog mit drei Ergebnissen, -Qt-Worker-Abbruch, Sitzungsbedienung und bereinigte Statusanzeige gezielt testen. +Auf `feat/desktop-ui` als Nächstes Offscreen-GUI-Tests, sicheres Startskript, +Desktopanleitung und anschließend echten lokalen GUI-Smoke ergänzen. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Noch keine Obsidian-Integration oder Tools beginnen. diff --git a/src/javis/core/provider_router.py b/src/javis/core/provider_router.py index 8fddff3..c4c9846 100644 --- a/src/javis/core/provider_router.py +++ b/src/javis/core/provider_router.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator from dataclasses import dataclass +from enum import StrEnum from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError from javis.providers.base import ( @@ -18,7 +19,14 @@ from javis.providers.base import ( ) from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter -ApprovalCallback = Callable[[PrivacyDecision], bool] + +class ApprovalChoice(StrEnum): + ALLOW = "allow" + LOCAL = "local" + CANCEL = "cancel" + + +ApprovalCallback = Callable[[PrivacyDecision], ApprovalChoice | bool] NoticeCallback = Callable[[str], None] CloudProviderFactory = Callable[[], LocalModelProvider] @@ -89,6 +97,14 @@ class HybridProvider: raise ValueError("Provider-Modus muss auto, local oder gemini sein.") self.mode = mode + def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice: + result = self._approval_callback(decision) + if isinstance(result, bool): + return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL + if isinstance(result, ApprovalChoice): + return result + raise ValueError("Ungültiges Ergebnis der Cloudfreigabe.") + def chat(self, messages: list[ChatMessage]) -> str: current_text = messages[-1].content if messages else "" cloud_requested = self.mode == "gemini" @@ -110,8 +126,11 @@ class HybridProvider: approved = decision.policy is CloudPolicy.ALLOWED if decision.policy is CloudPolicy.ASK: - approved = self._approval_callback(decision) - if not approved: + choice = self._approval_choice(decision) + if choice is ApprovalChoice.CANCEL: + raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.") + approved = choice is ApprovalChoice.ALLOW + if choice is ApprovalChoice.LOCAL: return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True) unavailable_reason = self._cloud_unavailable_reason() @@ -194,8 +213,11 @@ class HybridProvider: approved = decision.policy is CloudPolicy.ALLOWED if decision.policy is CloudPolicy.ASK: - approved = self._approval_callback(decision) - if not approved: + choice = self._approval_choice(decision) + if choice is ApprovalChoice.CANCEL: + raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.") + approved = choice is ApprovalChoice.ALLOW + if choice is ApprovalChoice.LOCAL: yield from self._stream_local( messages, decision, diff --git a/src/javis/ui/chat_controller.py b/src/javis/ui/chat_controller.py index 8c7c90b..6565825 100644 --- a/src/javis/ui/chat_controller.py +++ b/src/javis/ui/chat_controller.py @@ -88,6 +88,10 @@ class DesktopController: yield StreamEvent(StreamEventKind.STATE, state=self.state) yield StreamEvent(StreamEventKind.CHUNK, text=chunk) except ProviderError as exc: + if cancel_event.is_set(): + self.state = GenerationState.ABORTED + yield StreamEvent(StreamEventKind.STATE, state=self.state) + return self.state = GenerationState.ERROR yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state) return @@ -101,8 +105,11 @@ class DesktopController: def status(self) -> DesktopStatus: runtime_status = self.runtime.status() route = self.runtime.hybrid_provider.last_route + state = self.state + if state is GenerationState.READY and not runtime_status.ollama_reachable: + state = GenerationState.OFFLINE return DesktopStatus( - state=self.state, + state=state, mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto", provider=route.provider if route else "—", ollama_reachable=runtime_status.ollama_reachable, diff --git a/src/javis/ui/main_window.py b/src/javis/ui/main_window.py index f60a9fc..e443a22 100644 --- a/src/javis/ui/main_window.py +++ b/src/javis/ui/main_window.py @@ -25,6 +25,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from javis.core.provider_router import ApprovalChoice from javis.providers.base import ChatMessage from javis.security.privacy import PrivacyDecision from javis.ui.chat_controller import DesktopController @@ -63,7 +64,7 @@ class MainWindow(QMainWindow): self._build_ui() self._connect_signals() self._apply_style() - self._new_session() + self._open_initial_session() self._animation = QTimer(self) self._animation.timeout.connect(self._animate_state) self._animation.start(450) @@ -191,6 +192,20 @@ class MainWindow(QMainWindow): self.message_edit.setFocus() self._refresh_status() + def _open_initial_session(self) -> None: + try: + sessions = self.controller.list_sessions() + if sessions: + loaded = self.controller.load_session(sessions[0].id) + self._messages = list(loaded.messages) + self._render_chat() + self._refresh_sessions() + self._refresh_status() + return + except Exception as exc: + self._show_error(str(exc)) + self._new_session() + def _refresh_sessions(self) -> None: try: sessions = self.controller.list_sessions(self.search_edit.text()) @@ -332,8 +347,17 @@ class MainWindow(QMainWindow): box.setInformativeText(f"Lokale Einstufung: {decision.reason}") allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole) local = box.addButton("Lokal beantworten", QMessageBox.RejectRole) + cancel = box.addButton("Abbrechen", QMessageBox.DestructiveRole) box.exec() - self.approval_bridge.resolve(box.clickedButton() is allow) + clicked = box.clickedButton() + choice = ApprovalChoice.CANCEL + if clicked is allow: + choice = ApprovalChoice.ALLOW + elif clicked is local: + choice = ApprovalChoice.LOCAL + elif clicked is cancel: + choice = ApprovalChoice.CANCEL + self.approval_bridge.resolve(choice) if box.clickedButton() is local: self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000) diff --git a/src/javis/ui/workers.py b/src/javis/ui/workers.py index c8633c0..0b5a335 100644 --- a/src/javis/ui/workers.py +++ b/src/javis/ui/workers.py @@ -6,6 +6,7 @@ import threading from PySide6.QtCore import QObject, Signal, Slot +from javis.core.provider_router import ApprovalChoice from javis.security.privacy import PrivacyDecision from javis.ui.chat_controller import DesktopController @@ -18,13 +19,13 @@ class ApprovalBridge(QObject): super().__init__() self._lock = threading.Lock() self._event: threading.Event | None = None - self._result = False + self._result = ApprovalChoice.CANCEL - def request(self, decision: PrivacyDecision) -> bool: + def request(self, decision: PrivacyDecision) -> ApprovalChoice: event = threading.Event() with self._lock: self._event = event - self._result = False + self._result = ApprovalChoice.CANCEL self.approval_requested.emit(decision) event.wait() with self._lock: @@ -32,15 +33,15 @@ class ApprovalBridge(QObject): self._event = None return result - def resolve(self, allowed: bool) -> None: + def resolve(self, choice: ApprovalChoice) -> None: with self._lock: - self._result = allowed + self._result = choice event = self._event if event is not None: event.set() def cancel_pending(self) -> None: - self.resolve(False) + self.resolve(ApprovalChoice.CANCEL) class StreamWorker(QObject): diff --git a/tests/unit/test_desktop_controller.py b/tests/unit/test_desktop_controller.py index 8111c8e..2f3490c 100644 --- a/tests/unit/test_desktop_controller.py +++ b/tests/unit/test_desktop_controller.py @@ -1,10 +1,16 @@ from __future__ import annotations +import tempfile +import threading import unittest +from pathlib import Path from types import SimpleNamespace +from javis.core.chat_service import ChatService +from javis.memory.sqlite_store import SQLiteSessionStore from javis.providers.base import ChatMessage from javis.ui.chat_controller import DesktopController, sanitized_error +from javis.ui.models import GenerationState, StreamEventKind class _Service: @@ -44,6 +50,20 @@ class _Hybrid: self.mode = mode +class _StreamingProvider: + name = "ollama" + model = "test" + last_route = None + mode = "local" + + def stream_chat(self, _messages): + yield "Teil " + yield "Antwort" + + def set_mode(self, mode: str) -> None: + self.mode = mode + + class DesktopControllerTests(unittest.TestCase): def setUp(self) -> None: self.runtime = SimpleNamespace( @@ -71,3 +91,43 @@ class DesktopControllerTests(unittest.TestCase): self.assertNotIn(secret, result) self.assertIn("MASKIERT", result) + + def test_streaming_events_are_forwarded_and_completed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + provider = _StreamingProvider() + service = ChatService( + SQLiteSessionStore(Path(directory) / "sessions.sqlite3"), + provider, + ) + controller = DesktopController( + SimpleNamespace(service=service, hybrid_provider=provider) + ) + + events = list(controller.stream_message("Hallo", threading.Event())) + + self.assertEqual( + [event.text for event in events if event.kind is StreamEventKind.CHUNK], + ["Teil ", "Antwort"], + ) + self.assertEqual(events[-1].kind, StreamEventKind.FINISHED) + session = service.list_sessions()[0] + self.assertEqual(service.load_session(session.id).session.message_count, 2) + + def test_cancel_drops_partial_exchange(self) -> None: + with tempfile.TemporaryDirectory() as directory: + provider = _StreamingProvider() + service = ChatService( + SQLiteSessionStore(Path(directory) / "sessions.sqlite3"), + provider, + ) + controller = DesktopController( + SimpleNamespace(service=service, hybrid_provider=provider) + ) + cancel = threading.Event() + cancel.set() + + events = list(controller.stream_message("Hallo", cancel)) + + self.assertEqual(events[-1].state, GenerationState.ABORTED) + session = service.list_sessions()[0] + self.assertEqual(service.load_session(session.id).messages, []) diff --git a/tests/unit/test_provider_router.py b/tests/unit/test_provider_router.py index 4b129eb..3f912c9 100644 --- a/tests/unit/test_provider_router.py +++ b/tests/unit/test_provider_router.py @@ -4,7 +4,7 @@ import tempfile import unittest from pathlib import Path -from javis.core.provider_router import HybridProvider +from javis.core.provider_router import ApprovalChoice, HybridProvider from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore from javis.providers.base import ( ChatMessage, @@ -182,6 +182,19 @@ class HybridProviderTests(unittest.TestCase): self.assertEqual(answer, "Cloud") self.assertEqual(len(self.cloud.calls), 1) + def test_ask_supports_allow_local_and_cancel_choices(self) -> None: + question = [ChatMessage("user", "Meine Familie plant Urlaub")] + + allowed = self._router(approval_callback=lambda _decision: ApprovalChoice.ALLOW) + self.assertEqual(allowed.chat(question), "Cloud") + + local = self._router(approval_callback=lambda _decision: ApprovalChoice.LOCAL) + self.assertEqual(local.chat(question), "Lokal") + + cancelled = self._router(approval_callback=lambda _decision: ApprovalChoice.CANCEL) + with self.assertRaises(ResponseAbortedError): + cancelled.chat(question) + def test_429_falls_back_without_losing_local_answer(self) -> None: self.cloud.error = ProviderRateLimitError("quota") router = self._router()