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
+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.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
+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()