feat: connect desktop sessions and streaming
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user