Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f4fc23e1d | |||
| cba80dfeb1 |
@@ -251,8 +251,8 @@ Abnahmestatus:
|
|||||||
- stabiler Ausgangsstand: `main` bei `d3e85f2`
|
- stabiler Ausgangsstand: `main` bei `d3e85f2`
|
||||||
- GUI-Grundgerüst und Kernadapter: `1658e04`
|
- GUI-Grundgerüst und Kernadapter: `1658e04`
|
||||||
- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
|
- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
|
||||||
- Sitzungsnavigation: `43c1f76`
|
- Tests, Startskript und Desktopdokumentation: `571969b`
|
||||||
- Dokumentation: `328dcd9`
|
- `feat/desktop-ui` bis `571969b` gepusht und mit Remote synchron verifiziert
|
||||||
- Chatkomfort-Merge nach `main`: `cbba102`
|
- Chatkomfort-Merge nach `main`: `cbba102`
|
||||||
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
||||||
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
||||||
@@ -288,9 +288,8 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Auf `feat/desktop-ui` als Nächstes vollständige Abschlussprüfungen ausführen,
|
Desktop-MVP nach `docs/DESKTOP_UI.md` manuell durch Pascal abnehmen. Danach
|
||||||
Dokumentation committen und Feature-Branch pushen. Danach für Pascals manuelle
|
stoppen; vor dieser Abnahme nicht nach `main` mergen und keine Folgephase beginnen.
|
||||||
Desktop-Abnahme stoppen; noch nicht nach `main` mergen.
|
|
||||||
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
||||||
Noch keine Obsidian-Integration oder Tools beginnen.
|
Noch keine Obsidian-Integration oder Tools beginnen.
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ Living-Mind-Frontend.
|
|||||||
- Desktop-Basis: `main` bei `d3e85f2`
|
- Desktop-Basis: `main` bei `d3e85f2`
|
||||||
- GUI-Grundgerüst: `1658e04`
|
- GUI-Grundgerüst: `1658e04`
|
||||||
- Sitzungen/Streaming/Datenschutz: `cf35148`
|
- Sitzungen/Streaming/Datenschutz: `cf35148`
|
||||||
|
- Tests/Startskript/Dokumentation: `571969b`
|
||||||
|
- `feat/desktop-ui` zu Gitea gepusht und synchron verifiziert
|
||||||
- Basis: `main` bei `77b510b`
|
- Basis: `main` bei `77b510b`
|
||||||
- medizinisches Routing: `9148193`
|
- medizinisches Routing: `9148193`
|
||||||
- Streaming: `00a5a6c`
|
- Streaming: `00a5a6c`
|
||||||
|
|||||||
@@ -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 <module>
|
||||||
|
File "<frozen runpy>", line 88 in _run_code
|
||||||
|
File "<frozen runpy>", 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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -84,6 +85,8 @@ class HybridProvider:
|
|||||||
self._max_cloud_input_chars = max_cloud_input_chars
|
self._max_cloud_input_chars = max_cloud_input_chars
|
||||||
self._max_cloud_context_messages = max_cloud_context_messages
|
self._max_cloud_context_messages = max_cloud_context_messages
|
||||||
self._cloud_session_disabled = False
|
self._cloud_session_disabled = False
|
||||||
|
self._approval_lock = threading.Lock()
|
||||||
|
self._next_approval: ApprovalChoice | None = None
|
||||||
self.last_route: RouteStatus | None = None
|
self.last_route: RouteStatus | None = None
|
||||||
|
|
||||||
def supports_session(self, provider: str, model: str) -> bool:
|
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.")
|
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
|
||||||
self.mode = mode
|
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:
|
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)
|
result = self._approval_callback(decision)
|
||||||
if isinstance(result, bool):
|
if isinstance(result, bool):
|
||||||
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
|
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@ from PySide6.QtCore import QTimer
|
|||||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
from javis.config.settings import ConfigurationError, Settings
|
from javis.config.settings import ConfigurationError, Settings
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
from javis.core.runtime import build_javis_runtime
|
from javis.core.runtime import build_javis_runtime
|
||||||
from javis.memory.sqlite_store import SessionStoreError
|
from javis.memory.sqlite_store import SessionStoreError
|
||||||
from javis.memory.usage_store import UsageStoreError
|
from javis.memory.usage_store import UsageStoreError
|
||||||
@@ -26,7 +27,7 @@ def run_gui(argv: Sequence[str] | None = None) -> int:
|
|||||||
settings = Settings.from_env()
|
settings = Settings.from_env()
|
||||||
runtime = build_javis_runtime(
|
runtime = build_javis_runtime(
|
||||||
settings,
|
settings,
|
||||||
approval_callback=approval_bridge.request,
|
approval_callback=lambda _decision: ApprovalChoice.CANCEL,
|
||||||
notice_callback=approval_bridge.notice_received.emit,
|
notice_callback=approval_bridge.notice_received.emit,
|
||||||
)
|
)
|
||||||
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
|
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
from javis.core.runtime import JavisRuntime
|
from javis.core.runtime import JavisRuntime
|
||||||
from javis.memory.sqlite_store import ChatSession
|
from javis.memory.sqlite_store import ChatSession
|
||||||
from javis.providers.base import ProviderError, ResponseAbortedError
|
from javis.providers.base import ProviderError, ResponseAbortedError
|
||||||
|
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||||
from javis.ui.models import (
|
from javis.ui.models import (
|
||||||
DesktopStatus,
|
DesktopStatus,
|
||||||
GenerationState,
|
GenerationState,
|
||||||
@@ -65,6 +67,16 @@ class DesktopController:
|
|||||||
def set_mode(self, mode: str) -> None:
|
def set_mode(self, mode: str) -> None:
|
||||||
self.runtime.hybrid_provider.set_mode(mode)
|
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(
|
def stream_message(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.approval_bridge = approval_bridge
|
self.approval_bridge = approval_bridge
|
||||||
self._thread: QThread | None = None
|
self._thread: QThread | None = None
|
||||||
self._worker: StreamWorker | None = None
|
self._worker: StreamWorker | None = None
|
||||||
|
self._generation_finalized = True
|
||||||
|
self._close_when_finished = False
|
||||||
self._messages: list[ChatMessage] = []
|
self._messages: list[ChatMessage] = []
|
||||||
self._draft_user = ""
|
self._draft_user = ""
|
||||||
self._draft_assistant = ""
|
self._draft_assistant = ""
|
||||||
@@ -158,7 +160,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.stop_button.clicked.connect(self._stop_generation)
|
self.stop_button.clicked.connect(self._stop_generation)
|
||||||
self.message_edit.send_requested.connect(self._send)
|
self.message_edit.send_requested.connect(self._send)
|
||||||
self.mode_combo.currentIndexChanged.connect(self._mode_changed)
|
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)
|
self.approval_bridge.notice_received.connect(self._show_notice)
|
||||||
|
|
||||||
def _apply_style(self) -> None:
|
def _apply_style(self) -> None:
|
||||||
@@ -266,25 +267,40 @@ class MainWindow(QMainWindow):
|
|||||||
text = self.message_edit.toPlainText().strip()
|
text = self.message_edit.toPlainText().strip()
|
||||||
if not text or self._worker is not None:
|
if not text or self._worker is not None:
|
||||||
return
|
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.message_edit.clear()
|
||||||
self._draft_user = text
|
self._draft_user = text
|
||||||
self._draft_assistant = ""
|
self._draft_assistant = ""
|
||||||
self._render_chat()
|
self._render_chat()
|
||||||
self._set_generating(True)
|
self._set_generating(True)
|
||||||
|
self._generation_finalized = False
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
worker = StreamWorker(self.controller, text)
|
worker = StreamWorker(self.controller, text)
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.event_received.connect(self._handle_stream_event)
|
worker.event_received.connect(self._handle_stream_event)
|
||||||
worker.completed.connect(thread.quit)
|
worker.completed.connect(thread.quit)
|
||||||
|
worker.completed.connect(worker.deleteLater)
|
||||||
worker.completed.connect(self._generation_complete)
|
worker.completed.connect(self._generation_complete)
|
||||||
thread.finished.connect(worker.deleteLater)
|
thread.finished.connect(self._thread_finished)
|
||||||
thread.finished.connect(thread.deleteLater)
|
thread.finished.connect(thread.deleteLater)
|
||||||
self._thread = thread
|
self._thread = thread
|
||||||
self._worker = worker
|
self._worker = worker
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _handle_stream_event(self, event: StreamEvent) -> None:
|
def _handle_stream_event(self, event: StreamEvent) -> None:
|
||||||
|
if self._generation_finalized:
|
||||||
|
return
|
||||||
if event.kind is StreamEventKind.CHUNK:
|
if event.kind is StreamEventKind.CHUNK:
|
||||||
self._draft_assistant += event.text
|
self._draft_assistant += event.text
|
||||||
self._render_chat()
|
self._render_chat()
|
||||||
@@ -294,12 +310,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._show_error(event.text)
|
self._show_error(event.text)
|
||||||
|
|
||||||
def _generation_complete(self) -> None:
|
def _generation_complete(self) -> None:
|
||||||
|
if self._generation_finalized:
|
||||||
|
return
|
||||||
|
self._generation_finalized = True
|
||||||
failed = self.controller.state in {
|
failed = self.controller.state in {
|
||||||
GenerationState.ABORTED,
|
GenerationState.ABORTED,
|
||||||
GenerationState.ERROR,
|
GenerationState.ERROR,
|
||||||
}
|
}
|
||||||
self._worker = None
|
|
||||||
self._thread = None
|
|
||||||
self._set_generating(False)
|
self._set_generating(False)
|
||||||
try:
|
try:
|
||||||
loaded = self.controller.load_session(self.controller.active_session_id or "")
|
loaded = self.controller.load_session(self.controller.active_session_id or "")
|
||||||
@@ -315,11 +332,17 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000)
|
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:
|
def _stop_generation(self) -> None:
|
||||||
if self._worker is None:
|
if self._worker is None:
|
||||||
return
|
return
|
||||||
self._worker.request_cancel()
|
self._worker.request_cancel()
|
||||||
self.approval_bridge.cancel_pending()
|
|
||||||
self.statusBar().showMessage("Abbruch angefordert …")
|
self.statusBar().showMessage("Abbruch angefordert …")
|
||||||
|
|
||||||
def _set_generating(self, active: bool) -> None:
|
def _set_generating(self, active: bool) -> None:
|
||||||
@@ -336,7 +359,7 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._show_error(str(exc))
|
self._show_error(str(exc))
|
||||||
|
|
||||||
def _show_approval(self, decision: PrivacyDecision) -> None:
|
def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setWindowTitle("Cloudfreigabe")
|
box.setWindowTitle("Cloudfreigabe")
|
||||||
box.setIcon(QMessageBox.Question)
|
box.setIcon(QMessageBox.Question)
|
||||||
@@ -357,9 +380,9 @@ class MainWindow(QMainWindow):
|
|||||||
choice = ApprovalChoice.LOCAL
|
choice = ApprovalChoice.LOCAL
|
||||||
elif clicked is cancel:
|
elif clicked is cancel:
|
||||||
choice = ApprovalChoice.CANCEL
|
choice = ApprovalChoice.CANCEL
|
||||||
self.approval_bridge.resolve(choice)
|
|
||||||
if box.clickedButton() is local:
|
if box.clickedButton() is local:
|
||||||
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
||||||
|
return choice
|
||||||
|
|
||||||
def _show_notice(self, text: str) -> None:
|
def _show_notice(self, text: str) -> None:
|
||||||
self.statusBar().showMessage(text, 5000)
|
self.statusBar().showMessage(text, 5000)
|
||||||
@@ -438,8 +461,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.search_edit.selectAll()
|
self.search_edit.selectAll()
|
||||||
|
|
||||||
def closeEvent(self, event: QCloseEvent) -> None:
|
def closeEvent(self, event: QCloseEvent) -> None:
|
||||||
|
if self._thread is not None:
|
||||||
|
self._close_when_finished = True
|
||||||
self._stop_generation()
|
self._stop_generation()
|
||||||
if self._thread is not None and not self._thread.wait(2500):
|
|
||||||
event.ignore()
|
event.ignore()
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
"Javis beendet die laufende Antwort noch kontrolliert.",
|
"Javis beendet die laufende Antwort noch kontrolliert.",
|
||||||
|
|||||||
@@ -53,9 +53,14 @@ class StreamWorker(QObject):
|
|||||||
self._controller = controller
|
self._controller = controller
|
||||||
self._text = text
|
self._text = text
|
||||||
self._cancel_event = threading.Event()
|
self._cancel_event = threading.Event()
|
||||||
|
self._started = False
|
||||||
|
self._completed = False
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
try:
|
try:
|
||||||
for event in self._controller.stream_message(
|
for event in self._controller.stream_message(
|
||||||
self._text,
|
self._text,
|
||||||
@@ -63,6 +68,8 @@ class StreamWorker(QObject):
|
|||||||
):
|
):
|
||||||
self.event_received.emit(event)
|
self.event_received.emit(event)
|
||||||
finally:
|
finally:
|
||||||
|
if not self._completed:
|
||||||
|
self._completed = True
|
||||||
self.completed.emit()
|
self.completed.emit()
|
||||||
|
|
||||||
def request_cancel(self) -> None:
|
def request_cancel(self) -> None:
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from javis.core.chat_service import ChatService
|
from javis.core.chat_service import ChatService
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
from javis.memory.sqlite_store import SQLiteSessionStore
|
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||||
from javis.providers.base import ChatMessage, ResponseAbortedError
|
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.chat_controller import DesktopController, sanitized_error
|
||||||
from javis.ui.models import GenerationState, StreamEventKind
|
from javis.ui.models import GenerationState, StreamEventKind
|
||||||
|
|
||||||
@@ -45,10 +47,17 @@ class _Service:
|
|||||||
class _Hybrid:
|
class _Hybrid:
|
||||||
mode = "auto"
|
mode = "auto"
|
||||||
last_route = None
|
last_route = None
|
||||||
|
queued_approval = None
|
||||||
|
|
||||||
def set_mode(self, mode: str) -> None:
|
def set_mode(self, mode: str) -> None:
|
||||||
self.mode = mode
|
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:
|
class _StreamingProvider:
|
||||||
name = "ollama"
|
name = "ollama"
|
||||||
@@ -91,6 +100,17 @@ class DesktopControllerTests(unittest.TestCase):
|
|||||||
self.assertEqual(loaded.messages[0].content, "Hallo")
|
self.assertEqual(loaded.messages[0].content, "Hallo")
|
||||||
self.assertEqual(renamed.title, "Neu")
|
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:
|
def test_error_sanitizer_masks_key_patterns(self) -> None:
|
||||||
secret = "AIza" + "x" * 25
|
secret = "AIza" + "x" * 25
|
||||||
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
|
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
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.providers.base import ChatMessage
|
||||||
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||||
from javis.ui.main_window import MainWindow
|
from javis.ui.main_window import MainWindow
|
||||||
from javis.ui.models import DesktopStatus, GenerationState
|
from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
|
||||||
from javis.ui.workers import ApprovalBridge
|
from javis.ui.workers import ApprovalBridge, StreamWorker
|
||||||
|
|
||||||
|
|
||||||
class _Controller:
|
class _Controller:
|
||||||
@@ -28,6 +30,15 @@ class _Controller:
|
|||||||
message_count=2,
|
message_count=2,
|
||||||
updated_at="2026-07-30T20:00:00+00:00",
|
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 = ""):
|
def list_sessions(self, search: str = ""):
|
||||||
return [self.session] if not search or search in self.session.title else []
|
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
|
self.active_session_id = session_id
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
session=self.session,
|
session=self.session,
|
||||||
messages=[
|
messages=list(self.messages),
|
||||||
ChatMessage("user", "Hallo"),
|
|
||||||
ChatMessage("assistant", "Guten Tag"),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_session(self):
|
def new_session(self):
|
||||||
@@ -53,6 +61,38 @@ class _Controller:
|
|||||||
def set_mode(self, _mode: str) -> None:
|
def set_mode(self, _mode: str) -> None:
|
||||||
return
|
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:
|
def status(self) -> DesktopStatus:
|
||||||
return DesktopStatus(
|
return DesktopStatus(
|
||||||
state=self.state,
|
state=self.state,
|
||||||
@@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.bridge = ApprovalBridge()
|
self.bridge = ApprovalBridge()
|
||||||
self.window = MainWindow(_Controller(), self.bridge)
|
self.controller = _Controller()
|
||||||
|
self.window = MainWindow(self.controller, self.bridge)
|
||||||
self.window.show()
|
self.window.show()
|
||||||
self.application.processEvents()
|
self.application.processEvents()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
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.window.close()
|
||||||
self.application.processEvents()
|
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:
|
def test_offscreen_window_loads_session_and_core_widgets(self) -> None:
|
||||||
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
|
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
|
||||||
self.assertEqual(self.window.session_list.count(), 1)
|
self.assertEqual(self.window.session_list.count(), 1)
|
||||||
@@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase):
|
|||||||
for label, choice in expected.items():
|
for label, choice in expected.items():
|
||||||
with self.subTest(label=label):
|
with self.subTest(label=label):
|
||||||
_MessageBox.selected_label = label
|
_MessageBox.selected_label = label
|
||||||
self.window._show_approval(decision)
|
self.assertIs(self.window._show_approval(decision), choice)
|
||||||
self.bridge.resolve.assert_called_with(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())
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ class HybridProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual(answer, "Cloud")
|
self.assertEqual(answer, "Cloud")
|
||||||
self.assertEqual(len(self.cloud.calls), 1)
|
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:
|
def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
|
||||||
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user