feat: add desktop ui foundation

This commit is contained in:
2026-07-30 20:15:20 +02:00
parent d3e85f2077
commit 1658e0485e
13 changed files with 1069 additions and 84 deletions
+8 -10
View File
@@ -109,6 +109,8 @@ Rootserver:
- Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile. - Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile.
Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown
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-
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden.
## Aktuelle Architektur ## Aktuelle Architektur
@@ -189,8 +191,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: 91 bestanden; Navigation, Streaming, Migration, Datenschutz, - Unit-Tests: 94 bestanden; zusätzlich Runtime-Adapter, Qt-freier Controller,
ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen abgedeckt Secretmaskierung sowie neuer und bestehender CLI-Parserpfad 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
@@ -235,7 +237,8 @@ Abnahmestatus:
## Git-Stand ## Git-Stand
- aktueller und geprüfter Branch: `main` - aktueller Arbeitsbranch: `feat/desktop-ui`
- stabiler Ausgangsstand: `main` bei `d3e85f2`
- stabiler Ausgangsstand: `main` bei `77b510b` - stabiler Ausgangsstand: `main` bei `77b510b`
- medizinischer Datenschutz-Fix: `9148193` - medizinischer Datenschutz-Fix: `9148193`
- Provider- und CLI-Streaming: `00a5a6c` - Provider- und CLI-Streaming: `00a5a6c`
@@ -248,11 +251,6 @@ Abnahmestatus:
- konfliktfreier Merge nach `main`: `564a3fd` - konfliktfreier Merge nach `main`: `564a3fd`
- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - 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
- Secret-Provider: `570e7fa`
- Datenschutz/Nullkosten: `286d96d`
- isolierter Gemini-Provider: `f3e1183`
- Hybridrouter: `e57186f`
- lokale Konfiguration: `ab6ea48`
- CLI-Verdrahtung: `af9426e` - CLI-Verdrahtung: `af9426e`
- Startskript: `141ff2e` - Startskript: `141ff2e`
- Resttests: `a0af15a` - Resttests: `a0af15a`
@@ -289,8 +287,8 @@ Abnahmestatus:
## Nächster sinnvoller Auftrag ## Nächster sinnvoller Auftrag
Chatkomfort ist implementiert, gemergt, auf `main` erneut geprüft und gepusht. Auf `feat/desktop-ui` als Nächstes Freigabedialog mit drei Ergebnissen,
Jetzt stoppen und mit Pascal erst später ein einzelnes nächstes Paket festlegen. Qt-Worker-Abbruch, Sitzungsbedienung und bereinigte Statusanzeige gezielt testen.
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.
+2
View File
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"google-genai>=2.6,<3", "google-genai>=2.6,<3",
"keyring>=25.7,<26", "keyring>=25.7,<26",
"pyside6>=6.8,<7",
] ]
[project.scripts] [project.scripts]
@@ -18,6 +19,7 @@ javis = "javis.interface.cli:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8,<9",
"ruff>=0.12,<1", "ruff>=0.12,<1",
] ]
+145
View File
@@ -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,
)
+15 -72
View File
@@ -12,19 +12,15 @@ from pathlib import Path
from javis.config.local_config import LocalConfigError, write_gemini_activation from javis.config.local_config import LocalConfigError, write_gemini_activation
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
from javis.core.chat_service import ChatService, SessionProviderMismatchError 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 ( from javis.memory.sqlite_store import (
AmbiguousSessionReferenceError, AmbiguousSessionReferenceError,
ChatSession, ChatSession,
SessionNotFoundError, SessionNotFoundError,
SessionStoreError, SessionStoreError,
SQLiteSessionStore,
) )
from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError from javis.memory.usage_store import UsageStoreError
from javis.providers.base import MissingApiKeyError, ProviderError from javis.providers.base import ProviderError
from javis.providers.gemini import GeminiProvider
from javis.providers.ollama import OllamaProvider
from javis.security.privacy import PrivacyDecision, PrivacyRouter
from javis.security.secrets import SecretProvider, SecretStoreError from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str] InputFunction = Callable[[str], str]
@@ -52,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") 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_parser = subparsers.add_parser(
"configure", "configure",
help="nicht geheime lokale Einstellungen einrichten", help="nicht geheime lokale Einstellungen einrichten",
@@ -385,79 +382,21 @@ def build_runtime(
output: OutputFunction, output: OutputFunction,
secret_provider: SecretProvider | None = None, secret_provider: SecretProvider | None = None,
) -> tuple[ChatService, StatusFunction]: ) -> tuple[ChatService, StatusFunction]:
store = SQLiteSessionStore(settings.database_path) def approve(_decision: object) -> bool:
usage_store = SQLiteUsageStore(settings.usage_database_path) answer = input_fn("Cloudfreigabe erforderlich. Einmalig erlauben? [j/N] ")
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] "
)
return _confirmed(answer) return _confirmed(answer)
def create_cloud_provider() -> GeminiProvider: runtime = build_javis_runtime(
try: settings,
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=approve, approval_callback=approve,
notice_callback=output, notice_callback=output,
mode=settings.provider_mode, secret_provider=secret_provider,
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,
) )
def status() -> list[str]: def status() -> list[str]:
ollama = local.probe() return runtime.status().cli_lines()
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 ChatService(store, hybrid), status return runtime.service, status
def main( def main(
@@ -484,6 +423,10 @@ def main(
input_fn=input_fn, input_fn=input_fn,
output=output, output=output,
) )
if args.command == "gui":
from javis.ui.app import run_gui
return run_gui([])
if args.command != "chat": if args.command != "chat":
return 2 return 2
+1
View File
@@ -0,0 +1 @@
"""PySide6 desktop interface for the existing Javis core."""
+43
View File
@@ -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())
+114
View File
@@ -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,
)
+419
View File
@@ -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()
+43
View File
@@ -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
+68
View File
@@ -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()
+10 -1
View File
@@ -7,7 +7,12 @@ from types import SimpleNamespace
from javis.config.settings import Settings from javis.config.settings import Settings
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
from javis.core.provider_router import HybridProvider 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.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy from javis.security.privacy import CloudPolicy
@@ -62,6 +67,10 @@ class _LazySecrets:
class CliTests(unittest.TestCase): 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: def test_basic_chat_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3") store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
+73
View File
@@ -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)
Generated
+128 -1
View File
@@ -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" }, { 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]] [[package]]
name = "cryptography" name = "cryptography"
version = "49.0.0" 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" }, { 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]] [[package]]
name = "jaraco-classes" name = "jaraco-classes"
version = "3.4.0" version = "3.4.0"
@@ -423,10 +441,12 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "google-genai" }, { name = "google-genai" },
{ name = "keyring" }, { name = "keyring" },
{ name = "pyside6" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -434,10 +454,14 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "google-genai", specifier = ">=2.6,<3" }, { name = "google-genai", specifier = ">=2.6,<3" },
{ name = "keyring", specifier = ">=25.7,<26" }, { name = "keyring", specifier = ">=25.7,<26" },
{ name = "pyside6", specifier = ">=6.8,<7" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.12,<1" }] dev = [
{ name = "pytest", specifier = ">=8,<9" },
{ name = "ruff", specifier = ">=0.12,<1" },
]
[[package]] [[package]]
name = "jeepney" 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" }, { 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]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.4" 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" }, { 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]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.3" 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" }, { 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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"