feat: add desktop ui foundation
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""PySide6 desktop interface for the existing Javis core."""
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user