test: verify and document desktop ui

This commit is contained in:
2026-07-30 20:26:03 +02:00
parent cf35148576
commit 571969b69b
15 changed files with 369 additions and 28 deletions
+4
View File
@@ -28,6 +28,10 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
Gedankenfilter, lesbarer Terminalausgabe und sicherem Strg+C-Abbruch. Gedankenfilter, lesbarer Terminalausgabe und sicherem Strg+C-Abbruch.
- Automatische lokale Sitzungstitel, `/rename`, nummerierte `/sessions`, - Automatische lokale Sitzungstitel, `/rename`, nummerierte `/sessions`,
`/load <NR|ID>` und begrenzte `/search`-Suche. `/load <NR|ID>` und begrenzte `/search`-Suche.
- PySide6-Desktop-MVP mit Sitzungsleiste, Markdownchat, Qt-Streamingworker,
sicherem Abbruch, Datenschutzdialog und bereinigter Statusspalte.
- Sicherer Startweg `scripts/start-javis-gui.ps1`, der die vorhandene
Loopback-/Prozesslogik des CLI-Starters wiederverwendet.
### Changed ### Changed
+12 -3
View File
@@ -5,9 +5,9 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers
## Aktueller Stand ## Aktueller Stand
Der lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`. Der lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`.
Ein optionaler Gemini-Free-Provider ist auf einem Feature-Branch vorbereitet; Gemini-Free-Hybridrouting, Datenschutz, Streaming und Sitzungsnavigation sind
Datenschutzrouter, Nullkostenmodus und lokales Fallback sind automatisiert implementiert. Zusätzlich steht ein erster PySide6-Desktopclient zur manuellen
getestet, aber noch nicht von Pascal mit einem echten Key abgenommen. Abnahme bereit.
Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile
Client und Living Mind. Client und Living Mind.
@@ -33,6 +33,15 @@ entsprechenden `JAVIS_*`-Umgebungsvariablen möglich.
Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen
sicheren benutzerspezifischen Plattformstandard. sicheren benutzerspezifischen Plattformstandard.
Desktopoberfläche:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1
```
Alternativ: `uv run javis gui`. Die [Desktop-Anleitung](docs/DESKTOP_UI.md)
beschreibt Tastenkürzel, Datenschutzdialog und den manuellen Abnahmetest.
Gemini bleibt standardmäßig aus. Die spätere lokale Einrichtung erfolgt ohne Gemini bleibt standardmäßig aus. Die spätere lokale Einrichtung erfolgt ohne
Key in der Konfigurationsdatei: Key in der Konfigurationsdatei:
+13 -13
View File
@@ -115,6 +115,8 @@ Rootserver:
Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet. Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet.
- `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`; - `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`;
`never` erreicht weiterhin keinen Cloudfreigabedialog. `never` erreicht weiterhin keinen Cloudfreigabedialog.
- Startwege `uv run javis gui` und `scripts/start-javis-gui.ps1` funktionieren;
der GUI-Starter verwendet die sichere Loopback-/Prozesslogik des CLI-Starters.
## Aktuelle Architektur ## Aktuelle Architektur
@@ -195,8 +197,12 @@ 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: 97 bestanden; zusätzlich GUI-Streamingereignisse, atomarer Abbruch - Tests: 101 bestanden; zusätzlich Qt-Offscreen-Fenster, Dialogzuordnung und
und alle drei Ergebnisse des Datenschutzdialogs abgedeckt sichere Wiederverwendung des Startskripts abgedeckt
- `uv run javis gui` im Offscreen-Modus: erfolgreich, Exitcode 0
- echter lokaler GUI-Smoke: erfolgreich; 1 Sitzung, 2 Nachrichten, Provider Ollama
- GUI-Startskript mit prozesslokalem ExecutionPolicy-Bypass: erfolgreich
- manuelle Desktop-Abnahme durch Pascal: noch offen
- 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
@@ -243,21 +249,14 @@ Abnahmestatus:
- aktueller Arbeitsbranch: `feat/desktop-ui` - aktueller Arbeitsbranch: `feat/desktop-ui`
- stabiler Ausgangsstand: `main` bei `d3e85f2` - stabiler Ausgangsstand: `main` bei `d3e85f2`
- stabiler Ausgangsstand: `main` bei `77b510b` - GUI-Grundgerüst und Kernadapter: `1658e04`
- medizinischer Datenschutz-Fix: `9148193` - Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
- Provider- und CLI-Streaming: `00a5a6c`
- SQLite-Titel und Migration: `112ae6d`
- Sitzungsnavigation: `43c1f76` - Sitzungsnavigation: `43c1f76`
- Dokumentation: `328dcd9` - Dokumentation: `328dcd9`
- Chatkomfort-Merge nach `main`: `cbba102` - Chatkomfort-Merge nach `main`: `cbba102`
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht - Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert - `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
- CLI-Verdrahtung: `af9426e`
- Startskript: `141ff2e`
- Resttests: `a0af15a`
- Routing-/Reporting-Korrektur: `7a14fe8`
- Gemini-/Datenschutz-Merge nach `main`: `564a3fd`
- `origin` verwendet HTTPS - `origin` verwendet HTTPS
- kein Force-Push und keine umgeschriebene Historie - kein Force-Push und keine umgeschriebene Historie
@@ -289,8 +288,9 @@ Abnahmestatus:
## Nächster sinnvoller Auftrag ## Nächster sinnvoller Auftrag
Auf `feat/desktop-ui` als Nächstes Offscreen-GUI-Tests, sicheres Startskript, Auf `feat/desktop-ui` als Nächstes vollständige Abschlussprüfungen ausführen,
Desktopanleitung und anschließend echten lokalen GUI-Smoke ergänzen. Dokumentation committen und Feature-Branch pushen. Danach für Pascals manuelle
Desktop-Abnahme stoppen; noch nicht nach `main` mergen.
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.
+1
View File
@@ -9,6 +9,7 @@
| ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen | | ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen |
| ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen | | ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen |
| ADR-0007 | Gemini kostenlos bevorzugen, sensible Inhalte lokal halten und niemals Billing aktivieren | angenommen | | ADR-0007 | Gemini kostenlos bevorzugen, sensible Inhalte lokal halten und niemals Billing aktivieren | angenommen |
| UI-0001 | Desktop-MVP verwendet PySide6 und denselben Javis-Kern wie die CLI | angenommen |
Der normale Runtime-Secret-Speicher ist mit dem Betriebssystem-Keyring festgelegt; Der normale Runtime-Secret-Speicher ist mit dem Betriebssystem-Keyring festgelegt;
keine XML-Secret-Datei. Offen bleiben insbesondere der genaue keine XML-Secret-Datei. Offen bleiben insbesondere der genaue
+87
View File
@@ -0,0 +1,87 @@
# Javis Desktop
## Installation und Start
Die Desktopoberfläche verwendet PySide6 und denselben Python-Kern wie der CLI-Chat.
```powershell
& 'D:\Javis-Tools\uv\uv.exe' sync --dev
& 'D:\Javis-Tools\uv\uv.exe' run javis gui
```
Der empfohlene Windows-Start prüft zusätzlich Repository, lokale Werkzeuge und
Ollama und setzt ausschließlich Prozessvariablen:
```powershell
.\scripts\start-javis-gui.ps1
```
Falls Windows den direkten Skriptaufruf per Richtlinie blockiert, funktioniert
ohne systemweite Änderung der prozesslokale Start:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1
```
Der bisherige Start über `.\scripts\start-javis.ps1` beziehungsweise
`uv run javis chat` bleibt unverändert verfügbar.
## Bedienung
- `Enter`: Nachricht senden
- `Shift+Enter`: neue Zeile
- `Ctrl+N`: neue Sitzung
- `Ctrl+K`: Sitzungssuche fokussieren
- `Esc`: laufende Antwort abbrechen
- Doppelklick: Sitzung laden
- Kontextmenü einer Sitzung: umbenennen
Die linke Spalte verwaltet Sitzungen. In der Mitte erscheinen Verlauf,
Markdownantwort und Eingabe. Rechts stehen nur bereinigte technische Metadaten:
Zustand, Modus, Provider, Ollama-Erreichbarkeit, Modell, Datenschutzentscheidung,
Cloudzähler, Fallback und Datenverzeichnis.
## Provider und Datenschutz
`Hybrid/Auto` verwendet weiterhin den vorhandenen lokalen Datenschutzrouter.
`Lokal` verhindert Cloudnutzung. Bei `ask` stehen drei Entscheidungen bereit:
- `Einmal erlauben`: minimierten notwendigen Kontext einmalig an Gemini senden
- `Lokal beantworten`: ohne Cloud mit Ollama antworten
- `Abbrechen`: Anfrage ohne Speicherung beenden
`never` wird ausschließlich lokal verarbeitet und zeigt keinen Freigabedialog.
Der Gemini-Key bleibt im Betriebssystem-Keyring und wird in der Oberfläche weder
ausgegeben noch dokumentiert.
## Streaming und Abbruch
Die Modellgenerierung läuft in einem Qt-Worker. Es kann nur eine Antwort
gleichzeitig laufen. `Stoppen`, `Esc` oder kontrolliertes Schließen fordern den
Abbruch an. Ein unvollständiges Benutzer-/Assistentenpaar wird nicht gespeichert.
## Bekannte Grenzen
- keine Sprache, kein Mikrofon und kein Wakeword
- keine Betriebssystem-, App-, Obsidian-, Server- oder Laptopsteuerung
- keine autonomen Aktionen und keine neuen Cloudanbieter
- Schließen kann kurz warten, bis der lokale Netzwerkstream kontrolliert reagiert
- medizinische Antwortqualität ist ein späteres Sicherheits-/Systemprompt-Thema
## Manueller Abnahmetest für Pascal
1. `.\scripts\start-javis-gui.ps1`
2. Prüfen, dass sich das Fenster öffnet.
3. Eine lokale Frage stellen.
4. Die gestreamte Antwort prüfen.
5. Eine neue Sitzung anlegen.
6. Die alte Sitzung laden.
7. Eine Antwort mit `Esc` oder `Stoppen` abbrechen.
8. Den Modus `Lokal` testen.
9. Eine öffentliche Technikfrage im Hybridmodus testen.
10. Eine `ask`-Anfrage lokal beantworten lassen.
11. Anwendung schließen und neu starten.
12. Die gespeicherte Sitzung erneut laden.
Abnahmestatus: automatisierte Tests und echter lokaler GUI-Smoke durch Codex
bestanden; manuelle Abnahme durch Pascal steht noch aus.
+5 -5
View File
@@ -10,14 +10,14 @@ Chatkomfort umfasst sicheres Ollama-/Gemini-Streaming, Strg+C ohne Teilpersisten
medizinisches `never`, automatische und manuelle Sitzungstitel, nummerierte medizinisches `never`, automatische und manuelle Sitzungstitel, nummerierte
Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten. Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten.
91 Unit-Tests, Ruff, Struktur-, Secret-, Gitignore- und Auditprüfungen sowie Das Desktop-MVP auf `feat/desktop-ui` verwendet denselben Kern wie die CLI.
echte Ollama- und Gemini-Streaming-Smokes sind erfolgreich. 101 Tests, Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke sind erfolgreich.
Die manuelle Abnahme durch Pascal steht noch aus.
## Nächster sinnvoller Schritt ## Nächster sinnvoller Schritt
Der Chatkomfort-Branch ist nach `main` gemergt und dort erneut geprüft. Den Nur den Desktop-MVP nach `docs/DESKTOP_UI.md` manuell abnehmen. Erst danach mit
finalen Stand aus `docs/CHATGPT_HANDOFF.md` übernehmen und mit Pascal ein Pascal ein einzelnes nächstes Paket festlegen; keine Folgephase automatisch beginnen.
einzelnes nächstes Paket festlegen; noch keine Folgephase automatisch beginnen.
## Weiterhin nicht beginnen ## Weiterhin nicht beginnen
+18 -3
View File
@@ -1,6 +1,6 @@
# Projektstatus # Projektstatus
Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026. Stand: Desktop-MVP automatisiert und mit lokalem Modell geprüft, 30.07.2026.
## Erreicht ## Erreicht
@@ -28,7 +28,7 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026.
Inhalte werden nicht wörtlich in automatische Titel übernommen Inhalte werden nicht wörtlich in automatische Titel übernommen
- echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem - echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem
neuen Prozess bestätigt neuen Prozess bestätigt
- 91 Unit-Tests, Ruff, Struktur-, Gitignore-, Secret- und Auditprüfung erfolgreich - 101 Tests, Ruff, Struktur-, Gitignore-, Secret- und Auditprüfung erfolgreich
- lokaler Core-Chat am 30.07.2026 zusätzlich von Pascal manuell erfolgreich - lokaler Core-Chat am 30.07.2026 zusätzlich von Pascal manuell erfolgreich
geprüft: deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart, geprüft: deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart,
`/load <UUID>`, vollständiger Verlauf und Fortsetzung `/load <UUID>`, vollständiger Verlauf und Fortsetzung
@@ -96,9 +96,24 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026.
Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client, Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client,
Living-Mind-Frontend. Living-Mind-Frontend.
## Desktop-MVP
- PySide6-Client verwendet Runtime, Chatservice, Hybridprovider, Datenschutz,
SQLite, Audit und Secret-Provider des bestehenden Kerns
- Sitzungen, Suche, Umbenennen, Markdown, Streaming, Abbruch, Modus,
Dreifach-Freigabedialog und bereinigter Status implementiert
- `uv run javis gui` und `scripts/start-javis-gui.ps1` vorhanden
- Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke bestanden
- automatisiert durch Codex getestet: ja
- echter lokaler GUI-Smoke durch Codex: ja; 1 Sitzung, 2 Nachrichten, Ollama
- manuell durch Pascal abgenommen: noch nicht
## Git ## Git
- aktueller Branch: `main` - aktueller Branch: `feat/desktop-ui`
- Desktop-Basis: `main` bei `d3e85f2`
- GUI-Grundgerüst: `1658e04`
- Sitzungen/Streaming/Datenschutz: `cf35148`
- Basis: `main` bei `77b510b` - Basis: `main` bei `77b510b`
- medizinisches Routing: `9148193` - medizinisches Routing: `9148193`
- Streaming: `00a5a6c` - Streaming: `00a5a6c`
+20
View File
@@ -0,0 +1,20 @@
[CmdletBinding()]
param(
[string]$UvPath,
[string]$OllamaPath,
[string]$OllamaModels,
[string]$DataDir,
[string]$UvCacheDir,
[string]$PythonInstallDir
)
$ErrorActionPreference = 'Stop'
$env:JAVIS_START_INTERFACE = 'gui'
$sharedStarter = Join-Path $PSScriptRoot 'start-javis.ps1'
if (-not (Test-Path -LiteralPath $sharedStarter -PathType Leaf)) {
throw "Das gemeinsame sichere Javis-Startskript wurde nicht gefunden."
}
& $sharedStarter @PSBoundParameters
exit $LASTEXITCODE
+6 -1
View File
@@ -113,7 +113,12 @@ if (-not (Test-OllamaReady)) {
Push-Location $repositoryRoot Push-Location $repositoryRoot
try { try {
& $resolvedUv run javis chat if ($env:JAVIS_START_INTERFACE -eq 'gui') {
& $resolvedUv run javis gui
}
else {
& $resolvedUv run javis chat
}
exit $LASTEXITCODE exit $LASTEXITCODE
} }
finally { finally {
+9
View File
@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from collections.abc import Sequence from collections.abc import Sequence
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
@@ -32,6 +34,13 @@ def run_gui(argv: Sequence[str] | None = None) -> int:
return 2 return 2
window = MainWindow(DesktopController(runtime), approval_bridge) window = MainWindow(DesktopController(runtime), approval_bridge)
window.show() window.show()
smoke_exit = os.environ.get("JAVIS_GUI_SMOKE_EXIT_MS")
if smoke_exit:
try:
delay = max(1, min(int(smoke_exit), 10_000))
except ValueError:
delay = 500
QTimer.singleShot(delay, application.quit)
return application.exec() return application.exec()
+5 -1
View File
@@ -8,7 +8,7 @@ from collections.abc import Iterator
from javis.core.runtime import JavisRuntime from javis.core.runtime import JavisRuntime
from javis.memory.sqlite_store import ChatSession from javis.memory.sqlite_store import ChatSession
from javis.providers.base import ProviderError from javis.providers.base import ProviderError, ResponseAbortedError
from javis.ui.models import ( from javis.ui.models import (
DesktopStatus, DesktopStatus,
GenerationState, GenerationState,
@@ -87,6 +87,10 @@ class DesktopController:
self.state = GenerationState.ANSWERING self.state = GenerationState.ANSWERING
yield StreamEvent(StreamEventKind.STATE, state=self.state) yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text=chunk) yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
except ResponseAbortedError:
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
except ProviderError as exc: except ProviderError as exc:
if cancel_event.is_set(): if cancel_event.is_set():
self.state = GenerationState.ABORTED self.state = GenerationState.ABORTED
+7 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from html import escape from html import escape
from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtCore import QSignalBlocker, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QShortcut from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -375,6 +375,12 @@ class MainWindow(QMainWindow):
self._show_error(str(exc)) self._show_error(str(exc))
return return
self._set_state(status.state) self._set_state(status.state)
target_mode = "local" if status.mode == "Lokal" else "auto"
mode_index = self.mode_combo.findData(target_mode)
if mode_index >= 0 and mode_index != self.mode_combo.currentIndex():
blocker = QSignalBlocker(self.mode_combo)
self.mode_combo.setCurrentIndex(mode_index)
del blocker
self.status_labels["provider"].setText(status.provider) self.status_labels["provider"].setText(status.provider)
self.status_labels["ollama"].setText("Ja" if status.ollama_reachable else "Nein") self.status_labels["ollama"].setText("Ja" if status.ollama_reachable else "Nein")
self.status_labels["model"].setText(status.local_model) self.status_labels["model"].setText(status.local_model)
+23 -1
View File
@@ -8,7 +8,7 @@ from types import SimpleNamespace
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
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, ResponseAbortedError
from javis.ui.chat_controller import DesktopController, sanitized_error from javis.ui.chat_controller import DesktopController, sanitized_error
from javis.ui.models import GenerationState, StreamEventKind from javis.ui.models import GenerationState, StreamEventKind
@@ -64,6 +64,12 @@ class _StreamingProvider:
self.mode = mode self.mode = mode
class _AbortingProvider(_StreamingProvider):
def stream_chat(self, _messages):
raise ResponseAbortedError("Vom Dialog abgebrochen")
yield
class DesktopControllerTests(unittest.TestCase): class DesktopControllerTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.runtime = SimpleNamespace( self.runtime = SimpleNamespace(
@@ -131,3 +137,19 @@ class DesktopControllerTests(unittest.TestCase):
self.assertEqual(events[-1].state, GenerationState.ABORTED) self.assertEqual(events[-1].state, GenerationState.ABORTED)
session = service.list_sessions()[0] session = service.list_sessions()[0]
self.assertEqual(service.load_session(session.id).messages, []) self.assertEqual(service.load_session(session.id).messages, [])
def test_provider_abort_is_reported_as_aborted_not_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
provider = _AbortingProvider()
service = ChatService(
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
provider,
)
controller = DesktopController(
SimpleNamespace(service=service, hybrid_provider=provider)
)
events = list(controller.stream_message("Hallo", threading.Event()))
self.assertEqual(events[-1].state, GenerationState.ABORTED)
self.assertNotIn(StreamEventKind.ERROR, [event.kind for event in events])
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import os
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from javis.core.provider_router import ApprovalChoice
from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.main_window import MainWindow
from javis.ui.models import DesktopStatus, GenerationState
from javis.ui.workers import ApprovalBridge
class _Controller:
def __init__(self) -> None:
self.active_session_id = None
self.state = GenerationState.READY
self.session = SimpleNamespace(
id="session-one",
title="Desktop-Test",
last_provider="ollama",
message_count=2,
updated_at="2026-07-30T20:00:00+00:00",
)
def list_sessions(self, search: str = ""):
return [self.session] if not search or search in self.session.title else []
def load_session(self, session_id: str):
self.active_session_id = session_id
return SimpleNamespace(
session=self.session,
messages=[
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
],
)
def new_session(self):
self.active_session_id = self.session.id
return self.session
def rename_active_session(self, title: str):
self.session.title = title
return self.session
def set_mode(self, _mode: str) -> None:
return
def status(self) -> DesktopStatus:
return DesktopStatus(
state=self.state,
mode="Lokal",
provider="ollama",
ollama_reachable=True,
local_model="test-model",
privacy_policy="never",
cloud_requests_today=0,
local_fallback=False,
data_dir="D:\\Testdaten",
)
class _MessageBox:
Question = object()
AcceptRole = object()
RejectRole = object()
DestructiveRole = object()
selected_label = ""
def __init__(self, _parent) -> None:
self.buttons: dict[str, object] = {}
def setWindowTitle(self, _text: str) -> None:
return
def setIcon(self, _icon: object) -> None:
return
def setText(self, _text: str) -> None:
return
def setInformativeText(self, _text: str) -> None:
return
def addButton(self, label: str, _role: object) -> object:
button = object()
self.buttons[label] = button
return button
def exec(self) -> None:
return
def clickedButton(self) -> object | None:
return self.buttons.get(type(self).selected_label)
class DesktopGuiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.application = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.bridge = ApprovalBridge()
self.window = MainWindow(_Controller(), self.bridge)
self.window.show()
self.application.processEvents()
def tearDown(self) -> None:
self.window.close()
self.application.processEvents()
def test_offscreen_window_loads_session_and_core_widgets(self) -> None:
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
self.assertEqual(self.window.session_list.count(), 1)
self.assertIn("Hallo", self.window.chat_view.toPlainText())
self.assertTrue(self.window.send_button.isEnabled())
self.assertFalse(self.window.stop_button.isEnabled())
self.assertEqual(self.window.status_labels["provider"].text(), "ollama")
def test_approval_dialog_maps_all_three_choices(self) -> None:
decision = PrivacyDecision(CloudPolicy.ASK, "persönliche Information")
self.bridge.resolve = Mock()
expected = {
"Einmal erlauben": ApprovalChoice.ALLOW,
"Lokal beantworten": ApprovalChoice.LOCAL,
"Abbrechen": ApprovalChoice.CANCEL,
}
with patch("javis.ui.main_window.QMessageBox", _MessageBox):
for label, choice in expected.items():
with self.subTest(label=label):
_MessageBox.selected_label = label
self.window._show_approval(decision)
self.bridge.resolve.assert_called_with(choice)
+18
View File
@@ -25,6 +25,24 @@ class StartScriptTests(unittest.TestCase):
): ):
self.assertNotIn(forbidden, normalized) self.assertNotIn(forbidden, normalized)
def test_gui_script_reuses_safe_starter_without_system_changes(self) -> None:
repository_root = Path(__file__).resolve().parents[2]
script = (repository_root / "scripts" / "start-javis-gui.ps1").read_text(encoding="utf-8")
normalized = script.lower()
self.assertIn("start-javis.ps1", normalized)
self.assertIn("javis_start_interface", normalized)
self.assertIn("'gui'", normalized)
self.assertNotIn("c:\\users\\", normalized)
for forbidden in (
"set-executionpolicy",
"setx ",
"new-service",
"schtasks",
"hkey_",
):
self.assertNotIn(forbidden, normalized)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()