From 1658e0485ee828e031a93dc930c99644fc18ff14 Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 20:15:20 +0200 Subject: [PATCH] feat: add desktop ui foundation --- docs/CHATGPT_HANDOFF.md | 18 +- pyproject.toml | 2 + src/javis/core/runtime.py | 145 +++++++++ src/javis/interface/cli.py | 87 +----- src/javis/ui/__init__.py | 1 + src/javis/ui/app.py | 43 +++ src/javis/ui/chat_controller.py | 114 +++++++ src/javis/ui/main_window.py | 419 ++++++++++++++++++++++++++ src/javis/ui/models.py | 43 +++ src/javis/ui/workers.py | 68 +++++ tests/unit/test_cli.py | 11 +- tests/unit/test_desktop_controller.py | 73 +++++ uv.lock | 129 +++++++- 13 files changed, 1069 insertions(+), 84 deletions(-) create mode 100644 src/javis/core/runtime.py create mode 100644 src/javis/ui/__init__.py create mode 100644 src/javis/ui/app.py create mode 100644 src/javis/ui/chat_controller.py create mode 100644 src/javis/ui/main_window.py create mode 100644 src/javis/ui/models.py create mode 100644 src/javis/ui/workers.py create mode 100644 tests/unit/test_desktop_controller.py diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 19518e5..c0e27e0 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -109,6 +109,8 @@ Rootserver: - Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile. Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown 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. ## Aktuelle Architektur @@ -189,8 +191,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: 91 bestanden; Navigation, Streaming, Migration, Datenschutz, - ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen abgedeckt +- Unit-Tests: 94 bestanden; zusätzlich Runtime-Adapter, Qt-freier Controller, + Secretmaskierung sowie neuer und bestehender CLI-Parserpfad abgedeckt - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -235,7 +237,8 @@ Abnahmestatus: ## Git-Stand -- aktueller und geprüfter Branch: `main` +- aktueller Arbeitsbranch: `feat/desktop-ui` +- stabiler Ausgangsstand: `main` bei `d3e85f2` - stabiler Ausgangsstand: `main` bei `77b510b` - medizinischer Datenschutz-Fix: `9148193` - Provider- und CLI-Streaming: `00a5a6c` @@ -248,11 +251,6 @@ Abnahmestatus: - 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 -- Secret-Provider: `570e7fa` -- Datenschutz/Nullkosten: `286d96d` -- isolierter Gemini-Provider: `f3e1183` -- Hybridrouter: `e57186f` -- lokale Konfiguration: `ab6ea48` - CLI-Verdrahtung: `af9426e` - Startskript: `141ff2e` - Resttests: `a0af15a` @@ -289,8 +287,8 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Chatkomfort ist implementiert, gemergt, auf `main` erneut geprüft und gepusht. -Jetzt stoppen und mit Pascal erst später ein einzelnes nächstes Paket festlegen. +Auf `feat/desktop-ui` als Nächstes Freigabedialog mit drei Ergebnissen, +Qt-Worker-Abbruch, Sitzungsbedienung und bereinigte Statusanzeige gezielt testen. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Noch keine Obsidian-Integration oder Tools beginnen. diff --git a/pyproject.toml b/pyproject.toml index 26ccacb..d9c938d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.11" dependencies = [ "google-genai>=2.6,<3", "keyring>=25.7,<26", + "pyside6>=6.8,<7", ] [project.scripts] @@ -18,6 +19,7 @@ javis = "javis.interface.cli:main" [dependency-groups] dev = [ + "pytest>=8,<9", "ruff>=0.12,<1", ] diff --git a/src/javis/core/runtime.py b/src/javis/core/runtime.py new file mode 100644 index 0000000..2f88cc4 --- /dev/null +++ b/src/javis/core/runtime.py @@ -0,0 +1,145 @@ +"""Shared runtime assembly for CLI and desktop interfaces.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from javis.config.settings import Settings +from javis.core.chat_service import ChatService +from javis.core.provider_router import ApprovalCallback, HybridProvider, NoticeCallback +from javis.memory.sqlite_store import SQLiteSessionStore +from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError +from javis.providers.base import MissingApiKeyError +from javis.providers.gemini import GeminiProvider +from javis.providers.ollama import OllamaProvider +from javis.security.privacy import PrivacyRouter +from javis.security.secrets import SecretProvider, SecretStoreError + + +@dataclass(frozen=True, slots=True) +class RuntimeStatus: + ollama_reachable: bool + local_model_available: bool + local_model: str + gemini_key_available: bool + gemini_configured: bool + free_only: bool + provider_mode: str + cloud_requests_today: int | None + local_fallbacks_today: int | None + data_dir: str + + def cli_lines(self) -> list[str]: + cloud_requests = ( + self.cloud_requests_today if self.cloud_requests_today is not None else "unbekannt" + ) + local_fallbacks = ( + self.local_fallbacks_today if self.local_fallbacks_today is not None else "unbekannt" + ) + return [ + f"Ollama erreichbar: {'ja' if self.ollama_reachable else 'nein'}", + f"Lokales Modell vorhanden: {'ja' if self.local_model_available else 'nein'}", + f"Gemini-Schlüssel vorhanden: {'ja' if self.gemini_key_available else 'nein'}", + f"Gemini konfiguriert: {'ja' if self.gemini_configured else 'nein'}", + f"Nullkostenmodus: {'aktiv' if self.free_only else 'inaktiv'}", + f"Aktiver Providermodus: {self.provider_mode}", + f"Heutige Cloudanfragen: {cloud_requests}", + f"Heutige lokale Fallbacks: {local_fallbacks}", + ] + + +@dataclass(slots=True) +class JavisRuntime: + settings: Settings + service: ChatService + local_provider: OllamaProvider + hybrid_provider: HybridProvider + usage_store: SQLiteUsageStore + secrets: SecretProvider + + def status(self) -> RuntimeStatus: + ollama = self.local_provider.probe() + try: + key_available = self.secrets.gemini_key_available() + except SecretStoreError: + key_available = False + try: + cloud_requests: int | None = self.usage_store.cloud_requests_on() + local_fallbacks: int | None = self.usage_store.local_fallbacks_on() + except UsageStoreError: + cloud_requests = local_fallbacks = None + return RuntimeStatus( + ollama_reachable=ollama.reachable, + local_model_available=ollama.model_available, + local_model=self.local_provider.model, + gemini_key_available=key_available, + gemini_configured=( + self.settings.gemini_enabled + and self.settings.free_only + and self.settings.billing_confirmed_disabled + ), + free_only=self.settings.free_only, + provider_mode=self.hybrid_provider.mode, + cloud_requests_today=cloud_requests, + local_fallbacks_today=local_fallbacks, + data_dir=str(self.settings.data_dir), + ) + + +def build_javis_runtime( + settings: Settings, + *, + approval_callback: ApprovalCallback, + notice_callback: NoticeCallback, + secret_provider: SecretProvider | None = None, +) -> JavisRuntime: + """Assemble the existing core once for any user interface.""" + + store = SQLiteSessionStore(settings.database_path) + usage_store = SQLiteUsageStore(settings.usage_database_path) + secrets = secret_provider or SecretProvider() + local = OllamaProvider( + settings.model, + settings.ollama_base_url, + settings.timeout_seconds, + ) + + def create_cloud_provider() -> GeminiProvider: + try: + key = secrets.get_gemini_key() + except SecretStoreError as exc: + raise MissingApiKeyError( + "Der Gemini-Schlüssel konnte nicht sicher geladen werden." + ) from exc + return GeminiProvider( + model=settings.gemini_model, + api_key=key, + timeout_seconds=settings.gemini_timeout_seconds, + max_output_tokens=settings.max_cloud_output_tokens, + max_retries=settings.gemini_max_retries, + ) + + hybrid = HybridProvider( + local_provider=local, + cloud_provider_factory=create_cloud_provider, + cloud_model=settings.gemini_model, + privacy_router=PrivacyRouter(), + usage_store=usage_store, + approval_callback=approval_callback, + notice_callback=notice_callback, + mode=settings.provider_mode, + cloud_enabled=settings.gemini_enabled, + billing_confirmed_disabled=settings.billing_confirmed_disabled, + free_only=settings.free_only, + max_cloud_requests_per_day=settings.max_cloud_requests_per_day, + max_cloud_input_chars=settings.max_cloud_input_chars, + max_cloud_context_messages=settings.max_cloud_context_messages, + ) + return JavisRuntime( + settings=settings, + service=ChatService(store, hybrid), + local_provider=local, + hybrid_provider=hybrid, + usage_store=usage_store, + secrets=secrets, + ) diff --git a/src/javis/interface/cli.py b/src/javis/interface/cli.py index 9e70a64..951d434 100644 --- a/src/javis/interface/cli.py +++ b/src/javis/interface/cli.py @@ -12,19 +12,15 @@ from pathlib import Path from javis.config.local_config import LocalConfigError, write_gemini_activation from javis.config.settings import ConfigurationError, Settings from javis.core.chat_service import ChatService, SessionProviderMismatchError -from javis.core.provider_router import HybridProvider +from javis.core.runtime import build_javis_runtime from javis.memory.sqlite_store import ( AmbiguousSessionReferenceError, ChatSession, SessionNotFoundError, SessionStoreError, - SQLiteSessionStore, ) -from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError -from javis.providers.base import MissingApiKeyError, ProviderError -from javis.providers.gemini import GeminiProvider -from javis.providers.ollama import OllamaProvider -from javis.security.privacy import PrivacyDecision, PrivacyRouter +from javis.memory.usage_store import UsageStoreError +from javis.providers.base import ProviderError from javis.security.secrets import SecretProvider, SecretStoreError InputFunction = Callable[[str], str] @@ -52,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") + subparsers.add_parser("gui", help="Javis-Desktopoberfläche starten") configure_parser = subparsers.add_parser( "configure", help="nicht geheime lokale Einstellungen einrichten", @@ -385,79 +382,21 @@ def build_runtime( output: OutputFunction, secret_provider: SecretProvider | None = None, ) -> tuple[ChatService, StatusFunction]: - store = SQLiteSessionStore(settings.database_path) - usage_store = SQLiteUsageStore(settings.usage_database_path) - secrets = secret_provider or SecretProvider() - local = OllamaProvider( - settings.model, - settings.ollama_base_url, - settings.timeout_seconds, - ) - - def approve(decision: PrivacyDecision) -> bool: - answer = input_fn( - f"Cloudfreigabe erforderlich ({decision.reason}). Einmalig erlauben? [j/N] " - ) + def approve(_decision: object) -> bool: + answer = input_fn("Cloudfreigabe erforderlich. Einmalig erlauben? [j/N] ") return _confirmed(answer) - def create_cloud_provider() -> GeminiProvider: - try: - key = secrets.get_gemini_key() - except SecretStoreError as exc: - raise MissingApiKeyError( - "Der Gemini-Schlüssel konnte nicht sicher geladen werden." - ) from exc - return GeminiProvider( - model=settings.gemini_model, - api_key=key, - timeout_seconds=settings.gemini_timeout_seconds, - max_output_tokens=settings.max_cloud_output_tokens, - max_retries=settings.gemini_max_retries, - ) - - hybrid = HybridProvider( - local_provider=local, - cloud_provider_factory=create_cloud_provider, - cloud_model=settings.gemini_model, - privacy_router=PrivacyRouter(), - usage_store=usage_store, + runtime = build_javis_runtime( + settings, approval_callback=approve, notice_callback=output, - mode=settings.provider_mode, - cloud_enabled=settings.gemini_enabled, - billing_confirmed_disabled=settings.billing_confirmed_disabled, - free_only=settings.free_only, - max_cloud_requests_per_day=settings.max_cloud_requests_per_day, - max_cloud_input_chars=settings.max_cloud_input_chars, - max_cloud_context_messages=settings.max_cloud_context_messages, + secret_provider=secret_provider, ) def status() -> list[str]: - ollama = local.probe() - try: - key_available = secrets.gemini_key_available() - except SecretStoreError: - key_available = False - gemini_configured = ( - settings.gemini_enabled and settings.free_only and settings.billing_confirmed_disabled - ) - try: - cloud_requests = usage_store.cloud_requests_on() - local_fallbacks = usage_store.local_fallbacks_on() - except UsageStoreError: - cloud_requests = local_fallbacks = -1 - return [ - f"Ollama erreichbar: {'ja' if ollama.reachable else 'nein'}", - f"Lokales Modell vorhanden: {'ja' if ollama.model_available else 'nein'}", - f"Gemini-Schlüssel vorhanden: {'ja' if key_available else 'nein'}", - f"Gemini konfiguriert: {'ja' if gemini_configured else 'nein'}", - f"Nullkostenmodus: {'aktiv' if settings.free_only else 'inaktiv'}", - f"Aktiver Providermodus: {hybrid.mode}", - f"Heutige Cloudanfragen: {cloud_requests if cloud_requests >= 0 else 'unbekannt'}", - f"Heutige lokale Fallbacks: {local_fallbacks if local_fallbacks >= 0 else 'unbekannt'}", - ] + return runtime.status().cli_lines() - return ChatService(store, hybrid), status + return runtime.service, status def main( @@ -484,6 +423,10 @@ def main( input_fn=input_fn, output=output, ) + if args.command == "gui": + from javis.ui.app import run_gui + + return run_gui([]) if args.command != "chat": return 2 diff --git a/src/javis/ui/__init__.py b/src/javis/ui/__init__.py new file mode 100644 index 0000000..1f6e0f5 --- /dev/null +++ b/src/javis/ui/__init__.py @@ -0,0 +1 @@ +"""PySide6 desktop interface for the existing Javis core.""" diff --git a/src/javis/ui/app.py b/src/javis/ui/app.py new file mode 100644 index 0000000..e0a57ab --- /dev/null +++ b/src/javis/ui/app.py @@ -0,0 +1,43 @@ +"""Desktop application entry point.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +from PySide6.QtWidgets import QApplication, QMessageBox + +from javis.config.settings import ConfigurationError, Settings +from javis.core.runtime import build_javis_runtime +from javis.memory.sqlite_store import SessionStoreError +from javis.memory.usage_store import UsageStoreError +from javis.ui.chat_controller import DesktopController +from javis.ui.main_window import MainWindow +from javis.ui.workers import ApprovalBridge + + +def run_gui(argv: Sequence[str] | None = None) -> int: + application = QApplication.instance() or QApplication(list(argv or [])) + application.setApplicationName("Javis Desktop") + approval_bridge = ApprovalBridge() + try: + settings = Settings.from_env() + runtime = build_javis_runtime( + settings, + approval_callback=approval_bridge.request, + notice_callback=approval_bridge.notice_received.emit, + ) + except (ConfigurationError, SessionStoreError, UsageStoreError) as exc: + QMessageBox.critical(None, "Javis konnte nicht starten", str(exc)) + return 2 + window = MainWindow(DesktopController(runtime), approval_bridge) + window.show() + return application.exec() + + +def main() -> int: + return run_gui(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/javis/ui/chat_controller.py b/src/javis/ui/chat_controller.py new file mode 100644 index 0000000..8c7c90b --- /dev/null +++ b/src/javis/ui/chat_controller.py @@ -0,0 +1,114 @@ +"""Small non-Qt adapter between desktop widgets and the existing Javis core.""" + +from __future__ import annotations + +import re +import threading +from collections.abc import Iterator + +from javis.core.runtime import JavisRuntime +from javis.memory.sqlite_store import ChatSession +from javis.providers.base import ProviderError +from javis.ui.models import ( + DesktopStatus, + GenerationState, + StreamEvent, + StreamEventKind, +) + +_SECRET_PATTERN = re.compile( + r"AIza[\w-]{20,}|sk-[\w-]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----", + re.IGNORECASE, +) + + +def sanitized_error(error: BaseException) -> str: + text = str(error).strip() or "Unbekannter Fehler." + return _SECRET_PATTERN.sub("[SECRET MASKIERT]", text) + + +class DesktopController: + """Expose core operations without placing business rules in widgets.""" + + def __init__(self, runtime: JavisRuntime) -> None: + self.runtime = runtime + self.service = runtime.service + self.active_session_id: str | None = None + self.state = GenerationState.READY + + def ensure_session(self) -> ChatSession: + if self.active_session_id is None: + session = self.service.new_session() + self.active_session_id = session.id + return session + return self.service.load_session(self.active_session_id).session + + def new_session(self) -> ChatSession: + session = self.service.new_session() + self.active_session_id = session.id + return session + + def list_sessions(self, search: str = "") -> list[ChatSession]: + return ( + self.service.search_sessions(search) if search.strip() else self.service.list_sessions() + ) + + def load_session(self, session_id: str): + loaded = self.service.load_session(session_id) + self.active_session_id = loaded.session.id + return loaded + + def rename_active_session(self, title: str) -> ChatSession: + session = self.ensure_session() + return self.service.rename_session(session.id, title) + + def set_mode(self, mode: str) -> None: + self.runtime.hybrid_provider.set_mode(mode) + + def stream_message( + self, + text: str, + cancel_event: threading.Event, + ) -> Iterator[StreamEvent]: + session = self.ensure_session() + stream = self.service.stream_send(session.id, text) + self.state = GenerationState.THINKING + yield StreamEvent(StreamEventKind.STATE, state=self.state) + started = False + try: + for chunk in stream: + if cancel_event.is_set(): + stream.close() + self.state = GenerationState.ABORTED + yield StreamEvent(StreamEventKind.STATE, state=self.state) + return + if not started: + started = True + self.state = GenerationState.ANSWERING + yield StreamEvent(StreamEventKind.STATE, state=self.state) + yield StreamEvent(StreamEventKind.CHUNK, text=chunk) + except ProviderError as exc: + self.state = GenerationState.ERROR + yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state) + return + except Exception as exc: + self.state = GenerationState.ERROR + yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state) + return + self.state = GenerationState.READY + yield StreamEvent(StreamEventKind.FINISHED, state=self.state) + + def status(self) -> DesktopStatus: + runtime_status = self.runtime.status() + route = self.runtime.hybrid_provider.last_route + return DesktopStatus( + state=self.state, + mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto", + provider=route.provider if route else "—", + ollama_reachable=runtime_status.ollama_reachable, + local_model=runtime_status.local_model, + privacy_policy=route.privacy_policy.value if route else "—", + cloud_requests_today=runtime_status.cloud_requests_today, + local_fallback=bool(route and route.fallback), + data_dir=runtime_status.data_dir, + ) diff --git a/src/javis/ui/main_window.py b/src/javis/ui/main_window.py new file mode 100644 index 0000000..f60a9fc --- /dev/null +++ b/src/javis/ui/main_window.py @@ -0,0 +1,419 @@ +"""Main PySide6 window for the Javis desktop MVP.""" + +from __future__ import annotations + +from html import escape + +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMenu, + QMessageBox, + QPushButton, + QSplitter, + QTextBrowser, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from javis.providers.base import ChatMessage +from javis.security.privacy import PrivacyDecision +from javis.ui.chat_controller import DesktopController +from javis.ui.models import GenerationState, StreamEvent, StreamEventKind +from javis.ui.workers import ApprovalBridge, StreamWorker + + +class MessageEdit(QTextEdit): + send_requested = Signal() + + def keyPressEvent(self, event) -> None: + if event.key() in {Qt.Key_Return, Qt.Key_Enter} and not ( + event.modifiers() & Qt.ShiftModifier + ): + self.send_requested.emit() + event.accept() + return + super().keyPressEvent(event) + + +class MainWindow(QMainWindow): + def __init__( + self, + controller: DesktopController, + approval_bridge: ApprovalBridge, + ) -> None: + super().__init__() + self.controller = controller + self.approval_bridge = approval_bridge + self._thread: QThread | None = None + self._worker: StreamWorker | None = None + self._messages: list[ChatMessage] = [] + self._draft_user = "" + self._draft_assistant = "" + self._thinking_dots = 0 + self._build_ui() + self._connect_signals() + self._apply_style() + self._new_session() + self._animation = QTimer(self) + self._animation.timeout.connect(self._animate_state) + self._animation.start(450) + + def _build_ui(self) -> None: + self.setWindowTitle("Javis Desktop") + self.resize(1360, 820) + splitter = QSplitter(Qt.Horizontal) + splitter.addWidget(self._build_sidebar()) + splitter.addWidget(self._build_chat()) + splitter.addWidget(self._build_status_panel()) + splitter.setSizes([280, 760, 300]) + self.setCentralWidget(splitter) + self.statusBar().showMessage("Bereit") + + QShortcut(QKeySequence("Ctrl+N"), self, activated=self._new_session) + QShortcut(QKeySequence("Ctrl+K"), self, activated=self._focus_search) + QShortcut(QKeySequence("Esc"), self, activated=self._stop_generation) + + def _build_sidebar(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + self.new_button = QPushButton("Neue Sitzung") + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("Sitzungen suchen …") + self.session_list = QListWidget() + self.session_list.setContextMenuPolicy(Qt.CustomContextMenu) + layout.addWidget(self.new_button) + layout.addWidget(self.search_edit) + layout.addWidget(self.session_list, 1) + return panel + + def _build_chat(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + self.chat_view = QTextBrowser() + self.chat_view.setOpenExternalLinks(False) + self.message_edit = MessageEdit() + self.message_edit.setPlaceholderText("Nachricht an Javis …") + self.message_edit.setMaximumHeight(130) + buttons = QHBoxLayout() + buttons.addStretch(1) + self.stop_button = QPushButton("Stoppen") + self.stop_button.setEnabled(False) + self.send_button = QPushButton("Senden") + buttons.addWidget(self.stop_button) + buttons.addWidget(self.send_button) + layout.addWidget(self.chat_view, 1) + layout.addWidget(self.message_edit) + layout.addLayout(buttons) + return panel + + def _build_status_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + title = QLabel("SYSTEMSTATUS") + title.setObjectName("panelTitle") + layout.addWidget(title) + self.state_label = QLabel("Bereit") + self.state_label.setObjectName("stateLabel") + layout.addWidget(self.state_label) + self.mode_combo = QComboBox() + self.mode_combo.addItem("Hybrid/Auto", "auto") + self.mode_combo.addItem("Lokal", "local") + form = QFormLayout() + form.addRow("Modus", self.mode_combo) + self.status_labels: dict[str, QLabel] = {} + for key, label in ( + ("provider", "Provider"), + ("ollama", "Ollama erreichbar"), + ("model", "Lokales Modell"), + ("privacy", "Datenschutz"), + ("cloud", "Cloudaufrufe heute"), + ("fallback", "Lokales Fallback"), + ("data", "Datenverzeichnis"), + ): + value = QLabel("—") + value.setWordWrap(True) + self.status_labels[key] = value + form.addRow(label, value) + layout.addLayout(form) + layout.addStretch(1) + return panel + + def _connect_signals(self) -> None: + self.new_button.clicked.connect(self._new_session) + self.search_edit.textChanged.connect(self._refresh_sessions) + self.session_list.itemDoubleClicked.connect(self._load_item) + self.session_list.customContextMenuRequested.connect(self._session_menu) + self.send_button.clicked.connect(self._send) + 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: + self.setStyleSheet( + """ + QMainWindow, QWidget { background: #171c21; color: #e8edf2; } + QLineEdit, QTextEdit, QTextBrowser, QListWidget, QComboBox { + background: #202b35; border: 1px solid #344553; + border-radius: 6px; padding: 7px; selection-background-color: #b97828; + } + QPushButton { background: #2b3945; border: 1px solid #485b69; + border-radius: 6px; padding: 8px 14px; } + QPushButton:hover { border-color: #d3943f; } + QPushButton:disabled { color: #68747d; } + #panelTitle { color: #d3943f; font-weight: 700; letter-spacing: 1px; } + #stateLabel { background: #24342e; color: #86d49b; padding: 10px; + border-radius: 6px; font-weight: 700; } + QStatusBar { background: #11161a; color: #b8c2ca; } + """ + ) + + def _new_session(self) -> None: + if self._worker is not None: + return + session = self.controller.new_session() + self._messages = [] + self._draft_user = self._draft_assistant = "" + self._render_chat() + self._refresh_sessions() + self.statusBar().showMessage(f"Neue Sitzung: {session.title}", 3000) + self.message_edit.setFocus() + self._refresh_status() + + def _refresh_sessions(self) -> None: + try: + sessions = self.controller.list_sessions(self.search_edit.text()) + except Exception as exc: + self._show_error(str(exc)) + return + active = self.controller.active_session_id + self.session_list.clear() + for session in sessions: + item = QListWidgetItem( + f"{session.title}\n" + f"{session.last_provider} · {session.message_count} Nachrichten · " + f"{session.updated_at[:16].replace('T', ' ')}" + ) + item.setData(Qt.UserRole, session.id) + self.session_list.addItem(item) + if session.id == active: + self.session_list.setCurrentItem(item) + + def _load_item(self, item: QListWidgetItem) -> None: + if self._worker is not None: + return + try: + loaded = self.controller.load_session(item.data(Qt.UserRole)) + except Exception as exc: + self._show_error(str(exc)) + return + self._messages = list(loaded.messages) + self._draft_user = self._draft_assistant = "" + self._render_chat() + self.statusBar().showMessage(f"Sitzung geladen: {loaded.session.title}", 3000) + + def _session_menu(self, position) -> None: + item = self.session_list.itemAt(position) + if item is None: + return + menu = QMenu(self) + rename_action = QAction("Umbenennen", menu) + rename_action.triggered.connect(lambda: self._rename_item(item)) + menu.addAction(rename_action) + menu.exec(self.session_list.mapToGlobal(position)) + + def _rename_item(self, item: QListWidgetItem) -> None: + if item.data(Qt.UserRole) != self.controller.active_session_id: + self._load_item(item) + from PySide6.QtWidgets import QInputDialog + + title, accepted = QInputDialog.getText(self, "Sitzung umbenennen", "Neuer Titel:") + if not accepted: + return + try: + self.controller.rename_active_session(title) + self._refresh_sessions() + except Exception as exc: + self._show_error(str(exc)) + + def _send(self) -> None: + text = self.message_edit.toPlainText().strip() + if not text or self._worker is not None: + return + self.message_edit.clear() + self._draft_user = text + self._draft_assistant = "" + self._render_chat() + self._set_generating(True) + 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(self._generation_complete) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + self._thread = thread + self._worker = worker + thread.start() + + def _handle_stream_event(self, event: StreamEvent) -> None: + if event.kind is StreamEventKind.CHUNK: + self._draft_assistant += event.text + self._render_chat() + if event.state is not None: + self._set_state(event.state) + if event.kind is StreamEventKind.ERROR: + self._show_error(event.text) + + def _generation_complete(self) -> None: + 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 "") + self._messages = list(loaded.messages) + except Exception as exc: + self._show_error(str(exc)) + self._draft_user = self._draft_assistant = "" + self._render_chat() + self._refresh_sessions() + self._refresh_status() + if failed: + self.statusBar().showMessage("Antwort abgebrochen oder fehlgeschlagen.", 5000) + else: + self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000) + + 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: + self.send_button.setEnabled(not active) + self.stop_button.setEnabled(active) + self.new_button.setEnabled(not active) + self.session_list.setEnabled(not active) + self.mode_combo.setEnabled(not active) + + def _mode_changed(self) -> None: + try: + self.controller.set_mode(self.mode_combo.currentData()) + self._refresh_status() + except Exception as exc: + self._show_error(str(exc)) + + def _show_approval(self, decision: PrivacyDecision) -> None: + box = QMessageBox(self) + box.setWindowTitle("Cloudfreigabe") + box.setIcon(QMessageBox.Question) + box.setText( + "Diese Anfrage darf nur nach deiner Freigabe an Gemini gesendet werden.\n\n" + "Es wird ausschließlich der minimierte notwendige Kontext übertragen." + ) + box.setInformativeText(f"Lokale Einstufung: {decision.reason}") + allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole) + local = box.addButton("Lokal beantworten", QMessageBox.RejectRole) + box.exec() + self.approval_bridge.resolve(box.clickedButton() is allow) + if box.clickedButton() is local: + self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000) + + def _show_notice(self, text: str) -> None: + self.statusBar().showMessage(text, 5000) + + def _show_error(self, text: str) -> None: + QMessageBox.critical(self, "Javis-Fehler", text) + self.statusBar().showMessage(f"Fehler: {text}", 5000) + + def _refresh_status(self) -> None: + try: + status = self.controller.status() + except Exception as exc: + self._show_error(str(exc)) + return + self._set_state(status.state) + self.status_labels["provider"].setText(status.provider) + self.status_labels["ollama"].setText("Ja" if status.ollama_reachable else "Nein") + self.status_labels["model"].setText(status.local_model) + self.status_labels["privacy"].setText(status.privacy_policy) + self.status_labels["cloud"].setText( + str(status.cloud_requests_today) + if status.cloud_requests_today is not None + else "Unbekannt" + ) + self.status_labels["fallback"].setText("Ja" if status.local_fallback else "Nein") + self.status_labels["data"].setText(status.data_dir) + + def _set_state(self, state: GenerationState) -> None: + self.state_label.setText(state.value) + colors = { + GenerationState.READY: ("#24342e", "#86d49b"), + GenerationState.THINKING: ("#3a3020", "#e1a54f"), + GenerationState.ANSWERING: ("#3a3020", "#e1a54f"), + GenerationState.ABORTED: ("#3a3020", "#e1a54f"), + GenerationState.ERROR: ("#3a2424", "#ed7777"), + GenerationState.OFFLINE: ("#3a2424", "#ed7777"), + } + background, foreground = colors[state] + self.state_label.setStyleSheet( + f"background: {background}; color: {foreground}; padding: 10px; " + "border-radius: 6px; font-weight: 700;" + ) + + def _animate_state(self) -> None: + if self.controller.state not in { + GenerationState.THINKING, + GenerationState.ANSWERING, + }: + return + self._thinking_dots = (self._thinking_dots + 1) % 4 + self.state_label.setText(f"{self.controller.state.value}{'.' * self._thinking_dots}") + + def _render_chat(self) -> None: + blocks: list[str] = [] + messages = list(self._messages) + if self._draft_user: + messages.append(ChatMessage("user", self._draft_user)) + if self._draft_assistant: + messages.append(ChatMessage("assistant", self._draft_assistant)) + for message in messages: + label = "Pascal" if message.role == "user" else "Javis" + content = message.content if message.role == "assistant" else escape(message.content) + blocks.append(f"### {label}\n\n{content}") + self.chat_view.setMarkdown("\n\n---\n\n".join(blocks)) + scrollbar = self.chat_view.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _focus_search(self) -> None: + self.search_edit.setFocus() + 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): + event.ignore() + self.statusBar().showMessage( + "Javis beendet die laufende Antwort noch kontrolliert.", + 3000, + ) + return + event.accept() diff --git a/src/javis/ui/models.py b/src/javis/ui/models.py new file mode 100644 index 0000000..685fd3f --- /dev/null +++ b/src/javis/ui/models.py @@ -0,0 +1,43 @@ +"""UI-facing immutable models without Qt dependencies.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class GenerationState(StrEnum): + OFFLINE = "Offline" + READY = "Bereit" + THINKING = "Denkt" + ANSWERING = "Antwortet" + ABORTED = "Abgebrochen" + ERROR = "Fehler" + + +class StreamEventKind(StrEnum): + STATE = "state" + CHUNK = "chunk" + NOTICE = "notice" + ERROR = "error" + FINISHED = "finished" + + +@dataclass(frozen=True, slots=True) +class StreamEvent: + kind: StreamEventKind + text: str = "" + state: GenerationState | None = None + + +@dataclass(frozen=True, slots=True) +class DesktopStatus: + state: GenerationState + mode: str + provider: str + ollama_reachable: bool + local_model: str + privacy_policy: str + cloud_requests_today: int | None + local_fallback: bool + data_dir: str diff --git a/src/javis/ui/workers.py b/src/javis/ui/workers.py new file mode 100644 index 0000000..c8633c0 --- /dev/null +++ b/src/javis/ui/workers.py @@ -0,0 +1,68 @@ +"""Qt worker objects for non-blocking model generation and approval.""" + +from __future__ import annotations + +import threading + +from PySide6.QtCore import QObject, Signal, Slot + +from javis.security.privacy import PrivacyDecision +from javis.ui.chat_controller import DesktopController + + +class ApprovalBridge(QObject): + approval_requested = Signal(object) + notice_received = Signal(str) + + def __init__(self) -> None: + super().__init__() + self._lock = threading.Lock() + self._event: threading.Event | None = None + self._result = False + + def request(self, decision: PrivacyDecision) -> bool: + event = threading.Event() + with self._lock: + self._event = event + self._result = False + self.approval_requested.emit(decision) + event.wait() + with self._lock: + result = self._result + self._event = None + return result + + def resolve(self, allowed: bool) -> None: + with self._lock: + self._result = allowed + event = self._event + if event is not None: + event.set() + + def cancel_pending(self) -> None: + self.resolve(False) + + +class StreamWorker(QObject): + event_received = Signal(object) + completed = Signal() + + def __init__(self, controller: DesktopController, text: str) -> None: + super().__init__() + self._controller = controller + self._text = text + self._cancel_event = threading.Event() + + @Slot() + def run(self) -> None: + try: + for event in self._controller.stream_message( + self._text, + self._cancel_event, + ): + self.event_received.emit(event) + finally: + self.completed.emit() + + def request_cancel(self) -> None: + self._cancel_event.set() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2d3b312..ebceceb 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -7,7 +7,12 @@ from types import SimpleNamespace from javis.config.settings import Settings from javis.core.chat_service import ChatService from javis.core.provider_router import HybridProvider -from javis.interface.cli import build_runtime, run_chat, run_gemini_configuration +from javis.interface.cli import ( + build_parser, + build_runtime, + run_chat, + run_gemini_configuration, +) from javis.memory.sqlite_store import SQLiteSessionStore from javis.providers.base import ChatMessage from javis.security.privacy import CloudPolicy @@ -62,6 +67,10 @@ class _LazySecrets: class CliTests(unittest.TestCase): + def test_parser_recognizes_gui_and_existing_chat(self) -> None: + self.assertEqual(build_parser().parse_args(["gui"]).command, "gui") + self.assertEqual(build_parser().parse_args(["chat"]).command, "chat") + def test_basic_chat_commands(self) -> None: with tempfile.TemporaryDirectory() as directory: store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3") diff --git a/tests/unit/test_desktop_controller.py b/tests/unit/test_desktop_controller.py new file mode 100644 index 0000000..8111c8e --- /dev/null +++ b/tests/unit/test_desktop_controller.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from javis.providers.base import ChatMessage +from javis.ui.chat_controller import DesktopController, sanitized_error + + +class _Service: + def __init__(self) -> None: + self.sessions = [ + SimpleNamespace(id="one", title="Eins"), + SimpleNamespace(id="two", title="Zwei"), + ] + + def new_session(self): + return self.sessions[0] + + def list_sessions(self): + return self.sessions + + def search_sessions(self, text: str): + return [session for session in self.sessions if text in session.title] + + def load_session(self, session_id: str): + session = next(item for item in self.sessions if item.id == session_id) + return SimpleNamespace( + session=session, + messages=[ChatMessage("user", "Hallo")], + ) + + def rename_session(self, session_id: str, title: str): + session = next(item for item in self.sessions if item.id == session_id) + session.title = title + return session + + +class _Hybrid: + mode = "auto" + last_route = None + + def set_mode(self, mode: str) -> None: + self.mode = mode + + +class DesktopControllerTests(unittest.TestCase): + def setUp(self) -> None: + self.runtime = SimpleNamespace( + service=_Service(), + hybrid_provider=_Hybrid(), + ) + self.controller = DesktopController(self.runtime) + + def test_controller_manages_sessions_without_qt_window(self) -> None: + created = self.controller.new_session() + listed = self.controller.list_sessions() + searched = self.controller.list_sessions("Zwei") + loaded = self.controller.load_session("two") + renamed = self.controller.rename_active_session("Neu") + + self.assertEqual(created.id, "one") + self.assertEqual(len(listed), 2) + self.assertEqual([session.id for session in searched], ["two"]) + self.assertEqual(loaded.messages[0].content, "Hallo") + self.assertEqual(renamed.title, "Neu") + + def test_error_sanitizer_masks_key_patterns(self) -> None: + secret = "AIza" + "x" * 25 + result = sanitized_error(RuntimeError(f"Fehler {secret}")) + + self.assertNotIn(secret, result) + self.assertIn("MASKIERT", result) diff --git a/uv.lock b/uv.lock index 6ca0c50..9fea726 100644 --- a/uv.lock +++ b/uv.lock @@ -218,6 +218,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -380,6 +389,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -423,10 +441,12 @@ source = { editable = "." } dependencies = [ { name = "google-genai" }, { name = "keyring" }, + { name = "pyside6" }, ] [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -434,10 +454,14 @@ dev = [ requires-dist = [ { name = "google-genai", specifier = ">=2.6,<3" }, { name = "keyring", specifier = ">=25.7,<26" }, + { name = "pyside6", specifier = ">=6.8,<7" }, ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.12,<1" }] +dev = [ + { name = "pytest", specifier = ">=8,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] [[package]] name = "jeepney" @@ -475,6 +499,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyasn1" version = "0.6.4" @@ -622,6 +664,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -684,6 +799,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"