diff --git a/javis-gui-crash.log b/javis-gui-crash.log new file mode 100644 index 0000000..73cbd6c --- /dev/null +++ b/javis-gui-crash.log @@ -0,0 +1,13 @@ +Fatal Python error: Aborted + +Current thread 0x000066dc (most recent call first): + File "D:\Javis-Projekt\src\javis\ui\main_window.py", line 301 in _generation_complete + File "D:\Javis-Projekt\src\javis\ui\app.py", line 44 in run_gui + File "D:\Javis-Projekt\src\javis\interface\cli.py", line 429 in main + File "D:\Javis-Projekt\.venv\Scripts\javis.exe\__main__.py", line 10 in + File "", line 88 in _run_code + File "", line 198 in _run_module_as_main + +Extension modules: charset_normalizer.md, charset_normalizer.cd, requests.packages.charset_normalizer.md, requests.packages.chardet.md, requests.packages.charset_normalizer.cd, requests.packages.chardet.cd, _cffi_backend, websockets.speedups, shiboken6.Shiboken, PySide6.QtCore, PySide6.QtGui, PySide6.QtWidgets (total: 12) +QThreadStorage: entry 1 destroyed before end of thread 0x28a793bea70 +QThreadStorage: entry 0 destroyed before end of thread 0x28a793bea70 diff --git a/src/javis/core/provider_router.py b/src/javis/core/provider_router.py index c4c9846..c8204c0 100644 --- a/src/javis/core/provider_router.py +++ b/src/javis/core/provider_router.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from collections.abc import Callable, Iterator from dataclasses import dataclass from enum import StrEnum @@ -84,6 +85,8 @@ class HybridProvider: self._max_cloud_input_chars = max_cloud_input_chars self._max_cloud_context_messages = max_cloud_context_messages self._cloud_session_disabled = False + self._approval_lock = threading.Lock() + self._next_approval: ApprovalChoice | None = None self.last_route: RouteStatus | None = None def supports_session(self, provider: str, model: str) -> bool: @@ -97,7 +100,26 @@ class HybridProvider: raise ValueError("Provider-Modus muss auto, local oder gemini sein.") self.mode = mode + def privacy_decision(self, text: str) -> PrivacyDecision: + """Classify a prospective request without contacting a provider.""" + return self._privacy_router.classify( + text, + cloud_requested=self.mode == "gemini", + ) + + def queue_approval(self, choice: ApprovalChoice) -> None: + """Provide the GUI decision for exactly the next ASK request.""" + if choice not in {ApprovalChoice.ALLOW, ApprovalChoice.LOCAL}: + raise ValueError("Nur Freigabe oder lokale Antwort kann vorgemerkt werden.") + with self._approval_lock: + self._next_approval = choice + def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice: + with self._approval_lock: + queued = self._next_approval + self._next_approval = None + if queued is not None: + return queued result = self._approval_callback(decision) if isinstance(result, bool): return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL diff --git a/src/javis/ui/app.py b/src/javis/ui/app.py index 6d77815..db494ed 100644 --- a/src/javis/ui/app.py +++ b/src/javis/ui/app.py @@ -10,6 +10,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication, QMessageBox from javis.config.settings import ConfigurationError, Settings +from javis.core.provider_router import ApprovalChoice from javis.core.runtime import build_javis_runtime from javis.memory.sqlite_store import SessionStoreError from javis.memory.usage_store import UsageStoreError @@ -26,7 +27,7 @@ def run_gui(argv: Sequence[str] | None = None) -> int: settings = Settings.from_env() runtime = build_javis_runtime( settings, - approval_callback=approval_bridge.request, + approval_callback=lambda _decision: ApprovalChoice.CANCEL, notice_callback=approval_bridge.notice_received.emit, ) except (ConfigurationError, SessionStoreError, UsageStoreError) as exc: diff --git a/src/javis/ui/chat_controller.py b/src/javis/ui/chat_controller.py index 459b187..c5bbb5a 100644 --- a/src/javis/ui/chat_controller.py +++ b/src/javis/ui/chat_controller.py @@ -6,9 +6,11 @@ import re import threading from collections.abc import Iterator +from javis.core.provider_router import ApprovalChoice from javis.core.runtime import JavisRuntime from javis.memory.sqlite_store import ChatSession from javis.providers.base import ProviderError, ResponseAbortedError +from javis.security.privacy import CloudPolicy, PrivacyDecision from javis.ui.models import ( DesktopStatus, GenerationState, @@ -65,6 +67,16 @@ class DesktopController: def set_mode(self, mode: str) -> None: self.runtime.hybrid_provider.set_mode(mode) + def approval_decision(self, text: str) -> PrivacyDecision | None: + provider = self.runtime.hybrid_provider + decision = provider.privacy_decision(text) + if provider.mode == "local" or decision.policy is not CloudPolicy.ASK: + return None + return decision + + def queue_approval(self, choice: ApprovalChoice) -> None: + self.runtime.hybrid_provider.queue_approval(choice) + def stream_message( self, text: str, diff --git a/src/javis/ui/main_window.py b/src/javis/ui/main_window.py index dedea24..d86cbf0 100644 --- a/src/javis/ui/main_window.py +++ b/src/javis/ui/main_window.py @@ -57,6 +57,8 @@ class MainWindow(QMainWindow): self.approval_bridge = approval_bridge self._thread: QThread | None = None self._worker: StreamWorker | None = None + self._generation_finalized = True + self._close_when_finished = False self._messages: list[ChatMessage] = [] self._draft_user = "" self._draft_assistant = "" @@ -158,7 +160,6 @@ class MainWindow(QMainWindow): self.stop_button.clicked.connect(self._stop_generation) self.message_edit.send_requested.connect(self._send) self.mode_combo.currentIndexChanged.connect(self._mode_changed) - self.approval_bridge.approval_requested.connect(self._show_approval) self.approval_bridge.notice_received.connect(self._show_notice) def _apply_style(self) -> None: @@ -266,25 +267,40 @@ class MainWindow(QMainWindow): text = self.message_edit.toPlainText().strip() if not text or self._worker is not None: return + try: + decision = self.controller.approval_decision(text) + if decision is not None: + choice = self._show_approval(decision) + if choice is ApprovalChoice.CANCEL: + self.statusBar().showMessage("Anfrage abgebrochen.", 3000) + return + self.controller.queue_approval(choice) + except Exception as exc: + self._show_error(str(exc)) + return self.message_edit.clear() self._draft_user = text self._draft_assistant = "" self._render_chat() self._set_generating(True) + self._generation_finalized = False thread = QThread(self) worker = StreamWorker(self.controller, text) worker.moveToThread(thread) thread.started.connect(worker.run) worker.event_received.connect(self._handle_stream_event) worker.completed.connect(thread.quit) + worker.completed.connect(worker.deleteLater) worker.completed.connect(self._generation_complete) - thread.finished.connect(worker.deleteLater) + thread.finished.connect(self._thread_finished) thread.finished.connect(thread.deleteLater) self._thread = thread self._worker = worker thread.start() def _handle_stream_event(self, event: StreamEvent) -> None: + if self._generation_finalized: + return if event.kind is StreamEventKind.CHUNK: self._draft_assistant += event.text self._render_chat() @@ -294,12 +310,13 @@ class MainWindow(QMainWindow): self._show_error(event.text) def _generation_complete(self) -> None: + if self._generation_finalized: + return + self._generation_finalized = True failed = self.controller.state in { GenerationState.ABORTED, GenerationState.ERROR, } - self._worker = None - self._thread = None self._set_generating(False) try: loaded = self.controller.load_session(self.controller.active_session_id or "") @@ -315,11 +332,17 @@ class MainWindow(QMainWindow): else: self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000) + def _thread_finished(self) -> None: + self._worker = None + self._thread = None + if self._close_when_finished: + self._close_when_finished = False + QTimer.singleShot(0, self.close) + def _stop_generation(self) -> None: if self._worker is None: return self._worker.request_cancel() - self.approval_bridge.cancel_pending() self.statusBar().showMessage("Abbruch angefordert …") def _set_generating(self, active: bool) -> None: @@ -336,7 +359,7 @@ class MainWindow(QMainWindow): except Exception as exc: self._show_error(str(exc)) - def _show_approval(self, decision: PrivacyDecision) -> None: + def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice: box = QMessageBox(self) box.setWindowTitle("Cloudfreigabe") box.setIcon(QMessageBox.Question) @@ -357,9 +380,9 @@ class MainWindow(QMainWindow): 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) + return choice def _show_notice(self, text: str) -> None: self.statusBar().showMessage(text, 5000) @@ -438,8 +461,9 @@ class MainWindow(QMainWindow): self.search_edit.selectAll() def closeEvent(self, event: QCloseEvent) -> None: - self._stop_generation() - if self._thread is not None and not self._thread.wait(2500): + if self._thread is not None: + self._close_when_finished = True + self._stop_generation() event.ignore() self.statusBar().showMessage( "Javis beendet die laufende Antwort noch kontrolliert.", diff --git a/src/javis/ui/workers.py b/src/javis/ui/workers.py index 0b5a335..bc424d5 100644 --- a/src/javis/ui/workers.py +++ b/src/javis/ui/workers.py @@ -53,9 +53,14 @@ class StreamWorker(QObject): self._controller = controller self._text = text self._cancel_event = threading.Event() + self._started = False + self._completed = False @Slot() def run(self) -> None: + if self._started: + return + self._started = True try: for event in self._controller.stream_message( self._text, @@ -63,7 +68,9 @@ class StreamWorker(QObject): ): self.event_received.emit(event) finally: - self.completed.emit() + if not self._completed: + self._completed = True + self.completed.emit() def request_cancel(self) -> None: self._cancel_event.set() diff --git a/tests/unit/test_desktop_controller.py b/tests/unit/test_desktop_controller.py index 53cbe2c..bbd7b79 100644 --- a/tests/unit/test_desktop_controller.py +++ b/tests/unit/test_desktop_controller.py @@ -7,8 +7,10 @@ from pathlib import Path from types import SimpleNamespace from javis.core.chat_service import ChatService +from javis.core.provider_router import ApprovalChoice from javis.memory.sqlite_store import SQLiteSessionStore from javis.providers.base import ChatMessage, ResponseAbortedError +from javis.security.privacy import CloudPolicy, PrivacyDecision from javis.ui.chat_controller import DesktopController, sanitized_error from javis.ui.models import GenerationState, StreamEventKind @@ -45,10 +47,17 @@ class _Service: class _Hybrid: mode = "auto" last_route = None + queued_approval = None def set_mode(self, mode: str) -> None: self.mode = mode + def privacy_decision(self, _text: str) -> PrivacyDecision: + return PrivacyDecision(CloudPolicy.ASK, "Test") + + def queue_approval(self, choice: ApprovalChoice) -> None: + self.queued_approval = choice + class _StreamingProvider: name = "ollama" @@ -91,6 +100,17 @@ class DesktopControllerTests(unittest.TestCase): self.assertEqual(loaded.messages[0].content, "Hallo") self.assertEqual(renamed.title, "Neu") + def test_controller_preflights_and_queues_gui_approval(self) -> None: + decision = self.controller.approval_decision("Private Frage") + self.controller.queue_approval(ApprovalChoice.ALLOW) + + self.assertIsNotNone(decision) + self.assertIs(decision.policy, CloudPolicy.ASK) + self.assertIs( + self.runtime.hybrid_provider.queued_approval, + ApprovalChoice.ALLOW, + ) + def test_error_sanitizer_masks_key_patterns(self) -> None: secret = "AIza" + "x" * 25 result = sanitized_error(RuntimeError(f"Fehler {secret}")) diff --git a/tests/unit/test_desktop_gui.py b/tests/unit/test_desktop_gui.py index aed8b0c..b615750 100644 --- a/tests/unit/test_desktop_gui.py +++ b/tests/unit/test_desktop_gui.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import threading +import time import unittest from types import SimpleNamespace from unittest.mock import Mock, patch @@ -13,8 +15,8 @@ from javis.core.provider_router import ApprovalChoice from javis.providers.base import ChatMessage from javis.security.privacy import CloudPolicy, PrivacyDecision from javis.ui.main_window import MainWindow -from javis.ui.models import DesktopStatus, GenerationState -from javis.ui.workers import ApprovalBridge +from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind +from javis.ui.workers import ApprovalBridge, StreamWorker class _Controller: @@ -28,6 +30,15 @@ class _Controller: message_count=2, updated_at="2026-07-30T20:00:00+00:00", ) + self.messages = [ + ChatMessage("user", "Hallo"), + ChatMessage("assistant", "Guten Tag"), + ] + self.approval: PrivacyDecision | None = None + self.queued_approval: ApprovalChoice | None = None + self.started = threading.Event() + self.release = threading.Event() + self.release.set() def list_sessions(self, search: str = ""): return [self.session] if not search or search in self.session.title else [] @@ -36,10 +47,7 @@ class _Controller: self.active_session_id = session_id return SimpleNamespace( session=self.session, - messages=[ - ChatMessage("user", "Hallo"), - ChatMessage("assistant", "Guten Tag"), - ], + messages=list(self.messages), ) def new_session(self): @@ -53,6 +61,38 @@ class _Controller: def set_mode(self, _mode: str) -> None: return + def approval_decision(self, _text: str) -> PrivacyDecision | None: + return self.approval + + def queue_approval(self, choice: ApprovalChoice) -> None: + self.queued_approval = choice + + def stream_message(self, text: str, cancel_event: threading.Event): + self.started.set() + self.state = GenerationState.THINKING + yield StreamEvent(StreamEventKind.STATE, state=self.state) + while not self.release.wait(0.005): + if cancel_event.is_set(): + self.state = GenerationState.ABORTED + yield StreamEvent(StreamEventKind.STATE, state=self.state) + return + if cancel_event.is_set(): + self.state = GenerationState.ABORTED + yield StreamEvent(StreamEventKind.STATE, state=self.state) + return + self.state = GenerationState.ANSWERING + yield StreamEvent(StreamEventKind.STATE, state=self.state) + yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort") + self.messages.extend( + [ + ChatMessage("user", text), + ChatMessage("assistant", "Testantwort"), + ] + ) + self.session.message_count = len(self.messages) + self.state = GenerationState.READY + yield StreamEvent(StreamEventKind.FINISHED, state=self.state) + def status(self) -> DesktopStatus: return DesktopStatus( state=self.state, @@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase): def setUp(self) -> None: self.bridge = ApprovalBridge() - self.window = MainWindow(_Controller(), self.bridge) + self.controller = _Controller() + self.window = MainWindow(self.controller, self.bridge) self.window.show() self.application.processEvents() def tearDown(self) -> None: + self.controller.release.set() + self.window._stop_generation() + self._process_until(lambda: self.window._thread is None) self.window.close() self.application.processEvents() + def _process_until(self, condition, timeout: float = 2.0) -> None: + deadline = time.monotonic() + timeout + while not condition() and time.monotonic() < deadline: + self.application.processEvents() + time.sleep(0.005) + self.application.processEvents() + self.assertTrue(condition()) + def test_offscreen_window_loads_session_and_core_widgets(self) -> None: self.assertEqual(self.window.windowTitle(), "Javis Desktop") self.assertEqual(self.window.session_list.count(), 1) @@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase): for label, choice in expected.items(): with self.subTest(label=label): _MessageBox.selected_label = label - self.window._show_approval(decision) - self.bridge.resolve.assert_called_with(choice) + self.assertIs(self.window._show_approval(decision), choice) + + def test_local_completion_and_second_question_keep_gui_reusable(self) -> None: + for text in ("Erste Frage", "Zweite Frage"): + self.window.message_edit.setPlainText(text) + self.window._send() + self._process_until(lambda: self.window._thread is None) + + self.assertEqual(self.controller.session.message_count, 6) + self.assertIn("Zweite Frage", self.window.chat_view.toPlainText()) + self.assertTrue(self.window.send_button.isEnabled()) + + def test_approval_is_decided_before_worker_starts(self) -> None: + self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat") + self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW) + + self.window.message_edit.setPlainText("Private Testfrage") + self.window._send() + + self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW) + self.window._show_approval.assert_called_once() + self._process_until(lambda: self.window._thread is None) + + def test_local_approval_choice_is_queued_before_worker_starts(self) -> None: + self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat") + self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL) + + self.window.message_edit.setPlainText("Lokal beantworten") + self.window._send() + + self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL) + self._process_until(lambda: self.window._thread is None) + + def test_stop_during_streaming_discards_partial_exchange(self) -> None: + self.controller.release.clear() + before = list(self.controller.messages) + self.window.message_edit.setPlainText("Nicht speichern") + self.window._send() + self._process_until(self.controller.started.is_set) + + self.window._stop_generation() + self._process_until(lambda: self.window._thread is None) + + self.assertEqual(self.controller.messages, before) + self.assertEqual(self.controller.state, GenerationState.ABORTED) + + def test_stop_immediately_after_start_is_safe(self) -> None: + self.controller.release.clear() + self.window.message_edit.setPlainText("Sofort stoppen") + self.window._send() + self.window._stop_generation() + + self._process_until(lambda: self.window._thread is None) + self.assertEqual(self.controller.session.message_count, 2) + + def test_completion_is_idempotent_and_retains_qt_references(self) -> None: + worker = object() + thread = object() + self.window._worker = worker + self.window._thread = thread + self.window._generation_finalized = False + + self.window._generation_complete() + self.window._generation_complete() + + self.assertIs(self.window._worker, worker) + self.assertIs(self.window._thread, thread) + self.window._thread_finished() + self.assertIsNone(self.window._worker) + self.assertIsNone(self.window._thread) + + def test_worker_emits_completion_only_once(self) -> None: + worker = StreamWorker(self.controller, "Nur einmal") + completions: list[bool] = [] + worker.completed.connect(lambda: completions.append(True)) + + worker.run() + worker.run() + + self.assertEqual(completions, [True]) + + def test_close_during_generation_cancels_then_closes(self) -> None: + self.controller.release.clear() + self.window.message_edit.setPlainText("Beim Schließen stoppen") + self.window._send() + self._process_until(self.controller.started.is_set) + + self.window.close() + self.assertTrue(self.window._close_when_finished) + self._process_until(lambda: self.window._thread is None) + self._process_until(lambda: not self.window.isVisible()) diff --git a/tests/unit/test_provider_router.py b/tests/unit/test_provider_router.py index 3f912c9..48095ae 100644 --- a/tests/unit/test_provider_router.py +++ b/tests/unit/test_provider_router.py @@ -182,6 +182,18 @@ class HybridProviderTests(unittest.TestCase): self.assertEqual(answer, "Cloud") self.assertEqual(len(self.cloud.calls), 1) + def test_queued_gui_approval_is_one_shot_and_uses_fake_cloud(self) -> None: + router = self._router() + question = [ChatMessage("user", "Meine Familie plant Urlaub")] + router.queue_approval(ApprovalChoice.ALLOW) + + chunks = list(router.stream_chat(question)) + second_answer = router.chat(question) + + self.assertEqual(chunks, ["Cloud"]) + self.assertEqual(second_answer, "Lokal") + self.assertEqual(len(self.approvals), 1) + def test_ask_supports_allow_local_and_cancel_choices(self) -> None: question = [ChatMessage("user", "Meine Familie plant Urlaub")]