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