feat: connect desktop sessions and streaming

This commit is contained in:
2026-07-30 20:18:11 +02:00
parent 1658e0485e
commit cf35148576
7 changed files with 150 additions and 21 deletions
+8 -6
View File
@@ -111,6 +111,10 @@ Rootserver:
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück. 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- - Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI-
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden. 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 ## Aktuelle Architektur
@@ -191,8 +195,8 @@ Letzter bestätigter Projektstand:
- uv-Lock und `uv sync --dev`: bestanden - uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13 - Python in `.venv`: 3.12.13
- Ruff in `.venv`: 0.16.0 - Ruff in `.venv`: 0.16.0
- Unit-Tests: 94 bestanden; zusätzlich Runtime-Adapter, Qt-freier Controller, - Unit-Tests: 97 bestanden; zusätzlich GUI-Streamingereignisse, atomarer Abbruch
Secretmaskierung sowie neuer und bestehender CLI-Parserpfad abgedeckt und alle drei Ergebnisse des Datenschutzdialogs abgedeckt
- PowerShell-Syntax des Startskripts: erfolgreich geparst - PowerShell-Syntax des Startskripts: erfolgreich geparst
- Ruff Lint: bestanden - Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden - Ruff Formatprüfung: bestanden
@@ -248,8 +252,6 @@ Abnahmestatus:
- 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
- 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 - `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
- CLI-Verdrahtung: `af9426e` - CLI-Verdrahtung: `af9426e`
- Startskript: `141ff2e` - Startskript: `141ff2e`
@@ -287,8 +289,8 @@ Abnahmestatus:
## Nächster sinnvoller Auftrag ## Nächster sinnvoller Auftrag
Auf `feat/desktop-ui` als Nächstes Freigabedialog mit drei Ergebnissen, Auf `feat/desktop-ui` als Nächstes Offscreen-GUI-Tests, sicheres Startskript,
Qt-Worker-Abbruch, Sitzungsbedienung und bereinigte Statusanzeige gezielt testen. Desktopanleitung und anschließend echten lokalen GUI-Smoke ergänzen.
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.
+27 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
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 javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import ( from javis.providers.base import (
@@ -18,7 +19,14 @@ from javis.providers.base import (
) )
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter 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] NoticeCallback = Callable[[str], None]
CloudProviderFactory = Callable[[], LocalModelProvider] CloudProviderFactory = Callable[[], LocalModelProvider]
@@ -89,6 +97,14 @@ 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 _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: def chat(self, messages: list[ChatMessage]) -> str:
current_text = messages[-1].content if messages else "" current_text = messages[-1].content if messages else ""
cloud_requested = self.mode == "gemini" cloud_requested = self.mode == "gemini"
@@ -110,8 +126,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK: if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision) choice = self._approval_choice(decision)
if not approved: 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) return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
unavailable_reason = self._cloud_unavailable_reason() unavailable_reason = self._cloud_unavailable_reason()
@@ -194,8 +213,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK: if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision) choice = self._approval_choice(decision)
if not approved: 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( yield from self._stream_local(
messages, messages,
decision, decision,
+8 -1
View File
@@ -88,6 +88,10 @@ class DesktopController:
yield StreamEvent(StreamEventKind.STATE, state=self.state) yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text=chunk) yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
except ProviderError as exc: 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 self.state = GenerationState.ERROR
yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state) yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state)
return return
@@ -101,8 +105,11 @@ class DesktopController:
def status(self) -> DesktopStatus: def status(self) -> DesktopStatus:
runtime_status = self.runtime.status() runtime_status = self.runtime.status()
route = self.runtime.hybrid_provider.last_route 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( return DesktopStatus(
state=self.state, state=state,
mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto", mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto",
provider=route.provider if route else "", provider=route.provider if route else "",
ollama_reachable=runtime_status.ollama_reachable, ollama_reachable=runtime_status.ollama_reachable,
+26 -2
View File
@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from javis.core.provider_router import ApprovalChoice
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import PrivacyDecision from javis.security.privacy import PrivacyDecision
from javis.ui.chat_controller import DesktopController from javis.ui.chat_controller import DesktopController
@@ -63,7 +64,7 @@ class MainWindow(QMainWindow):
self._build_ui() self._build_ui()
self._connect_signals() self._connect_signals()
self._apply_style() self._apply_style()
self._new_session() self._open_initial_session()
self._animation = QTimer(self) self._animation = QTimer(self)
self._animation.timeout.connect(self._animate_state) self._animation.timeout.connect(self._animate_state)
self._animation.start(450) self._animation.start(450)
@@ -191,6 +192,20 @@ class MainWindow(QMainWindow):
self.message_edit.setFocus() self.message_edit.setFocus()
self._refresh_status() 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: def _refresh_sessions(self) -> None:
try: try:
sessions = self.controller.list_sessions(self.search_edit.text()) sessions = self.controller.list_sessions(self.search_edit.text())
@@ -332,8 +347,17 @@ class MainWindow(QMainWindow):
box.setInformativeText(f"Lokale Einstufung: {decision.reason}") box.setInformativeText(f"Lokale Einstufung: {decision.reason}")
allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole) allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole)
local = box.addButton("Lokal beantworten", QMessageBox.RejectRole) local = box.addButton("Lokal beantworten", QMessageBox.RejectRole)
cancel = box.addButton("Abbrechen", QMessageBox.DestructiveRole)
box.exec() 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: if box.clickedButton() is local:
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000) self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
+7 -6
View File
@@ -6,6 +6,7 @@ import threading
from PySide6.QtCore import QObject, Signal, Slot from PySide6.QtCore import QObject, Signal, Slot
from javis.core.provider_router import ApprovalChoice
from javis.security.privacy import PrivacyDecision from javis.security.privacy import PrivacyDecision
from javis.ui.chat_controller import DesktopController from javis.ui.chat_controller import DesktopController
@@ -18,13 +19,13 @@ class ApprovalBridge(QObject):
super().__init__() super().__init__()
self._lock = threading.Lock() self._lock = threading.Lock()
self._event: threading.Event | None = None 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() event = threading.Event()
with self._lock: with self._lock:
self._event = event self._event = event
self._result = False self._result = ApprovalChoice.CANCEL
self.approval_requested.emit(decision) self.approval_requested.emit(decision)
event.wait() event.wait()
with self._lock: with self._lock:
@@ -32,15 +33,15 @@ class ApprovalBridge(QObject):
self._event = None self._event = None
return result return result
def resolve(self, allowed: bool) -> None: def resolve(self, choice: ApprovalChoice) -> None:
with self._lock: with self._lock:
self._result = allowed self._result = choice
event = self._event event = self._event
if event is not None: if event is not None:
event.set() event.set()
def cancel_pending(self) -> None: def cancel_pending(self) -> None:
self.resolve(False) self.resolve(ApprovalChoice.CANCEL)
class StreamWorker(QObject): class StreamWorker(QObject):
+60
View File
@@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
import tempfile
import threading
import unittest import unittest
from pathlib import Path
from types import SimpleNamespace 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.providers.base import ChatMessage
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
class _Service: class _Service:
@@ -44,6 +50,20 @@ class _Hybrid:
self.mode = mode 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): class DesktopControllerTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.runtime = SimpleNamespace( self.runtime = SimpleNamespace(
@@ -71,3 +91,43 @@ class DesktopControllerTests(unittest.TestCase):
self.assertNotIn(secret, result) self.assertNotIn(secret, result)
self.assertIn("MASKIERT", 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, [])
+14 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.memory.usage_store import ProviderEvent, SQLiteUsageStore
from javis.providers.base import ( from javis.providers.base import (
ChatMessage, ChatMessage,
@@ -182,6 +182,19 @@ 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_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: def test_429_falls_back_without_losing_local_answer(self) -> None:
self.cloud.error = ProviderRateLimitError("quota") self.cloud.error = ProviderRateLimitError("quota")
router = self._router() router = self._router()